feat(web): add interactive sidebar terminals
This commit is contained in:
1 parent
6d99a8a53a
commit
e15a9b1bec
132 files changed
+5985
-101
No files matched your search
@@ -0,0 +1,73 @@
|
||||
/** Unfinished close requests survive reload independently of the removed sidebar tabs. */
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { WebTerminalId } from '../types.ts'
|
||||
|
||||
/** An explicit cleanup request; no process or open-tab metadata is mirrored here. */
|
||||
export interface TerminalCloseRequest {
|
||||
readonly sessionId: SessionId
|
||||
readonly id: WebTerminalId
|
||||
readonly title: string
|
||||
}
|
||||
|
||||
const PREFIX = 'dsh.terminal.close.v1.'
|
||||
|
||||
/** Each request has its own storage key, so other browser windows cannot overwrite its cleanup. */
|
||||
export class TerminalCloseRequests {
|
||||
private readonly requests = new Map<WebTerminalId, TerminalCloseRequest>()
|
||||
|
||||
constructor() {
|
||||
try {
|
||||
if (typeof localStorage === 'undefined') return
|
||||
for (let index = 0; index < localStorage.length; index++) {
|
||||
const key = localStorage.key(index)
|
||||
if (key?.startsWith(PREFIX)) this.load(key)
|
||||
}
|
||||
} catch (error) { console.error('Terminal cleanup recovery failed:', error) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Read cleanup work still awaiting Host confirmation.
|
||||
* @returns unfinished requests owned by this browser instance.
|
||||
*/
|
||||
pending(): readonly TerminalCloseRequest[] { return [...this.requests.values()] }
|
||||
|
||||
/**
|
||||
* Retain cleanup across reload before removing a tab.
|
||||
* @param request - close intent to save before removing its tab.
|
||||
*/
|
||||
save(request: TerminalCloseRequest): void {
|
||||
this.requests.set(request.id, request)
|
||||
try {
|
||||
if (typeof localStorage !== 'undefined') localStorage.setItem(PREFIX + request.id, JSON.stringify(request))
|
||||
} catch (error) { console.error('Terminal cleanup persistence failed:', error) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Forget confirmed cleanup in memory and browser storage.
|
||||
* @param id - terminal whose Host cleanup succeeded.
|
||||
*/
|
||||
remove(id: WebTerminalId): void {
|
||||
this.requests.delete(id)
|
||||
try {
|
||||
if (typeof localStorage !== 'undefined') localStorage.removeItem(PREFIX + id)
|
||||
} catch (error) { console.error('Terminal cleanup persistence failed:', error) }
|
||||
}
|
||||
|
||||
private load(key: string): void {
|
||||
try {
|
||||
const raw = localStorage.getItem(key)
|
||||
if (raw === null) return
|
||||
const parsed: unknown = JSON.parse(raw)
|
||||
if (!isRequest(parsed) || key !== PREFIX + parsed.id) throw new Error('Invalid terminal cleanup request')
|
||||
this.requests.set(parsed.id, parsed)
|
||||
} catch (error) { console.error('Terminal cleanup recovery failed:', error) }
|
||||
}
|
||||
}
|
||||
|
||||
function isRequest(value: unknown): value is TerminalCloseRequest {
|
||||
if (typeof value !== 'object' || value === null) return false
|
||||
const request = value as Record<string, unknown>
|
||||
return typeof request.sessionId === 'string' && request.sessionId.length > 0
|
||||
&& typeof request.id === 'string' && /^[\w-]{1,128}$/u.test(request.id)
|
||||
&& typeof request.title === 'string'
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
/** Client terminal model service; views are keyed independently from Host terminal identities. */
|
||||
import { Service, type Context } from '@deepseek-ai/cordis'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type {} from '@deepseek-ai/dsh-api-gateway/client'
|
||||
import { TerminalView, type TerminalRemote } from './model.ts'
|
||||
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import { randomUUID } from '@deepseek-ai/dsh-util-crypto'
|
||||
import type { WebTerminalId, WebTerminalInfo } from '../types.ts'
|
||||
import { TerminalCloseRequests, type TerminalCloseRequest } from './close-requests.ts'
|
||||
|
||||
export type { TerminalView, TerminalViewState, TerminalRenderFrame, TerminalRemote } from './model.ts'
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Context {
|
||||
/** React-free browser terminal views and explicit process cleanup. */
|
||||
webTerminals: ClientTerminals
|
||||
}
|
||||
}
|
||||
|
||||
/** A failed background close that can be retried without restoring its tab. */
|
||||
export interface TerminalCloseFailure {
|
||||
readonly id: WebTerminalId
|
||||
readonly title: string
|
||||
readonly message: string
|
||||
}
|
||||
|
||||
/** Session and occurrence lookup, independent tab and terminal identities and background cleanup. */
|
||||
export class ClientTerminals extends Service {
|
||||
/** Failed cleanup tasks; successful and in-progress closes have no visible notification. */
|
||||
readonly closeFailures: SnapshotStore<readonly TerminalCloseFailure[]> = createSnapshotStore<readonly TerminalCloseFailure[]>([])
|
||||
private readonly requests = new TerminalCloseRequests()
|
||||
private readonly closing = new Map<WebTerminalId, Promise<void>>()
|
||||
private readonly closed = new Set<WebTerminalId>(this.requests.pending().map(request => request.id))
|
||||
private disposed = false
|
||||
private readonly views = new Map<SessionId, Map<string, TerminalView>>()
|
||||
|
||||
/**
|
||||
* @param ctx - Client root Context with Gateway and terminal Remote namespace.
|
||||
* @param remote - generated terminal namespace.
|
||||
*/
|
||||
constructor(ctx: Context, private readonly remote: TerminalRemote) {
|
||||
super(ctx, 'webTerminals')
|
||||
ctx.effect(() => async () => {
|
||||
this.disposed = true
|
||||
for (const views of this.views.values()) for (const view of views.values()) view.dispose()
|
||||
this.views.clear()
|
||||
await Promise.all(this.closing.values())
|
||||
}, 'terminal-controller.client.views')
|
||||
for (const request of this.requests.pending()) this.cleanup(request)
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the stable model for one sidebar occurrence.
|
||||
* @param sessionId - owning Session.
|
||||
* @param key - sidebar occurrence key.
|
||||
* @param terminalId - existing Host identity when restoring a listed terminal.
|
||||
* @returns its observable state and terminal commands.
|
||||
*/
|
||||
view(sessionId: SessionId, key: string, terminalId?: WebTerminalId): TerminalView {
|
||||
let views = this.views.get(sessionId)
|
||||
if (views === undefined) { views = new Map(); this.views.set(sessionId, views) }
|
||||
let view = views.get(key)
|
||||
if (view === undefined) {
|
||||
const id = terminalId ?? randomUUID() as WebTerminalId
|
||||
view = new TerminalView(sessionId, this.remote, this.ctx.remote, id, terminalId === undefined)
|
||||
views.set(key, view)
|
||||
void view.refresh()
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a close intent and release the tab immediately; cleanup outlives DOM unmount and reload.
|
||||
* @param sessionId - owning Session.
|
||||
* @param key - sidebar occurrence key, including an inactive restored tab.
|
||||
* @param terminalId - restored identity if the tab has no model yet.
|
||||
*/
|
||||
close(sessionId: SessionId, key: string, terminalId?: WebTerminalId): void {
|
||||
const views = this.views.get(sessionId)
|
||||
const view = views?.get(key)
|
||||
const id = view?.id ?? terminalId
|
||||
if (id === undefined) return
|
||||
const request: TerminalCloseRequest = { sessionId, id, title: view?.state.getSnapshot().title ?? key }
|
||||
this.closed.add(id)
|
||||
this.requests.save(request)
|
||||
views?.delete(key)
|
||||
if (views?.size === 0) this.views.delete(sessionId)
|
||||
this.cleanup(request, view)
|
||||
}
|
||||
|
||||
/**
|
||||
* Query Host terminals that have neither a tab in this page nor an unfinished close.
|
||||
* @param sessionId - Session being displayed.
|
||||
* @returns terminals available for opening as recovered tabs.
|
||||
*/
|
||||
async recover(sessionId: SessionId): Promise<WebTerminalInfo[]> {
|
||||
const result = await this.remote.list(sessionId)
|
||||
if (!result.ok) throw new Error(result.error.message)
|
||||
const held = new Set([...(this.views.get(sessionId)?.values() ?? [])].map(view => view.id))
|
||||
const closing = new Set(this.requests.pending().map(request => request.id))
|
||||
return result.value.filter(info => !held.has(info.id) && !closing.has(info.id) && !this.closed.has(info.id))
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry a saved close request without reopening its tab.
|
||||
* @param id - failed terminal identity.
|
||||
*/
|
||||
retryClose(id: WebTerminalId): void {
|
||||
const record = this.requests.pending().find(item => item.id === id)
|
||||
if (record !== undefined) this.cleanup(record)
|
||||
}
|
||||
|
||||
private cleanup(record: TerminalCloseRequest, view?: TerminalView): void {
|
||||
if (this.closing.has(record.id) || this.disposed) return
|
||||
this.closeFailures.set(this.closeFailures.getSnapshot().filter(failure => failure.id !== record.id))
|
||||
const pending = (async () => {
|
||||
if (view !== undefined) await view.close()
|
||||
else {
|
||||
const result = await this.remote.close(record.sessionId, record.id)
|
||||
if (!result.ok) throw new Error(result.error.message)
|
||||
}
|
||||
this.requests.remove(record.id)
|
||||
})().catch((error: unknown) => {
|
||||
if (!this.disposed) this.closeFailures.set([...this.closeFailures.getSnapshot(), {
|
||||
id: record.id, title: record.title,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
}])
|
||||
}).finally(() => {
|
||||
view?.dispose()
|
||||
this.closing.delete(record.id)
|
||||
})
|
||||
this.closing.set(record.id, pending)
|
||||
}
|
||||
}
|
||||
|
||||
/** Required Client transport and terminal namespace. */
|
||||
export const inject = ['remote', 'remote.terminal']
|
||||
|
||||
/**
|
||||
* Install the Client terminal models.
|
||||
* @param ctx - Client root Context.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
new ClientTerminals(ctx, ctx.remote.terminal)
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
/** React-free browser terminal state and reconnecting Remote-stream ownership. */
|
||||
import { randomUUID } from '@deepseek-ai/dsh-util-crypto'
|
||||
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import { RemoteStreamCarrierError, type ClientRemote, type RemoteStream } from '@deepseek-ai/dsh-api-gateway/client'
|
||||
import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import type {} from '@deepseek-ai/dsh-api-terminal-controller/remote'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type {
|
||||
TerminalAttachmentId, TerminalEnvironment, TerminalFrame,
|
||||
WebTerminalId, WebTerminalInfo,
|
||||
} from '../types.ts'
|
||||
|
||||
/** The generated terminal namespace's browser-facing operations. */
|
||||
export type TerminalRemote = ClientRemote['terminal']
|
||||
|
||||
/** One screen write awaiting the DOM emulator's callback. */
|
||||
export interface TerminalRenderFrame {
|
||||
readonly revision: number
|
||||
readonly frame: Extract<TerminalFrame, { type: 'snapshot' | 'output' }>
|
||||
}
|
||||
|
||||
/** Observable state of one sidebar occurrence. */
|
||||
export interface TerminalViewState {
|
||||
readonly phase: 'idle' | 'loading' | 'creating' | 'connecting' | 'connected' | 'disconnected' | 'closing' | 'closed' | 'failed'
|
||||
readonly environment?: TerminalEnvironment | undefined
|
||||
readonly title?: string | undefined
|
||||
readonly info?: WebTerminalInfo | undefined
|
||||
readonly writable: boolean
|
||||
readonly render?: TerminalRenderFrame | undefined
|
||||
readonly error?: string | undefined
|
||||
}
|
||||
|
||||
/** A view survives DOM unmount; its process only ends on explicit close. */
|
||||
export class TerminalView {
|
||||
/** Observable controls, process metadata and the next screen update awaiting acknowledgement. */
|
||||
readonly state: SnapshotStore<TerminalViewState> = createSnapshotStore<TerminalViewState>({ phase: 'idle', writable: false })
|
||||
private readonly lifetime = new AbortController()
|
||||
private stream: RemoteStream<TerminalFrame> | undefined
|
||||
private mounted = false
|
||||
private attachmentId: TerminalAttachmentId | undefined
|
||||
private pendingRender: { revision: number; resolve: () => void } | undefined
|
||||
private revision = 0
|
||||
private creation: Promise<void> | undefined
|
||||
private loading: Promise<void> | undefined
|
||||
private closing: Promise<void> | undefined
|
||||
private writes: Promise<void> = Promise.resolve()
|
||||
private queuedInput = 0
|
||||
|
||||
/**
|
||||
* @param sessionId - Session owning the terminal.
|
||||
* @param remote - typed terminal Remote operations.
|
||||
* @param gateway - reconnecting stream factory.
|
||||
* @param id - Host terminal identity, reused when recovering an item from its Session list.
|
||||
* @param createWhenMissing - allow allocation only for a new tab, never a listed terminal.
|
||||
*/
|
||||
constructor(
|
||||
private readonly sessionId: SessionId,
|
||||
private readonly remote: TerminalRemote,
|
||||
private readonly gateway: Pick<ClientRemote, '$stream'>,
|
||||
readonly id: WebTerminalId,
|
||||
private readonly createWhenMissing = true,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Attach the DOM lifetime, starting the default shell or reconnecting the saved process.
|
||||
* @returns a detach callback that leaves the terminal process alive.
|
||||
*/
|
||||
mount(): () => void {
|
||||
this.mounted = true
|
||||
if (this.state.getSnapshot().info === undefined) void this.refresh()
|
||||
else this.connect()
|
||||
return () => {
|
||||
this.mounted = false
|
||||
this.detach()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start or recover this tab, deduplicating mounts and retries during allocation.
|
||||
* Only a new tab may allocate a shell; listed terminals cannot be silently replaced.
|
||||
* @returns after environment lookup and creation or recovery settle.
|
||||
*/
|
||||
refresh(): Promise<void> {
|
||||
if (this.loading !== undefined) return this.loading
|
||||
if (this.closing !== undefined || this.lifetime.signal.aborted) return Promise.resolve()
|
||||
this.patch({ phase: 'loading', error: undefined })
|
||||
this.loading = (async () => {
|
||||
const [environment, available] = await Promise.all([
|
||||
this.remote.environment(this.sessionId, this.lifetime.signal), this.remote.list(this.sessionId),
|
||||
])
|
||||
if (this.lifetime.signal.aborted || this.closing !== undefined) return
|
||||
this.patch({ environment: valueOf(environment) })
|
||||
const info = valueOf(available).find(item => item.id === this.id)
|
||||
if (info !== undefined) this.adopt(info)
|
||||
else if (this.createWhenMissing) await this.create(valueOf(environment))
|
||||
else throw new Error('This terminal no longer exists on the Host. Open a new terminal.')
|
||||
})().catch((error: unknown) => { this.fail(error) }).finally(() => { this.loading = undefined })
|
||||
return this.loading
|
||||
}
|
||||
|
||||
private async create(environment: TerminalEnvironment): Promise<void> {
|
||||
this.patch({ phase: 'creating', error: undefined })
|
||||
this.creation = (async () => {
|
||||
const info = valueOf(await this.remote.create(this.sessionId, {
|
||||
id: this.id, cols: Math.min(80, environment.maxCols), rows: Math.min(24, environment.maxRows),
|
||||
}, this.lifetime.signal))
|
||||
if (!this.lifetime.signal.aborted) this.adopt(info)
|
||||
})().finally(() => { this.creation = undefined })
|
||||
await this.creation
|
||||
}
|
||||
|
||||
private adopt(info: WebTerminalInfo): void {
|
||||
this.patch({ info, title: info.title })
|
||||
if (this.mounted && this.closing === undefined) this.connect()
|
||||
}
|
||||
|
||||
/** Reattach with a fresh screen and regain input control. */
|
||||
connect(): void {
|
||||
const info = this.state.getSnapshot().info
|
||||
if (info === undefined || !this.mounted || this.closing !== undefined || this.lifetime.signal.aborted) return
|
||||
this.detach()
|
||||
const stream = this.gateway.$stream<TerminalFrame>({
|
||||
name: 'Browser terminal output',
|
||||
open: (signal) => {
|
||||
const attachmentId = randomUUID() as TerminalAttachmentId
|
||||
this.attachmentId = attachmentId
|
||||
return this.remote.follow(this.sessionId, info.id, attachmentId, signal)
|
||||
},
|
||||
ended: () => new Error('Terminal attachment ended; reconnect to recover its state'),
|
||||
carrierFailed: () => { if (this.stream === stream) this.patch({ phase: 'disconnected', writable: false }) },
|
||||
})
|
||||
this.stream = stream
|
||||
this.patch({ phase: 'connecting', writable: false, error: undefined, render: undefined })
|
||||
void this.consume(stream)
|
||||
}
|
||||
|
||||
/**
|
||||
* Release the next stream item after xterm has parsed this frame.
|
||||
* @param revision - locally delivered render revision.
|
||||
*/
|
||||
acknowledge(revision: number): void {
|
||||
if (this.pendingRender?.revision !== revision) return
|
||||
this.pendingRender.resolve()
|
||||
this.pendingRender = undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize raw input so concurrent RPC requests cannot reorder keystrokes.
|
||||
* @param data - input from the terminal emulator.
|
||||
*/
|
||||
write(data: string): void {
|
||||
const state = this.state.getSnapshot()
|
||||
const attachmentId = this.attachmentId
|
||||
if (!state.writable || state.info === undefined || attachmentId === undefined) return
|
||||
const bytes = new TextEncoder().encode(data).byteLength
|
||||
if (this.queuedInput + bytes > (state.environment?.maxInputBytes ?? 0)) { this.fail(new Error('Terminal input buffer is full')); return }
|
||||
this.queuedInput += bytes
|
||||
const id = state.info.id
|
||||
this.writes = this.writes.then(async () => {
|
||||
if (this.attachmentId !== attachmentId) return
|
||||
valueOf(await this.remote.write(this.sessionId, id, attachmentId, data))
|
||||
}).catch((error: unknown) => { if (this.attachmentId === attachmentId) this.fail(error) }).finally(() => { this.queuedInput -= bytes })
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize only from the currently writable view.
|
||||
* @param cols - measured column count.
|
||||
* @param rows - measured row count.
|
||||
*/
|
||||
resize(cols: number, rows: number): void {
|
||||
const state = this.state.getSnapshot()
|
||||
const attachmentId = this.attachmentId
|
||||
if (!state.writable || state.info === undefined || attachmentId === undefined) return
|
||||
if (state.info.cols === cols && state.info.rows === rows) return
|
||||
const id = state.info.id
|
||||
cols = Math.min(cols, state.environment?.maxCols ?? cols)
|
||||
rows = Math.min(rows, state.environment?.maxRows ?? rows)
|
||||
this.writes = this.writes.then(async () => {
|
||||
if (this.attachmentId !== attachmentId) return
|
||||
valueOf(await this.remote.resize(this.sessionId, id, attachmentId, cols, rows))
|
||||
}).catch((error: unknown) => { if (this.attachmentId === attachmentId) this.fail(error) })
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the Host terminal's display name.
|
||||
* @param title - user-entered terminal title.
|
||||
* @returns after the rename settles and its result is reflected in view state.
|
||||
*/
|
||||
async rename(title: string): Promise<void> {
|
||||
if (title.trim() === this.state.getSnapshot().title || this.lifetime.signal.aborted) return
|
||||
try {
|
||||
valueOf(await this.remote.rename(this.sessionId, this.id, title))
|
||||
const current = this.state.getSnapshot().info
|
||||
this.patch({ ...(current === undefined ? {} : { info: { ...current, title: title.trim() } }), title: title.trim() })
|
||||
} catch (error) { this.fail(error) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Explicitly terminate this view's process independently of its DOM lifetime.
|
||||
* @returns after Host process cleanup succeeds; failures remain retryable by the owner.
|
||||
*/
|
||||
close(): Promise<void> {
|
||||
if (this.closing !== undefined) return this.closing
|
||||
this.patch({ phase: 'closing', writable: false, error: undefined })
|
||||
this.detach()
|
||||
this.closing = (async () => {
|
||||
// Even a refused or lost creation response may leave an allocation to close.
|
||||
await this.creation?.catch(() => {})
|
||||
valueOf(await this.remote.close(this.sessionId, this.id))
|
||||
this.detach()
|
||||
this.patch({ phase: 'closed', writable: false })
|
||||
})().catch((error: unknown) => { this.closing = undefined; this.fail(error); throw error })
|
||||
return this.closing
|
||||
}
|
||||
|
||||
/** Stop Client work on plugin unload without closing Host terminals. */
|
||||
dispose(): void {
|
||||
this.mounted = false
|
||||
this.lifetime.abort()
|
||||
this.detach()
|
||||
}
|
||||
|
||||
private detach(): void {
|
||||
const previous = this.stream
|
||||
this.stream = undefined
|
||||
this.attachmentId = undefined
|
||||
this.pendingRender?.resolve()
|
||||
this.pendingRender = undefined
|
||||
if (previous !== undefined) void previous.dispose()
|
||||
}
|
||||
|
||||
private async consume(stream: RemoteStream<TerminalFrame>): Promise<void> {
|
||||
let generation = 0
|
||||
let sequence = 0
|
||||
try {
|
||||
for await (const item of stream) {
|
||||
if (this.stream !== stream) return
|
||||
const frame = item.value
|
||||
if (generation !== item.generation) {
|
||||
if (frame.type !== 'snapshot') throw new Error('Terminal output generation is missing its screen snapshot')
|
||||
generation = item.generation
|
||||
sequence = frame.sequence
|
||||
item.accept()
|
||||
} else if (frame.type === 'output') {
|
||||
if (frame.sequence !== sequence + 1) throw new Error('Terminal output sequence has a gap')
|
||||
sequence = frame.sequence
|
||||
} else if (frame.type === 'snapshot') throw new Error('Unexpected terminal screen snapshot')
|
||||
if (frame.type !== 'output') {
|
||||
this.patch({ info: frame.info, title: frame.info.title, phase: 'connected', writable: frame.info.state === 'running' && frame.info.controllerId === this.attachmentId })
|
||||
}
|
||||
if (frame.type !== 'state') {
|
||||
const revision = ++this.revision
|
||||
await new Promise<void>((resolve) => {
|
||||
this.pendingRender = { revision, resolve }
|
||||
const aborted = (): void => { this.acknowledge(revision) }
|
||||
item.signal.addEventListener('abort', aborted, { once: true })
|
||||
this.pendingRender.resolve = () => { item.signal.removeEventListener('abort', aborted); resolve() }
|
||||
this.patch({ render: { revision, frame } })
|
||||
if (item.signal.aborted) aborted()
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.stream === stream) {
|
||||
if (this.state.getSnapshot().info?.state === 'exited') this.patch({ phase: 'closed', writable: false })
|
||||
else this.fail(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private patch(patch: Partial<TerminalViewState>): void {
|
||||
if (this.lifetime.signal.aborted) return
|
||||
this.state.set({ ...this.state.getSnapshot(), ...patch })
|
||||
}
|
||||
|
||||
private fail(error: unknown): void {
|
||||
this.patch({ phase: error instanceof RemoteStreamCarrierError ? 'disconnected' : 'failed', writable: false, error: error instanceof Error ? error.message : String(error) })
|
||||
}
|
||||
}
|
||||
|
||||
function valueOf<T>(result: RemoteResult<T>): T {
|
||||
if (!result.ok) throw new Error(result.error.message)
|
||||
return result.value
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
/** Session-scoped browser terminals over the composed subprocess and sandbox providers. */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SubprocessTerminalHandle } from '@deepseek-ai/dsh-subprocess'
|
||||
import type {} from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import type {} from '@deepseek-ai/dsh-sandbox'
|
||||
import type {} from '@deepseek-ai/dsh-session-projection'
|
||||
import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import { resolveShell } from './shells.ts'
|
||||
import { BrowserTerminal } from './terminal.ts'
|
||||
import type {
|
||||
TerminalAttachmentId, TerminalCreateRequest, TerminalEnvironment, TerminalFrame,
|
||||
WebTerminalId, WebTerminalInfo,
|
||||
} from './types.ts'
|
||||
|
||||
export type * from './types.ts'
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Context {
|
||||
/** Interactive user terminals, separate from the Agent terminal tool registry. */
|
||||
terminalController: TerminalController
|
||||
}
|
||||
}
|
||||
|
||||
/** Deployment limits and an optional shell profile. */
|
||||
export interface Config {
|
||||
/** Explicit shell profile; omission uses the execution environment's default shell. */
|
||||
readonly shell?: {
|
||||
/** Executable path or PATH name, verified by the subprocess provider. */
|
||||
path: string
|
||||
/** User-visible profile name. */
|
||||
name: string
|
||||
/** Arguments passed to the interactive shell. */
|
||||
args: string[]
|
||||
} | undefined
|
||||
/** Maximum retained terminals and pending allocations per Session. */
|
||||
readonly maxTerminals: number
|
||||
/** Maximum terminal width in columns. */
|
||||
readonly maxCols: number
|
||||
/** Maximum terminal height in rows. */
|
||||
readonly maxRows: number
|
||||
/** Screen history rows retained for reconnecting clients. */
|
||||
readonly scrollback: number
|
||||
/** Maximum queued UTF-8 frame bytes per output follower before disconnection. */
|
||||
readonly maxBufferedBytes: number
|
||||
/** Maximum UTF-8 bytes in one input request. */
|
||||
readonly maxInputBytes: number
|
||||
/** Provider process-termination grace period in milliseconds. */
|
||||
readonly disposeGraceMs: number
|
||||
}
|
||||
|
||||
interface OwnedSession {
|
||||
readonly lifetime: AbortController
|
||||
readonly closedIds: Set<WebTerminalId>
|
||||
cleanup?: Promise<void>
|
||||
readonly terminals: Map<WebTerminalId, BrowserTerminal>
|
||||
readonly pending: Map<WebTerminalId, Promise<BrowserTerminal>>
|
||||
readonly allocations: Map<WebTerminalId, { handle: SubprocessTerminalHandle; info: WebTerminalInfo }>
|
||||
}
|
||||
|
||||
/** Typed Remote control of transient Session-owned terminal processes. */
|
||||
export class TerminalController extends TypertRemoteService {
|
||||
static inject = ['subprocess', 'sandboxPolicy', 'sessionProjections', 'typert']
|
||||
static Config: z<Config> = z.object({
|
||||
shell: z.union([z.object({
|
||||
path: z.string().required(), name: z.string().required(), args: z.array(z.string()).default([]),
|
||||
}), z.const(undefined)]),
|
||||
maxTerminals: z.number().step(1).min(1).default(8),
|
||||
maxCols: z.number().step(1).min(2).default(500),
|
||||
maxRows: z.number().step(1).min(1).default(200),
|
||||
scrollback: z.number().step(1).min(0).default(1000),
|
||||
maxBufferedBytes: z.number().step(1).min(1024).default(2 * 1024 * 1024),
|
||||
maxInputBytes: z.number().step(1).min(1).default(64 * 1024),
|
||||
disposeGraceMs: z.number().step(1).min(1).default(1000),
|
||||
})
|
||||
|
||||
private readonly owners = new Map<SessionId, OwnedSession>()
|
||||
private readonly lifetime = new AbortController()
|
||||
|
||||
/**
|
||||
* @param ctx - Host context carrying typed Remote and execution providers.
|
||||
* @param config - validated terminal limits and optional shell profile.
|
||||
*/
|
||||
constructor(ctx: Context, private readonly config: Config) {
|
||||
super(ctx, 'terminalController', { namespace: 'terminal' })
|
||||
ctx.on('internal/dispatch', (_mode, eventName, args) => {
|
||||
if (eventName !== 'session/event') return
|
||||
const [session, event] = args as [Session, SessionEvent]
|
||||
if (event.type !== 'sandbox/mode') return
|
||||
const owner = this.owners.get(session.id)
|
||||
if (owner === undefined || owner.terminals.size + owner.pending.size + owner.allocations.size === 0) return
|
||||
const current = ctx.sessionProjections.stateOf(session, 'sandboxMode') ?? ctx.sandboxPolicy.defaultMode
|
||||
if (event.data.mode !== current) throw new Error('Close browser terminals before changing the Session sandbox mode')
|
||||
}, { global: true })
|
||||
ctx.effect(() => async () => {
|
||||
this.lifetime.abort(new Error('Terminal controller disposed'))
|
||||
const results = await Promise.allSettled([...this.owners].map(([id, owner]) => this.disposeOwner(id, owner)))
|
||||
const errors = results.filter(result => result.status === 'rejected').map(result => result.reason as unknown)
|
||||
if (errors.length > 0) throw new AggregateError(errors, 'Browser terminal cleanup failed')
|
||||
}, 'terminal-controller.processes')
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the Session working directory and terminal limits without resolving a shell.
|
||||
* @param agent - Session owner supplied by the Gateway.
|
||||
* @param signal - request cancellation.
|
||||
* @returns the Session workspace directory and terminal limits.
|
||||
*/
|
||||
@Remote
|
||||
environment(agent: Agent, signal: AbortSignal): TerminalEnvironment {
|
||||
signal.throwIfAborted()
|
||||
const { sandboxPolicy } = this.execution(agent)
|
||||
return { cwd: sandboxPolicy.resolve({ session: agent.session }).workspaceRoot,
|
||||
maxInputBytes: this.config.maxInputBytes, maxCols: this.config.maxCols,
|
||||
maxRows: this.config.maxRows, scrollback: this.config.scrollback }
|
||||
}
|
||||
|
||||
/**
|
||||
* List running and exited terminals available for reattachment.
|
||||
* @param agent - Session owner supplied by the Gateway.
|
||||
* @returns terminals retained for this Host lifetime.
|
||||
*/
|
||||
@Remote
|
||||
list(agent: Agent): WebTerminalInfo[] {
|
||||
const owner = this.owners.get(agent.id)
|
||||
if (owner === undefined) return []
|
||||
return [...owner.terminals.values(), ...owner.allocations.values()].map(terminal => terminal.info)
|
||||
}
|
||||
|
||||
/**
|
||||
* Allocate an interactive shell once for a caller-generated identity.
|
||||
* @param agent - Session owner supplied by the Gateway.
|
||||
* @param request - initial dimensions and idempotency identity.
|
||||
* @param signal - allocation cancellation; committed terminals survive disconnection.
|
||||
* @returns the existing or newly committed terminal.
|
||||
*/
|
||||
@Remote
|
||||
async create(agent: Agent, request: TerminalCreateRequest, signal: AbortSignal): Promise<WebTerminalInfo> {
|
||||
this.lifetime.signal.throwIfAborted()
|
||||
if (!/^[\w-]{1,128}$/u.test(request.id)) throw new Error('Invalid terminal identity')
|
||||
this.dimensions(request.cols, request.rows)
|
||||
const owner = this.owner(agent)
|
||||
owner.lifetime.signal.throwIfAborted()
|
||||
this.requireOpen(owner, request.id)
|
||||
const existing = owner.terminals.get(request.id)
|
||||
if (existing !== undefined) return existing.info
|
||||
const pending = owner.pending.get(request.id)
|
||||
if (pending !== undefined) {
|
||||
const terminal = await pending
|
||||
this.requireOpen(owner, request.id)
|
||||
return terminal.info
|
||||
}
|
||||
if (owner.allocations.has(request.id)) throw new Error('Close the failed terminal allocation before creating it again')
|
||||
if (new Set([...owner.terminals.keys(), ...owner.pending.keys(), ...owner.allocations.keys()]).size >= this.config.maxTerminals) throw new Error('Session terminal limit reached')
|
||||
const allocation = this.spawn(agent, owner, request, AbortSignal.any([signal, this.lifetime.signal, owner.lifetime.signal]))
|
||||
owner.pending.set(request.id, allocation)
|
||||
try {
|
||||
const terminal = await allocation
|
||||
owner.terminals.set(request.id, terminal)
|
||||
owner.allocations.delete(request.id)
|
||||
this.requireOpen(owner, request.id)
|
||||
return terminal.info
|
||||
} finally {
|
||||
owner.pending.delete(request.id)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach to a terminal without binding its process lifetime to the transport.
|
||||
* @param agent - Session owner supplied by the Gateway.
|
||||
* @param id - terminal identity.
|
||||
* @param attachmentId - new exclusive input attachment.
|
||||
* @param signal - physical stream cancellation.
|
||||
* @returns screen recovery followed by output and metadata changes.
|
||||
*/
|
||||
@Remote({ mode: 'stream' })
|
||||
follow(agent: Agent, id: WebTerminalId, attachmentId: TerminalAttachmentId, signal: AbortSignal): AsyncIterable<TerminalFrame> {
|
||||
if (!/^[\w-]{1,128}$/u.test(attachmentId)) throw new Error('Invalid terminal attachment identity')
|
||||
return this.terminal(agent, id).follow(attachmentId, signal)
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver raw input, including Tab completion and control characters.
|
||||
* @param agent - Session owner supplied by the Gateway.
|
||||
* @param id - terminal identity.
|
||||
* @param attachmentId - current writable attachment.
|
||||
* @param data - input bytes represented as UTF-8 text.
|
||||
* @returns after provider input acceptance.
|
||||
*/
|
||||
@Remote
|
||||
async write(agent: Agent, id: WebTerminalId, attachmentId: TerminalAttachmentId, data: string): Promise<void> {
|
||||
if (Buffer.byteLength(data, 'utf8') > this.config.maxInputBytes) throw new Error('Terminal input exceeds the configured limit')
|
||||
await this.terminal(agent, id).write(attachmentId, data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the dimensions of the PTY and recovery screen.
|
||||
* @param agent - Session owner supplied by the Gateway.
|
||||
* @param id - terminal identity.
|
||||
* @param attachmentId - current writable attachment.
|
||||
* @param cols - column count.
|
||||
* @param rows - row count.
|
||||
* @returns after the resize completes.
|
||||
*/
|
||||
@Remote
|
||||
async resize(agent: Agent, id: WebTerminalId, attachmentId: TerminalAttachmentId, cols: number, rows: number): Promise<void> {
|
||||
this.dimensions(cols, rows)
|
||||
await this.terminal(agent, id).resize(attachmentId, cols, rows)
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a terminal without changing its shell.
|
||||
* @param agent - Session owner supplied by the Gateway.
|
||||
* @param id - terminal identity.
|
||||
* @param title - nonempty display title, at most 120 characters.
|
||||
*/
|
||||
@Remote
|
||||
rename(agent: Agent, id: WebTerminalId, title: string): void {
|
||||
if (title.trim().length === 0 || title.length > 120) throw new Error('Terminal title must contain 1–120 characters')
|
||||
this.terminal(agent, id).rename(title.trim())
|
||||
}
|
||||
|
||||
/**
|
||||
* Close an identity to future creation and kill its process range; repeated closes succeed.
|
||||
* @param agent - Session owner supplied by the Gateway.
|
||||
* @param id - terminal identity.
|
||||
* @returns after provider cleanup succeeds. A failure retains the terminal for retry.
|
||||
*/
|
||||
@Remote
|
||||
async close(agent: Agent, id: WebTerminalId): Promise<void> {
|
||||
const owner = this.owner(agent)
|
||||
owner.closedIds.add(id)
|
||||
await owner.pending.get(id)?.catch(() => { /* Creation reports its failure; close still owns any allocated process. */ })
|
||||
const terminal = owner.terminals.get(id)
|
||||
if (terminal !== undefined) {
|
||||
await terminal.close()
|
||||
owner.terminals.delete(id)
|
||||
} else {
|
||||
const allocation = owner.allocations.get(id)
|
||||
if (allocation === undefined) return
|
||||
await allocation.handle.terminate()
|
||||
owner.allocations.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
private owner(agent: Agent): OwnedSession {
|
||||
let owner = this.owners.get(agent.id)
|
||||
if (owner === undefined) {
|
||||
owner = { terminals: new Map(), pending: new Map(), allocations: new Map(), closedIds: new Set(), lifetime: new AbortController() }
|
||||
this.owners.set(agent.id, owner)
|
||||
const owned = owner
|
||||
agent.ctx.effect(() => async () => { await this.disposeOwner(agent.id, owned) }, 'terminal-controller.owner')
|
||||
}
|
||||
return owner
|
||||
}
|
||||
|
||||
private disposeOwner(id: SessionId, owner: OwnedSession): Promise<void> {
|
||||
if (owner.cleanup !== undefined) return owner.cleanup
|
||||
owner.lifetime.abort(new Error('Terminal Session owner disposed'))
|
||||
owner.cleanup = (async () => {
|
||||
await Promise.allSettled(owner.pending.values())
|
||||
const results = await Promise.allSettled([
|
||||
...[...owner.terminals.values()].map(terminal => terminal.close()),
|
||||
...[...owner.allocations.values()].map(allocation => allocation.handle.terminate()),
|
||||
])
|
||||
const errors = results.filter(result => result.status === 'rejected').map(result => result.reason as unknown)
|
||||
if (errors.length > 0) throw new AggregateError(errors, 'Session terminal cleanup failed')
|
||||
owner.terminals.clear()
|
||||
owner.allocations.clear()
|
||||
this.owners.delete(id)
|
||||
})().catch((error: unknown) => { delete owner.cleanup; throw error })
|
||||
return owner.cleanup
|
||||
}
|
||||
|
||||
private terminal(agent: Agent, id: WebTerminalId): BrowserTerminal {
|
||||
const terminal = this.owners.get(agent.id)?.terminals.get(id)
|
||||
if (terminal === undefined) throw new Error('Terminal no longer exists in this Session')
|
||||
return terminal
|
||||
}
|
||||
|
||||
private requireOpen(owner: OwnedSession, id: WebTerminalId): void {
|
||||
if (owner.closedIds.has(id)) throw new Error('Terminal was closed in this Session')
|
||||
}
|
||||
|
||||
private dimensions(cols: number, rows: number): void {
|
||||
if (!Number.isSafeInteger(cols) || cols < 2 || cols > this.config.maxCols
|
||||
|| !Number.isSafeInteger(rows) || rows < 1 || rows > this.config.maxRows) throw new Error('Terminal dimensions exceed the configured limits')
|
||||
}
|
||||
|
||||
private execution(agent: Agent): { subprocess: Context['subprocess']; sandboxPolicy: Context['sandboxPolicy'] } {
|
||||
// The Agent context selects execution providers but does not inject consumer services.
|
||||
const subprocess = agent.ctx.get('subprocess')
|
||||
const sandboxPolicy = agent.ctx.get('sandboxPolicy')
|
||||
if (subprocess === undefined || sandboxPolicy === undefined) throw new Error('The Session execution environment requires subprocess and sandbox policy providers')
|
||||
return { subprocess, sandboxPolicy }
|
||||
}
|
||||
|
||||
private async spawn(agent: Agent, owner: OwnedSession, request: TerminalCreateRequest, signal: AbortSignal): Promise<BrowserTerminal> {
|
||||
const environment = this.environment(agent, signal)
|
||||
const { subprocess, sandboxPolicy } = this.execution(agent)
|
||||
const shell = await resolveShell(subprocess, this.config.shell, signal)
|
||||
const policy = sandboxPolicy.resolve({ session: agent.session })
|
||||
let argv = [shell.path, ...shell.args]
|
||||
if (policy.mode !== 'danger-full-access') {
|
||||
const sandbox = agent.ctx.get('sandbox')
|
||||
if (sandbox === undefined) throw new Error('The Session sandbox mode requires an execution sandbox provider')
|
||||
argv = sandbox.confine(argv, { ...policy, mode: policy.mode }).argv
|
||||
}
|
||||
const handle = await subprocess.spawnTerminal({
|
||||
argv, cwd: environment.cwd, cols: request.cols, rows: request.rows,
|
||||
terminalType: 'xterm-256color', env: { DSH_SESSION_ID: agent.id },
|
||||
graceMs: this.config.disposeGraceMs, signal,
|
||||
})
|
||||
const allocation = {
|
||||
handle,
|
||||
info: {
|
||||
id: request.id, shell, title: shell.name, cwd: environment.cwd,
|
||||
cols: request.cols, rows: request.rows, state: 'running', exitCode: null,
|
||||
} as WebTerminalInfo,
|
||||
}
|
||||
owner.allocations.set(request.id, allocation)
|
||||
try {
|
||||
signal.throwIfAborted()
|
||||
return new BrowserTerminal(handle, allocation.info, this.config.scrollback, this.config.maxBufferedBytes)
|
||||
} catch (error) {
|
||||
allocation.info = { ...allocation.info, state: 'failed', error: error instanceof Error ? error.message : String(error) }
|
||||
try {
|
||||
await handle.terminate()
|
||||
owner.allocations.delete(request.id)
|
||||
} catch (cleanupError) {
|
||||
throw new AggregateError([error, cleanupError], 'Terminal allocation cleanup failed')
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Browser terminal service plugin. */
|
||||
export default TerminalController
|
||||
@@ -0,0 +1,28 @@
|
||||
/** Shell selection and executable verification use the target execution provider. */
|
||||
import type { SubprocessRuntime } from '@deepseek-ai/dsh-subprocess'
|
||||
import type { TerminalShell } from './types.ts'
|
||||
|
||||
/**
|
||||
* Resolve the configured shell or the execution environment's default shell.
|
||||
* @param subprocess - target execution provider.
|
||||
* @param configured - optional profile overriding the environment's default shell.
|
||||
* @param signal - resolution cancellation.
|
||||
* @returns one verified shell; a declared default that cannot resolve rejects.
|
||||
*/
|
||||
export async function resolveShell(
|
||||
subprocess: SubprocessRuntime, configured: TerminalShell | undefined, signal: AbortSignal,
|
||||
): Promise<TerminalShell> {
|
||||
let shell = configured
|
||||
if (shell === undefined) {
|
||||
const environment = await subprocess.terminalEnvironment(signal)
|
||||
shell = profile(environment.defaultShell ?? (environment.platform === 'windows' ? 'cmd.exe' : '/bin/sh'))
|
||||
}
|
||||
const path = await subprocess.resolveExecutable(shell.path, undefined, signal)
|
||||
return { ...shell, path }
|
||||
}
|
||||
|
||||
function profile(path: string): TerminalShell {
|
||||
const name = path.slice(Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + 1)
|
||||
const kind = name.toLowerCase().replace(/\.exe$/u, '')
|
||||
return { path, name, args: kind === 'cmd' ? [] : kind === 'pwsh' || kind === 'powershell' ? ['-NoLogo'] : ['-i'] }
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/** A bounded output queue for one Remote stream generation. */
|
||||
import { Deque } from '@deepseek-ai/dsh-deque'
|
||||
import type { TerminalFrame } from './types.ts'
|
||||
|
||||
/** Slow followers fail explicitly; a later attachment recovers from the screen. */
|
||||
export class TerminalFollower {
|
||||
private readonly queue = new Deque<{ frame: TerminalFrame; bytes: number }>()
|
||||
private bytes = 0
|
||||
private wake: (() => void) | undefined
|
||||
private closed = false
|
||||
private finished = false
|
||||
private failure: Error | undefined
|
||||
|
||||
/** @param maxBytes - maximum queued UTF-8 bytes for this follower. */
|
||||
constructor(private readonly maxBytes: number) {}
|
||||
|
||||
/**
|
||||
* Queue a frame or fail this follower when its byte limit is exceeded.
|
||||
* @param frame - next ordered frame.
|
||||
*/
|
||||
push(frame: TerminalFrame): void {
|
||||
if (this.closed || this.finished) return
|
||||
const bytes = Buffer.byteLength(JSON.stringify(frame), 'utf8')
|
||||
if (this.bytes + bytes > this.maxBytes) {
|
||||
this.failure = new Error('Terminal output consumer exceeded its buffer; reconnect to recover the current screen')
|
||||
this.close()
|
||||
return
|
||||
}
|
||||
this.queue.pushBack({ frame, bytes })
|
||||
this.bytes += bytes
|
||||
this.wake?.()
|
||||
}
|
||||
|
||||
/** Finish after delivering every queued frame, including the final exit state. */
|
||||
finish(): void {
|
||||
this.finished = true
|
||||
this.wake?.()
|
||||
}
|
||||
|
||||
/** Stop this follower without stopping its terminal. */
|
||||
close(): void {
|
||||
this.closed = true
|
||||
this.queue.clear()
|
||||
this.bytes = 0
|
||||
this.wake?.()
|
||||
}
|
||||
|
||||
/**
|
||||
* Drain until detached or failed.
|
||||
* @param signal - Remote generation cancellation.
|
||||
* @returns ordered terminal frames.
|
||||
*/
|
||||
async *read(signal: AbortSignal): AsyncIterable<TerminalFrame> {
|
||||
const abort = (): void => { this.close() }
|
||||
signal.addEventListener('abort', abort, { once: true })
|
||||
if (signal.aborted) abort()
|
||||
try {
|
||||
while (!this.closed) {
|
||||
const next = this.queue.popFront()
|
||||
if (next !== undefined) {
|
||||
this.bytes -= next.bytes
|
||||
yield next.frame
|
||||
} else {
|
||||
if (this.finished) break
|
||||
await new Promise<void>((resolve) => { this.wake = resolve })
|
||||
this.wake = undefined
|
||||
}
|
||||
}
|
||||
if (this.failure !== undefined) throw this.failure
|
||||
} finally {
|
||||
signal.removeEventListener('abort', abort)
|
||||
this.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
/** One PTY, a bounded terminal emulator and its detachable browser followers. */
|
||||
import { createRequire } from 'node:module'
|
||||
import { StringDecoder } from 'node:string_decoder'
|
||||
import type { Terminal as HeadlessTerminal } from '@xterm/headless'
|
||||
import type { SerializeAddon as Serializer } from '@xterm/addon-serialize'
|
||||
import type { SubprocessTerminalHandle } from '@deepseek-ai/dsh-subprocess'
|
||||
import { TerminalFollower } from './stream.ts'
|
||||
import type { TerminalAttachmentId, TerminalFrame, WebTerminalInfo } from './types.ts'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const { Terminal } = require('@xterm/headless') as typeof import('@xterm/headless')
|
||||
const { SerializeAddon } = require('@xterm/addon-serialize') as typeof import('@xterm/addon-serialize')
|
||||
|
||||
/** Process lifetime is independent of follower and component lifetimes. */
|
||||
export class BrowserTerminal {
|
||||
private readonly screen: HeadlessTerminal
|
||||
private readonly serializer: Serializer
|
||||
private readonly followers = new Set<TerminalFollower>()
|
||||
private sequence = 0
|
||||
private operations: Promise<unknown> = Promise.resolve()
|
||||
private readonly drained: Promise<void>
|
||||
private closing: Promise<void> | undefined
|
||||
private controller: { id: TerminalAttachmentId; follower: TerminalFollower } | undefined
|
||||
|
||||
/**
|
||||
* @param handle - allocated terminal process range.
|
||||
* @param info - initial metadata.
|
||||
* @param scrollback - maximum retained scrollback rows.
|
||||
* @param maxBufferedBytes - per-follower queue cap.
|
||||
*/
|
||||
constructor(
|
||||
private readonly handle: SubprocessTerminalHandle,
|
||||
public info: WebTerminalInfo,
|
||||
scrollback: number,
|
||||
private readonly maxBufferedBytes: number,
|
||||
) {
|
||||
this.screen = new Terminal({ cols: info.cols, rows: info.rows, scrollback, allowProposedApi: true })
|
||||
this.serializer = new SerializeAddon()
|
||||
this.screen.loadAddon(this.serializer)
|
||||
this.drained = this.consume()
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach with exclusive input control; an older attachment becomes read-only.
|
||||
* @param id - browser attachment identity.
|
||||
* @param signal - attachment cancellation; never terminates the process.
|
||||
* @returns a consistent screen followed by ordered output and state changes.
|
||||
*/
|
||||
async *follow(id: TerminalAttachmentId, signal: AbortSignal): AsyncIterable<TerminalFrame> {
|
||||
signal.throwIfAborted()
|
||||
const follower = new TerminalFollower(this.maxBufferedBytes)
|
||||
const baseline = await this.enqueue(() => {
|
||||
signal.throwIfAborted()
|
||||
this.controller = { id, follower }
|
||||
this.info = { ...this.info, controllerId: id }
|
||||
this.broadcast({ type: 'state', info: this.info })
|
||||
const snapshot: TerminalFrame = { type: 'snapshot', sequence: this.sequence, screen: this.serializer.serialize(), info: this.info }
|
||||
this.followers.add(follower)
|
||||
return snapshot
|
||||
})
|
||||
try {
|
||||
yield baseline
|
||||
yield* follower.read(signal)
|
||||
} finally {
|
||||
this.followers.delete(follower)
|
||||
follower.close()
|
||||
if (this.controller?.follower === follower) {
|
||||
this.controller = undefined
|
||||
const { controllerId: _controllerId, ...info } = this.info
|
||||
this.info = info
|
||||
this.broadcast({ type: 'state', info })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send raw terminal input without command interpretation.
|
||||
* @param id - current writable attachment.
|
||||
* @param data - UTF-8 input, including shell completion/control keys.
|
||||
* @returns when the provider accepts the input.
|
||||
*/
|
||||
write(id: TerminalAttachmentId, data: string): Promise<void> {
|
||||
return this.enqueue(async () => { this.requireController(id); await this.handle.write(data) })
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize the PTY and recovery screen in the same operation order as output.
|
||||
* @param id - current writable attachment.
|
||||
* @param cols - validated column count.
|
||||
* @param rows - validated row count.
|
||||
* @returns when the provider and emulator use the new dimensions.
|
||||
*/
|
||||
resize(id: TerminalAttachmentId, cols: number, rows: number): Promise<void> {
|
||||
return this.enqueue(async () => {
|
||||
this.requireController(id)
|
||||
await this.handle.resize(cols, rows)
|
||||
this.screen.resize(cols, rows)
|
||||
this.info = { ...this.info, cols, rows }
|
||||
this.broadcast({ type: 'state', info: this.info })
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish a display name to every attached view.
|
||||
* @param title - validated user title.
|
||||
*/
|
||||
rename(title: string): void {
|
||||
this.info = { ...this.info, title }
|
||||
this.broadcast({ type: 'state', info: this.info })
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminate the complete provider-owned process range before releasing its screen.
|
||||
* @returns after process cleanup and final output drainage; failures remain retryable.
|
||||
*/
|
||||
close(): Promise<void> {
|
||||
if (this.closing !== undefined) return this.closing
|
||||
this.closing = (async () => {
|
||||
await this.handle.terminate()
|
||||
await this.drained
|
||||
for (const follower of this.followers) follower.finish()
|
||||
this.followers.clear()
|
||||
this.screen.dispose()
|
||||
})().catch((error: unknown) => { this.closing = undefined; throw error })
|
||||
return this.closing
|
||||
}
|
||||
|
||||
private requireController(id: TerminalAttachmentId): void {
|
||||
if (this.closing !== undefined || this.info.state !== 'running') throw new Error('Terminal is not running')
|
||||
if (this.controller?.id !== id) throw new Error('Terminal input is controlled by another attachment')
|
||||
}
|
||||
|
||||
private broadcast(frame: TerminalFrame): void {
|
||||
for (const follower of this.followers) follower.push(frame)
|
||||
}
|
||||
|
||||
private enqueue<T>(operation: () => T | Promise<T>): Promise<T> {
|
||||
const pending = this.operations.then(operation)
|
||||
this.operations = pending.catch(() => { /* The caller owns this operation's failure; later cleanup must still run. */ })
|
||||
return pending
|
||||
}
|
||||
|
||||
private async consume(): Promise<void> {
|
||||
const decoder = new StringDecoder('utf8')
|
||||
const outcome = this.handle.done.then(value => ({ value }), (error: unknown) => ({ error }))
|
||||
try {
|
||||
for await (const chunk of this.handle.output) {
|
||||
// Node Readable's iterator is untyped; this provider explicitly emits Buffer chunks.
|
||||
const data = decoder.write(chunk as Buffer)
|
||||
await this.output(data)
|
||||
}
|
||||
await this.output(decoder.end())
|
||||
const result = await outcome
|
||||
if ('error' in result) throw result.error
|
||||
this.info = { ...this.info, state: 'exited', exitCode: result.value.exitCode }
|
||||
} catch (error) {
|
||||
this.info = { ...this.info, state: 'failed', error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
this.broadcast({ type: 'state', info: this.info })
|
||||
}
|
||||
|
||||
private async output(data: string): Promise<void> {
|
||||
if (data.length === 0) return
|
||||
await this.enqueue(async () => {
|
||||
await new Promise<void>((resolve) => { this.screen.write(data, resolve) })
|
||||
this.broadcast({ type: 'output', sequence: ++this.sequence, data })
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/** Browser terminal identities, metadata and screen-stream frames. */
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
|
||||
/** A terminal identity scoped to one Session and one Host lifetime. */
|
||||
export type WebTerminalId = Branded<'WebTerminalId'>
|
||||
/** An attachment allowed to write and resize one terminal. */
|
||||
export type TerminalAttachmentId = Branded<'TerminalAttachmentId'>
|
||||
|
||||
/** An executable shell verified in the subprocess provider's execution environment. */
|
||||
export interface TerminalShell {
|
||||
readonly path: string
|
||||
readonly args: readonly string[]
|
||||
readonly name: string
|
||||
}
|
||||
|
||||
/** Working directory and limits shared by new and restored terminals. */
|
||||
export interface TerminalEnvironment {
|
||||
readonly cwd: string
|
||||
readonly maxInputBytes: number
|
||||
readonly maxCols: number
|
||||
readonly maxRows: number
|
||||
readonly scrollback: number
|
||||
}
|
||||
|
||||
/** Host-owned terminal state; process exit never creates a replacement shell. */
|
||||
export interface WebTerminalInfo {
|
||||
readonly id: WebTerminalId
|
||||
readonly title: string
|
||||
readonly shell: TerminalShell
|
||||
/** Initial working directory; shell directory changes do not update this field. */
|
||||
readonly cwd: string
|
||||
readonly cols: number
|
||||
readonly rows: number
|
||||
readonly state: 'running' | 'exited' | 'failed'
|
||||
readonly exitCode: number | null
|
||||
readonly error?: string
|
||||
readonly controllerId?: TerminalAttachmentId
|
||||
}
|
||||
|
||||
/** Create is idempotent for an open identity; closed identities cannot be recreated. */
|
||||
export interface TerminalCreateRequest {
|
||||
readonly id: WebTerminalId
|
||||
readonly cols: number
|
||||
readonly rows: number
|
||||
}
|
||||
|
||||
/** Every attachment begins with a complete bounded screen, then ordered output. */
|
||||
export type TerminalFrame =
|
||||
| { readonly type: 'snapshot'; readonly sequence: number; readonly screen: string; readonly info: WebTerminalInfo }
|
||||
| { readonly type: 'output'; readonly sequence: number; readonly data: string }
|
||||
| { readonly type: 'state'; readonly info: WebTerminalInfo }
|
||||
Reference in new issue
Block a user