/** * Six model-facing persistent terminal tools. Owner identity comes from the exact * tool execution Agent; generic `ctx.tasks` owns background ids and collection. * @module @deepseek-ai/dsh-tool-pty */ import { Context } from 'cordis' import z from 'schemastery' import type { Agent } from '@deepseek-ai/dsh-agent' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { PtySessionId } from '@deepseek-ai/dsh-pty' import type { PtySendResult, PtySessionId as PtySessionIdType, PtySignal } from '@deepseek-ai/dsh-pty' import type {} from '@deepseek-ai/dsh-tasks' import { defineTool } from '@deepseek-ai/dsh-tools' import type { ToolDefinition } from '@deepseek-ai/dsh-tools' import { boundTerminalText, renderList, renderRead, renderSend, renderSendRead, renderSpawn } from './render.ts' declare module '@deepseek-ai/dsh-tasks' { interface TaskKindMap { 'pty-send': 'pty-send' } } /** Cordis plugin name. */ export const name = 'tool-pty' /** Required capability, registry, and prompt services. */ export const inject = ['pty', 'tools', 'systemPrompt'] /** Default cap for one complete model-facing terminal result. */ export const DEFAULT_MAX_RESULT_BYTES = 256 * 1024 /** Smallest cap that preserves every counter-backed PTY and task id in its creation acknowledgement. */ export const MIN_MAX_RESULT_BYTES = 64 /** Model-facing terminal tool configuration. */ export interface Config { /** Expose `run_in_background` and accept background sends (default true). */ enableRunInBackground?: boolean /** Maximum UTF-8 bytes in one complete terminal or task-output result. */ maxResultBytes?: number } /** Schemastery configuration for the terminal tool consumer. */ export const Config: z = z.object({ enableRunInBackground: z.boolean().default(true), maxResultBytes: z.number().step(1).min(MIN_MAX_RESULT_BYTES).max(Number.MAX_SAFE_INTEGER).default(DEFAULT_MAX_RESULT_BYTES), }) interface SpawnArgs { type: string name?: string cwd?: string } interface SessionArgs { sessionId: string } interface SendArgs extends SessionArgs { text: string submit?: boolean run_in_background?: boolean } interface ReadArgs extends SessionArgs { offset?: number count?: number } interface SignalArgs extends SessionArgs { signal: PtySignal } const SESSION_STATUS_SCHEMA = { oneOf: [ { type: 'object', additionalProperties: false, properties: { kind: { type: 'string', required: true, const: 'running' }, }, }, { type: 'object', additionalProperties: false, properties: { kind: { type: 'string', required: true, const: 'exited' }, exitCode: { required: true, oneOf: [{ type: 'integer' }, { type: 'null' }] }, signal: { required: true, oneOf: [{ type: 'string' }, { type: 'null' }] }, }, }, ], } as const const SESSION_SNAPSHOT_PROPERTIES = { sessionId: { type: 'string', required: true }, name: { type: 'string' }, type: { type: 'string', required: true }, pid: { type: 'integer' }, status: { ...SESSION_STATUS_SCHEMA, required: true }, } as const const SESSION_SNAPSHOT_SCHEMA = { type: 'object', additionalProperties: false, properties: SESSION_SNAPSHOT_PROPERTIES, } as const const BACKGROUND_TASK_OUTPUT_SCHEMA = { type: 'object', additionalProperties: false, properties: { kind: { type: 'string', required: true, const: 'background' }, taskId: { type: 'string', required: true }, }, } as const function requireAgent(agent: Agent | undefined): Agent { if (agent === undefined) throw new Error('terminal tools require an initiating agent') return agent } function sessionId(args: SessionArgs): PtySessionIdType { if (args.sessionId.length === 0) { throw new Error('sessionId must be a non-empty string') } return PtySessionId(args.sessionId) } function textResult(text: string, maxBytes: number): ContentBlock[] { return [{ type: 'text', text: boundTerminalText(text, maxBytes) }] } function rawContentText(content: readonly ContentBlock[]): string | undefined { if (content.length !== 1) return undefined const block = content[0] return block?.type === 'text' ? block.text : undefined } function sendDetail(result: PtySendResult): string { return result.sessionStatus.kind === 'running' ? `wait: ${result.waitReason}` : `session exited: ${result.sessionStatus.exitCode ?? result.sessionStatus.signal ?? 'unknown'}` } /** Register all terminal tools and the minimal usage guidance. */ export function apply(ctx: Context, config: Config = {}): void { const enableRunInBackground = config.enableRunInBackground ?? true const maxResultBytes = config.maxResultBytes ?? DEFAULT_MAX_RESULT_BYTES if (!Number.isSafeInteger(maxResultBytes) || maxResultBytes < MIN_MAX_RESULT_BYTES) { throw new Error(`tool-pty: maxResultBytes must be a safe integer of at least ${MIN_MAX_RESULT_BYTES}`) } const finalizeContent: NonNullable = (_exec, result) => { const raw = rawContentText(result.content) return raw === undefined ? undefined : textResult(raw, maxResultBytes) } ctx.systemPrompt.section({ name: 'tool:pty', order: 106, text: 'Use a terminal session only when work needs persistent terminal state or interactive stdin; prefer bash/read/write/edit for bounded one-shot operations. Track every terminal session id and close sessions that no longer matter. An inferred_idle or timeout result does not prove the foreground command exited.', }) ctx.tools.register(defineTool({ name: 'terminal_open', description: 'Create a persistent, owner-isolated terminal session from a registered backend type. Use this for shell or REPL state that must survive across tool calls.', parameters: { type: { type: 'string', required: true, description: 'Registered terminal backend type, usually "shell".' }, name: { type: 'string', description: 'Optional owner-local display name such as "main" or "gdb".' }, cwd: { type: 'string', description: 'Initial working directory. Defaults to the deployment workspace root.' }, }, finalizeContent, output: { schema: { type: 'object', additionalProperties: false, properties: { ...SESSION_SNAPSHOT_PROPERTIES, motd: { type: 'string', required: true }, }, }, render: (_args, value) => [{ type: 'text', text: renderSpawn(value, maxResultBytes) }], }, async execute(args: SpawnArgs, exec) { if (args.type.length === 0) throw new Error('type must be a non-empty string') const result = await ctx.pty.spawn(requireAgent(exec.agent), { type: args.type, ...args.name !== undefined ? { name: args.name } : {}, ...args.cwd !== undefined ? { cwd: args.cwd } : {}, }, exec.signal) return result }, presentCall: (args) => { const parsed = args return { card: 'generic', title: `Open terminal ${parsed.name ?? parsed.type}`, kind: 'execute' } }, })) ctx.tools.register(defineTool({ name: 'terminal_send', description: 'Send text to a persistent terminal. By default Enter is submitted and the call waits for a prompt, stdin wait, output silence, timeout, or session exit.' + (enableRunInBackground ? ' Background mode returns a task id for task_output/task_kill.' : ''), parameters: { sessionId: { type: 'string', required: true, description: 'Terminal session id returned by terminal_open or terminal_list.' }, text: { type: 'string', required: true, description: 'UTF-8 text to write to the terminal.' }, submit: { type: 'boolean', description: 'Submit Enter after text (default true). Set false for control characters or incomplete REPL input.' }, ...enableRunInBackground ? { run_in_background: { type: 'boolean' as const, description: 'Return a task id immediately; collect with task_output or stop with task_kill.' } } : {}, }, finalizeContent, output: { schema: { oneOf: [ BACKGROUND_TASK_OUTPUT_SCHEMA, { type: 'object', additionalProperties: false, properties: { kind: { type: 'string', required: true, const: 'foreground' }, viewport: { type: 'string', required: true }, waitReason: { type: 'string', required: true, enum: ['stdin_read', 'inferred_idle', 'timeout', 'session_exit'], }, sessionStatus: { ...SESSION_STATUS_SCHEMA, required: true }, truncated: { type: 'boolean', required: true }, }, }, ], }, render: (_args, value) => [{ type: 'text', text: value.kind === 'background' ? `started background task ${value.taskId}` : renderSend(value, maxResultBytes), }], presentationMeta: (_args, value) => value.kind === 'foreground' ? { viewport: value.viewport, waitReason: value.waitReason, sessionStatus: value.sessionStatus, truncated: value.truncated, } : null, }, async execute(args: SendArgs, exec) { const owner = requireAgent(exec.agent) const id = sessionId(args) const request = { text: args.text, submit: args.submit ?? true } if (args.run_in_background === true) { if (!enableRunInBackground) throw new Error('background terminal sends are disabled by tool-pty configuration') const tasks = ctx.get('tasks') if (tasks === undefined) throw new Error('background terminal sends require @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks') let cancelRequested = false const taskId = tasks.start({ kind: 'pty-send', label: `${id}: ${args.text || '(input)'}`, owner, outputLimitBytes: maxResultBytes, run: () => { const operation = ctx.pty.startSend(owner, id, request) return { cancel: () => { cancelRequested = true operation.cancel() }, done: operation.done.then( result => ({ status: cancelRequested ? 'killed' as const : 'completed' as const, detail: sendDetail(result) }), (error: unknown) => ({ status: 'failed' as const, detail: String(error) }), ), readOutput: () => renderSendRead(operation.readOutput()), } }, }) return { kind: 'background' as const, taskId } } const operation = ctx.pty.startSend(owner, id, { ...request, signal: exec.signal }) const result = await operation.done if (exec.signal.aborted) throw new Error('terminal send aborted') return { kind: 'foreground' as const, ...result } }, presentCall(args) { const parsed = args as Partial if (parsed.run_in_background === true) { return { card: 'generic', title: `Send to terminal ${parsed.sessionId as string} in background`, kind: 'execute', rawInput: parsed.text } } return { card: 'terminal', title: parsed.text || '(send input)', description: `Terminal ${parsed.sessionId as string}` } }, presentResult(args, result) { if ((args as Partial).run_in_background === true || result.isError) return undefined const raw = rawContentText(result.content) return raw === undefined ? undefined : { card: 'terminal', output: raw } }, })) ctx.tools.register(defineTool({ name: 'terminal_read', description: 'Read a bounded page of retained output from a persistent terminal without sending input.', parameters: { sessionId: { type: 'string', required: true, description: 'Terminal session id.' }, offset: { type: 'number', description: 'Newest-relative line offset (default 0).' }, count: { type: 'number', description: 'Requested line count (default 500; backend caps apply).' }, }, finalizeContent, output: { schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true }, totalLines: { type: 'integer', required: true }, lineBegin: { type: 'integer', required: true }, lineEnd: { type: 'integer', required: true }, truncated: { type: 'boolean', required: true }, }, }, render: (_args, value) => [{ type: 'text', text: renderRead(value, maxResultBytes) }], }, execute(args: ReadArgs, exec) { const result = ctx.pty.read(requireAgent(exec.agent), sessionId(args), { ...args.offset !== undefined ? { offset: args.offset } : {}, ...args.count !== undefined ? { count: args.count } : {}, }) return Promise.resolve(result) }, presentCall: args => ({ card: 'generic', title: `Read terminal ${(args).sessionId}`, kind: 'read', rawInput: args }), })) ctx.tools.register(defineTool({ name: 'terminal_signal', description: 'Send an allowed signal to the current foreground process group of a persistent terminal.', parameters: { sessionId: { type: 'string', required: true, description: 'Terminal session id.' }, signal: { type: 'string', required: true, enum: ['SIGINT', 'SIGTERM', 'SIGKILL', 'SIGTSTP', 'SIGHUP'], description: 'Signal to deliver. Shell-targeted SIGKILL is rejected; use terminal_close.' }, }, finalizeContent, output: { schema: { type: 'object', additionalProperties: false, properties: { delivered: { type: 'boolean', required: true, const: true }, targetPgid: { type: 'integer', required: true }, }, }, render: (args, value) => [{ type: 'text', text: `delivered ${args.signal} to foreground process group ${value.targetPgid}` }], }, async execute(args: SignalArgs, exec) { return ctx.pty.signal(requireAgent(exec.agent), sessionId(args), args.signal) }, presentCall: args => ({ card: 'generic', title: `Signal terminal ${args.sessionId}`, kind: 'execute', rawInput: args }), })) ctx.tools.register(defineTool({ name: 'terminal_close', description: 'Close one persistent terminal and wait until its captured owned process tree is gone.', parameters: { sessionId: { type: 'string', required: true, description: 'Terminal session id.' }, }, finalizeContent, output: { schema: { type: 'object', additionalProperties: false, properties: { sessionId: { type: 'string', required: true }, outcome: { type: 'string', required: true, enum: ['closed', 'already-closing'] }, }, }, render: (_args, value) => [{ type: 'text', text: value.outcome === 'closed' ? `closed terminal session ${value.sessionId}` : `terminal session ${value.sessionId} was already closing`, }], }, async execute(args: SessionArgs, exec) { const id = sessionId(args) const closed = await ctx.pty.kill(requireAgent(exec.agent), id) return { sessionId: id, outcome: closed ? 'closed' as const : 'already-closing' as const } }, presentCall: args => ({ card: 'generic', title: `Close terminal ${(args).sessionId}`, kind: 'delete' }), })) ctx.tools.register(defineTool({ name: 'terminal_list', description: 'List persistent terminal sessions owned by the current agent.', parameters: {}, finalizeContent, output: { schema: { type: 'array', items: SESSION_SNAPSHOT_SCHEMA }, render: (_args, value) => [{ type: 'text', text: renderList(value, maxResultBytes) }], }, execute(_args: Record, exec) { return Promise.resolve(ctx.pty.list(requireAgent(exec.agent))) }, presentCall: () => ({ card: 'generic', title: 'List terminal sessions', kind: 'read' }), })) }