Files
deepseek-harness/packages/api/terminal-controller/src/index.ts
T
Yichen Jiang 454a3f7a88 fix(terminal-controller): delete confined Agent shell history and stop pointing read-only shells at it
read-only confinement cannot write /tmp, so the per-terminal history path
did nothing there; read-only shells now get an empty HISTFILE, which also
keeps them from loading the person's history. workspace-write shells keep
their per-terminal history file, and the controller now deletes it
through the same execution environment after the shell ends, including a
failed allocation; a failed deletion is logged.
2026-11-04 15:48:30 +08:00

573 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** Session-owned user terminals with the execution environment's system-user permissions. */
import { randomUUID } from 'node:crypto'
import type { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { SessionId } from '@deepseek-ai/dsh-session'
import type { SandboxExecutionPolicy } from '@deepseek-ai/dsh-sandbox'
import type {} from '@deepseek-ai/dsh-sandbox-policy'
import type {} from '@deepseek-ai/dsh-working-directory'
import { Remote, RemoteError, TypertRemoteService, type RemoteStream } from '@deepseek-ai/dsh-typert-protocol'
import { AgentTerminalHandle, type AgentTerminal, type AgentTerminalRequest } from './agent.ts'
import { discoverShells, findShell, resolveShell } from './shells.ts'
import { BrowserTerminal } from './terminal.ts'
import { TerminalRetention } from './retention.ts'
import type {
TerminalShell, TerminalInput, TerminalCreateRequest, TerminalEnvironment, TerminalFrame, TerminalRetentionFrame,
TerminalScreenPage, TerminalScreenRequest, WebTerminalId, WebTerminalInfo,
} from './types.ts'
export type * from './types.ts'
export { AgentTerminalError } from './agent.ts'
export type { AgentTerminal, AgentTerminalErrorCode, AgentTerminalRequest } from './agent.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
/** Executable names or paths checked for the new-terminal shell selector. */
readonly shellCandidates: string[]
/** Maximum retained terminals and pending allocations per Session. */
readonly maxTerminals: number
/**
* Maximum running Agent-controlled terminals and pending Agent allocations per Session; `maxTerminals` still bounds
* every terminal, including exited ones and failed allocations awaiting cleanup.
*/
readonly maxAgentTerminals: 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 and in the Client input buffer. */
readonly maxBufferedBytes: number
/** Maximum raw UTF-8 bytes per input command and across unacknowledged Client input. */
readonly maxInputBytes: number
/** Provider process-termination grace period in milliseconds. */
readonly disposeGraceMs: number
/** Continuous confirmed idle time without window holds before reclamation; zero disables reclamation. */
readonly unattendedTimeoutMs: number
/** Interval between unattended shell and process observations. */
readonly activityPollIntervalMs: number
/** Delay before retrying failed owned terminal cleanup. */
readonly cleanupRetryMs: number
}
interface TerminalAllocation {
info: WebTerminalInfo
readonly cleanup: TerminalRetention
}
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, TerminalAllocation>
/** Agent allocations not yet committed; with running Agent-controlled terminals, they count toward `maxAgentTerminals`. */
readonly agentPending: Set<WebTerminalId>
}
/** Process launch and the metadata published once the process is committed. */
interface TerminalLaunch {
readonly argv: readonly string[]
readonly env: Record<string, string>
readonly info: WebTerminalInfo
/** Removes files the launch created once the process has ended; it must not throw. */
readonly release?: () => Promise<void>
}
/**
* Validate a terminal display title.
* @param title - requested title.
* @returns the title without surrounding whitespace.
* @throws Error when the title is blank or longer than 120 characters.
*/
function terminalTitle(title: string): string {
if (title.trim().length === 0 || title.length > 120) throw new Error('Terminal title must contain 1–120 characters')
return title.trim()
}
/** Typed Remote control of transient Session-owned terminal processes. */
export class TerminalController extends TypertRemoteService {
static inject = ['subprocess', 'sandboxPolicy', 'workingDirectory', '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)]),
shellCandidates: z.array(z.string().min(1)).default(['zsh', 'bash', 'fish', 'pwsh', 'powershell', 'cmd']),
maxTerminals: z.number().step(1).min(1).default(8),
maxAgentTerminals: z.number().step(1).min(1).default(4),
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),
unattendedTimeoutMs: z.number().step(1).min(0).max(Number.MAX_SAFE_INTEGER).default(7_200_000),
activityPollIntervalMs: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER).default(30_000),
cleanupRetryMs: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER).default(60_000),
})
private readonly owners = new Map<SessionId, OwnedSession>()
private readonly rosters = new Map<SessionId, Set<() => void>>()
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.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's current directory and terminal limits without filesystem validation or shell lookup.
* @param agent - Session owner supplied by the Gateway.
* @param signal - request cancellation.
* @returns the logged current directory and terminal limits; retained terminals keep their own process directories.
*/
@Remote
environment(agent: Agent, signal: AbortSignal): TerminalEnvironment {
signal.throwIfAborted()
const { workingDirectory } = this.execution(agent)
return { cwd: workingDirectory.get(agent.session),
maxInputBytes: this.config.maxInputBytes, maxBufferedBytes: this.config.maxBufferedBytes, maxCols: this.config.maxCols,
maxRows: this.config.maxRows, scrollback: this.config.scrollback }
}
/**
* Discover installed shells in the Session's execution environment.
* @param agent - Session owner supplied by the Gateway.
* @param signal - request cancellation.
* @returns verified profiles, with the configured or system default first.
*/
@Remote
shells(agent: Agent, signal: AbortSignal): Promise<TerminalShell[]> {
signal.throwIfAborted()
return discoverShells(this.execution(agent).subprocess, this.config.shell, this.config.shellCandidates, signal)
}
/**
* List retained terminals without resolving or activating an Agent.
* @param sessionId - displayed Session identity, including offline history.
* @returns terminals retained for this Host lifetime.
*/
@Remote
list(sessionId: SessionId): WebTerminalInfo[] {
const owner = this.owners.get(sessionId)
if (owner === undefined) return []
return [...owner.terminals.values(), ...owner.allocations.values()].map(terminal => terminal.info)
}
/**
* Allocate a user shell once for a caller-generated identity, without Agent sandbox or approval restrictions.
* @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
}
this.requireCapacity(owner)
return (await this.allocate(agent, owner, request.id, launchSignal => this.userLaunch(agent, request, launchSignal), signal)).info
}
/**
* Open a terminal for the Session's Agent under a caller-resolved sandbox policy; it appears in every
* window that watches the Session and passes to the person on the person's first typed input.
* @param agent - Session owner whose Agent requests the terminal.
* @param request - title, directory, dimensions, sandbox policy and shell preference.
* @param signal - allocation cancellation; a committed terminal outlives it.
* @returns the Agent's handle for the committed terminal.
* @throws RemoteError `terminal/limit-reached` when the Session or Agent terminal limit is reached.
* @throws Error when the title is blank or longer than 120 characters.
*/
async openForAgent(agent: Agent, request: AgentTerminalRequest, signal: AbortSignal): Promise<AgentTerminal> {
this.lifetime.signal.throwIfAborted()
const title = terminalTitle(request.title)
this.dimensions(request.cols, request.rows)
const owner = this.owner(agent)
owner.lifetime.signal.throwIfAborted()
this.requireCapacity(owner)
const controlled = new Set(owner.agentPending)
for (const { info } of [...owner.terminals.values(), ...owner.allocations.values()]) {
if (info.origin === 'agent' && info.control === 'agent' && info.state === 'running') controlled.add(info.id)
}
if (controlled.size >= this.config.maxAgentTerminals) throw new RemoteError('terminal/limit-reached', 'Session Agent terminal limit reached', { limit: this.config.maxAgentTerminals })
const id = randomUUID() as WebTerminalId
owner.agentPending.add(id)
try {
const launch = { ...request, title }
const terminal = await this.allocate(agent, owner, id, launchSignal => this.agentLaunch(agent, id, launch, launchSignal), signal)
return new AgentTerminalHandle(terminal, () => this.close(agent, id))
} finally {
owner.agentPending.delete(id)
}
}
/**
* Render a page of any terminal in the Agent's Session, including terminals the person opened.
* @param agent - Session owner.
* @param id - terminal identity.
* @param request - page position and byte bound.
* @returns rendered screen text, including output received before the call.
* @throws RemoteError `terminal/unavailable` when the terminal is missing or closes before the screen is read.
*/
readScreen(agent: Agent, id: WebTerminalId, request: TerminalScreenRequest): Promise<TerminalScreenPage> {
const terminal = this.terminal(agent, id)
return terminal.drain().then(() => {
if (terminal.screenReleased) throw new RemoteError('terminal/unavailable', 'Terminal was closed in this Session', {})
return terminal.readScreen(request)
})
}
/**
* Observe whether a Session terminal's shell is running a command.
* @param agent - Session owner.
* @param id - terminal identity.
* @returns idle, busy, or unknown for unsupported shells and sandboxed launches.
*/
inspectActivity(agent: Agent, id: WebTerminalId): Promise<'idle' | 'busy' | 'unknown'> {
return this.terminal(agent, id).inspectActivity()
}
/**
* Stream the Session's retained terminals as whole sets, without resolving or activating an Agent.
* @param sessionId - watched Session identity.
* @param signal - stream cancellation.
* @returns the current set, then a new set after creation, removal or any published state change.
*/
@Remote({ mode: 'stream' })
async *watch(sessionId: SessionId, signal: AbortSignal): AsyncIterable<WebTerminalInfo[]> {
const lifetime = AbortSignal.any([signal, this.lifetime.signal])
let dirty = true
let wake: (() => void) | undefined
const listener = (): void => { dirty = true; wake?.() }
const listeners = this.rosters.get(sessionId) ?? new Set<() => void>()
this.rosters.set(sessionId, listeners)
listeners.add(listener)
lifetime.addEventListener('abort', listener, { once: true })
try {
while (!lifetime.aborted) {
if (dirty) {
dirty = false
yield this.list(sessionId)
continue
}
await new Promise<void>((resolve) => { wake = resolve })
wake = undefined
}
} finally {
lifetime.removeEventListener('abort', listener)
listeners.delete(listener)
if (listeners.size === 0) this.rosters.delete(sessionId)
}
}
/**
* Retain an existing terminal for a window without activating its Agent or taking input control.
* @param sessionId - owning Session identity, including an inactive saved layout.
* @param id - retained Host terminal identity.
* @param signal - physical Remote stream cancellation.
* @returns a hold acknowledgement followed by an open lifetime stream.
*/
@Remote({ mode: 'stream' })
retain(sessionId: SessionId, id: WebTerminalId, signal: AbortSignal): AsyncIterable<TerminalRetentionFrame> {
const owner = this.owners.get(sessionId)
const terminal = owner?.terminals.get(id)
if (terminal === undefined || owner?.closedIds.has(id) === true || owner?.lifetime.signal.aborted === true) {
throw new RemoteError('terminal/unavailable', 'Terminal is closing or unavailable', {})
}
return terminal.retain(signal)
}
/**
* Attach terminal input and output to one physical Remote stream.
* @param agent - Session owner supplied by the Gateway.
* @param id - terminal identity.
* @param signal - attachment cancellation; the process remains alive and input is never replayed.
* @returns screen recovery, output, writable state and provider-acceptance acknowledgements.
*/
@Remote({ mode: 'stream' })
attach(agent: Agent, id: WebTerminalId, signal: AbortSignal): RemoteStream<TerminalFrame, TerminalInput> {
const invocation = this.ctx.invocation
if (invocation === undefined) throw new Error('Terminal attachment requires a Remote invocation')
return this.terminal(agent, id).attach(invocation.uplink<TerminalInput>(), this.config, signal, invocation.uplinkWindow)
}
/**
* 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 {
const trimmed = terminalTitle(title)
this.terminal(agent, id).rename(trimmed)
}
/**
* 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)
// create publishes the allocation before this wait settles; close owns it even if create then rejects.
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.cleanup.close()
owner.allocations.delete(id)
}
this.changed(agent.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(), agentPending: new Set(),
}
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.dispose()),
...[...owner.allocations.values()].map(allocation => allocation.cleanup.dispose()),
])
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)
this.changed(id)
})().catch((error: unknown) => { delete owner.cleanup; throw error })
return owner.cleanup
}
private changed(sessionId: SessionId): void {
for (const listener of this.rosters.get(sessionId) ?? []) listener()
}
private requireCapacity(owner: OwnedSession): void {
if (new Set([...owner.terminals.keys(), ...owner.pending.keys(), ...owner.allocations.keys()]).size >= this.config.maxTerminals) throw new RemoteError('terminal/limit-reached', 'Session terminal limit reached', { limit: this.config.maxTerminals })
}
private async allocate(
agent: Agent, owner: OwnedSession, id: WebTerminalId, prepare: (signal: AbortSignal) => Promise<TerminalLaunch>, signal: AbortSignal,
): Promise<BrowserTerminal> {
const allocation = this.spawn(agent, owner, id, prepare, AbortSignal.any([signal, this.lifetime.signal, owner.lifetime.signal]))
owner.pending.set(id, allocation)
try {
const terminal = await allocation
owner.terminals.set(id, terminal)
owner.allocations.delete(id)
terminal.monitor(this.config, () => { owner.closedIds.add(id) }, () => {
owner.terminals.delete(id)
this.changed(agent.id)
}, (error) => { this.ctx.logger.error('Browser terminal cleanup failed', error) })
terminal.setStateListener(() => { this.changed(agent.id) })
this.changed(agent.id)
this.requireOpen(owner, id)
return terminal
} finally {
owner.pending.delete(id)
}
}
private async userLaunch(agent: Agent, request: TerminalCreateRequest, signal: AbortSignal): Promise<TerminalLaunch> {
const { subprocess, workingDirectory } = this.execution(agent)
const cwd = await workingDirectory.ensure(agent, signal)
const shell = request.shellPath === undefined
? await resolveShell(subprocess, this.config.shell, signal)
: (await this.shells(agent, signal)).find(candidate => candidate.path === request.shellPath)
if (shell === undefined) throw new Error('Selected shell is not available in this execution environment')
return {
argv: [shell.path, ...shell.args], env: { DSH_SESSION_ID: agent.id },
info: { id: request.id, shell, title: shell.name, cwd, cols: request.cols, rows: request.rows, state: 'running', exitCode: null, origin: 'user', control: 'user' },
}
}
private async agentLaunch(agent: Agent, id: WebTerminalId, request: AgentTerminalRequest, signal: AbortSignal): Promise<TerminalLaunch> {
const { subprocess, workingDirectory } = this.execution(agent)
const cwd = request.cwd ?? await workingDirectory.ensure(agent, signal)
const shell = await this.agentShell(agent, request.preferredShells, signal)
const argv = await this.confine(agent, [shell.path, ...shell.args], request.policy, signal)
const { platform } = await subprocess.terminalEnvironment(signal)
// Confined shells cannot write the person's history file and must not load it. Only workspace-write grants /tmp,
// so a read-only shell gets an empty HISTFILE and keeps no history file.
const historyFile = `/tmp/dsh-agent-history-${id}`
const keepsHistory = platform === 'posix' && request.policy.mode === 'workspace-write'
const confined = platform === 'posix' && request.policy.mode !== 'danger-full-access'
const history = confined ? { HISTFILE: keepsHistory ? historyFile : '' } : {}
return {
argv, env: { ...request.env, DSH_SESSION_ID: agent.id, ...history },
info: {
id, shell, title: request.title, cwd, cols: request.cols, rows: request.rows, state: 'running', exitCode: null,
origin: 'agent', control: 'agent', sandboxMode: request.policy.mode,
},
...keepsHistory ? { release: () => this.removeHistory(subprocess, historyFile) } : {},
}
}
/**
* Delete a confined Agent shell's history file in its execution environment after the shell ends.
* @param subprocess - the execution environment that ran the shell.
* @param path - history file the shell was given.
* @returns after deletion; a failure is logged because the file only holds the ended shell's own history.
*/
private async removeHistory(subprocess: Context['subprocess'], path: string): Promise<void> {
try {
const { exitCode, signal } = await subprocess.spawn({
argv: ['rm', '-f', '--', path], cwd: '/',
stdio: { stdin: 'ignore', stdout: { maxBytes: 4096 }, stderr: { maxBytes: 4096 } },
graceMs: this.config.disposeGraceMs,
}).done
if (exitCode !== 0) throw new Error(`rm ended with ${exitCode ?? signal}`)
} catch (error) {
this.ctx.logger.warn('Agent terminal history cleanup failed', { path, error })
}
}
private async agentShell(agent: Agent, preferred: readonly string[], signal: AbortSignal): Promise<TerminalShell> {
if (preferred.length > 0) {
const shells = await this.shells(agent, signal)
for (const name of preferred) {
const shell = findShell(shells, name)
if (shell !== undefined) return shell
}
}
return resolveShell(this.execution(agent).subprocess, this.config.shell, signal)
}
private async confine(
agent: Agent, argv: readonly string[], policy: SandboxExecutionPolicy, signal: AbortSignal,
): Promise<readonly string[]> {
if (policy.mode === 'danger-full-access') return argv
const sandbox = agent.ctx.get('sandbox')
if (sandbox === undefined) throw new Error(`Agent terminals in sandbox mode "${policy.mode}" require a sandbox provider in the execution environment`)
// Re-state the discriminant because object spread does not preserve its narrowed type.
return (await sandbox.confine(argv, { ...policy, mode: policy.mode }, signal)).argv
}
private terminal(agent: Agent, id: WebTerminalId): BrowserTerminal {
const terminal = this.owners.get(agent.id)?.terminals.get(id)
if (terminal === undefined) throw new RemoteError('terminal/unavailable', 'Terminal no longer exists in this Session', {})
this.requireOpen(this.owners.get(agent.id) as OwnedSession, id)
return terminal
}
private requireOpen(owner: OwnedSession, id: WebTerminalId): void {
if (owner.closedIds.has(id)) throw new RemoteError('terminal/unavailable', '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']; workingDirectory: Context['workingDirectory'] } {
// 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')
const workingDirectory = agent.ctx.get('workingDirectory')
if (workingDirectory === undefined) throw new Error('The Session execution environment requires a working-directory provider')
return { subprocess, sandboxPolicy, workingDirectory }
}
private async spawn(
agent: Agent, owner: OwnedSession, id: WebTerminalId, prepare: (signal: AbortSignal) => Promise<TerminalLaunch>, signal: AbortSignal,
): Promise<BrowserTerminal> {
const { info, argv, env, release } = await prepare(signal)
const handle = await this.execution(agent).subprocess.spawnTerminal({
argv, cwd: info.cwd, cols: info.cols, rows: info.rows,
terminalType: 'xterm-256color', env,
shellActivity: true,
graceMs: this.config.disposeGraceMs, signal,
})
try {
signal.throwIfAborted()
return new BrowserTerminal(handle, info, this.config.scrollback, this.config.maxBufferedBytes, release)
} catch (error) {
const cleanup = new TerminalRetention(this.config, handle.inspectActivity.bind(handle), async () => {
owner.closedIds.add(id)
await handle.terminate()
await release?.()
owner.allocations.delete(id)
this.changed(agent.id)
}, (cleanupError) => { this.ctx.logger.error('Browser terminal allocation cleanup failed', cleanupError) })
owner.allocations.set(id, {
info: { ...info, state: 'failed', error: error instanceof Error ? error.message : String(error) }, cleanup,
})
this.changed(agent.id)
try {
await cleanup.close()
} catch (cleanupError) {
throw new AggregateError([error, cleanupError], 'Terminal allocation cleanup failed')
}
throw error
}
}
}
/** Browser terminal service plugin. */
export default TerminalController