feat: add persistent PTY sessions

This commit is contained in:
NI0317
2026-07-21 16:01:00 +08:00
parent b4750f6333
commit 58cde5103a
71 changed files with 5677 additions and 82 deletions

View File

@@ -0,0 +1,225 @@
/**
* Six model-facing persistent PTY 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 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 { ToolExecutionResult, ToolResult } from '@deepseek-ai/dsh-tools'
import { 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']
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
}
function requireAgent(agent: Agent | undefined): Agent {
if (agent === undefined) throw new Error('PTY 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): ContentBlock[] {
return [{ type: 'text', text }]
}
function rawResultText(result: ToolResult): string | undefined {
if (result.content.length !== 1) return undefined
const block = result.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 PTY tools and the minimal usage guidance. */
export function apply(ctx: Context): void {
ctx.systemPrompt.section({
name: 'tool:pty',
order: 106,
text: 'Use PTY only when work needs persistent terminal state or interactive stdin; prefer bash/read/write/edit for bounded one-shot operations. Track every PTY session id and kill sessions that no longer matter. An inferred_idle or timeout result does not prove the foreground command exited.',
})
ctx.tools.register(defineTool({
name: 'pty_spawn',
description: 'Create a persistent, owner-isolated PTY 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 PTY 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.' },
},
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 textResult(renderSpawn(result))
},
presentCall: (args) => {
const parsed = args
return { card: 'generic', title: `Start PTY ${parsed.name ?? parsed.type}`, kind: 'execute' }
},
}))
ctx.tools.register(defineTool({
name: 'pty_send',
description: 'Send text to a persistent PTY. By default Enter is submitted and the call waits for a prompt, stdin wait, output silence, timeout, or session exit. Background mode returns a task id for task_output/task_kill.',
parameters: {
sessionId: { type: 'string', required: true, description: 'PTY session id returned by pty_spawn or pty_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.' },
run_in_background: { type: 'boolean', description: 'Return a task id immediately; collect with task_output or stop with task_kill.' },
},
async execute(args: SendArgs, exec): Promise<ToolExecutionResult> {
const owner = requireAgent(exec.agent)
const id = sessionId(args)
const request = { text: args.text, submit: args.submit ?? true }
if (args.run_in_background === true) {
const tasks = ctx.get('tasks')
if (tasks === undefined) throw new Error('background PTY sends require @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks')
if (exec.signal?.aborted === true) throw new Error('PTY send aborted')
let cancelRequested = false
const taskId = tasks.start({
kind: 'pty-send',
label: `${id}: ${args.text || '(input)'}`,
owner,
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 { content: textResult(`started background task ${taskId}`), isError: false }
}
const operation = ctx.pty.startSend(owner, id, { ...request, ...exec.signal ? { signal: exec.signal } : {} })
const result = await operation.done
if (exec.signal?.aborted === true) throw new Error('PTY send aborted')
return { content: textResult(renderSend(result)), isError: false, meta: result }
},
presentCall(args) {
const parsed = args as Partial<SendArgs>
if (parsed.run_in_background === true) {
return { card: 'generic', title: `Send PTY ${parsed.sessionId as string} in background`, kind: 'execute', rawInput: parsed.text }
}
return { card: 'terminal', title: parsed.text || '(send input)', description: `PTY ${parsed.sessionId as string}` }
},
presentResult(args, result) {
if ((args as Partial<SendArgs>).run_in_background === true || result.isError) return undefined
const raw = rawResultText(result)
return raw === undefined ? undefined : { card: 'terminal', output: raw }
},
}))
ctx.tools.register(defineTool({
name: 'pty_read',
description: 'Read a bounded page of retained output from a persistent PTY without sending input.',
parameters: {
sessionId: { type: 'string', required: true, description: 'PTY session id.' },
offset: { type: 'number', description: 'Newest-relative line offset (default 0).' },
count: { type: 'number', description: 'Requested line count (default 500; backend caps apply).' },
},
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(textResult(renderRead(result)))
},
presentCall: args => ({ card: 'generic', title: `Read PTY ${(args).sessionId}`, kind: 'read', rawInput: args }),
}))
ctx.tools.register(defineTool({
name: 'pty_signal',
description: 'Send an allowed signal to the current foreground process group of a persistent PTY.',
parameters: {
sessionId: { type: 'string', required: true, description: 'PTY session id.' },
signal: { type: 'string', required: true, enum: ['SIGINT', 'SIGTERM', 'SIGKILL', 'SIGTSTP', 'SIGHUP'], description: 'Signal to deliver. Shell-targeted SIGKILL is rejected; use pty_kill.' },
},
async execute(args: SignalArgs, exec) {
const result = await ctx.pty.signal(requireAgent(exec.agent), sessionId(args), args.signal)
return textResult(`delivered ${args.signal} to foreground process group ${result.targetPgid}`)
},
presentCall: args => ({ card: 'generic', title: `Signal PTY ${(args as SignalArgs).sessionId}`, kind: 'execute', rawInput: args }),
}))
ctx.tools.register(defineTool({
name: 'pty_kill',
description: 'Close one persistent PTY and wait until its captured owned process tree is gone.',
parameters: {
sessionId: { type: 'string', required: true, description: 'PTY session id.' },
},
async execute(args: SessionArgs, exec) {
const id = sessionId(args)
const killed = await ctx.pty.kill(requireAgent(exec.agent), id)
return textResult(killed ? `killed PTY session ${id}` : `PTY session ${id} was already closing`)
},
presentCall: args => ({ card: 'generic', title: `Kill PTY ${(args).sessionId}`, kind: 'delete' }),
}))
ctx.tools.register(defineTool({
name: 'pty_list',
description: 'List persistent PTY sessions owned by the current agent.',
parameters: {},
execute(_args: Record<string, never>, exec) {
return Promise.resolve(textResult(renderList(ctx.pty.list(requireAgent(exec.agent)))))
},
presentCall: () => ({ card: 'generic', title: 'List PTY sessions', kind: 'read' }),
}))
}

View File

@@ -0,0 +1,62 @@
/** Model and ACP rendering for persistent PTY tool results. */
import type { PtyReadResult, PtySendRead, PtySendResult, PtySessionSnapshot, PtySpawnResult } from '@deepseek-ai/dsh-pty'
/**
* Render one created session and its bounded MOTD.
* @param result - published spawn result.
* @returns Model-facing session acknowledgement.
*/
export function renderSpawn(result: PtySpawnResult): string {
const label = result.name === undefined ? result.sessionId : `${result.sessionId} (${result.name})`
return `started PTY session ${label} [type: ${result.type}]\n${result.motd || '(no startup output)'}`
}
/**
* Render one settled interactive send.
* @param result - settled send outcome.
* @returns Terminal output plus wait/session markers.
*/
export function renderSend(result: PtySendResult): string {
const output = result.viewport || '(no new output)'
const status = result.sessionStatus.kind === 'running'
? 'running'
: `exited code=${result.sessionStatus.exitCode ?? 'null'} signal=${result.sessionStatus.signal ?? 'null'}`
return `${output}\n[wait: ${result.waitReason}]\n[session: ${status}]${result.truncated ? '\n[output truncated]' : ''}`
}
/**
* Render one incremental background operation read.
* @param read - consuming operation delta.
* @returns Delta plus truncation marker when needed.
*/
export function renderSendRead(read: PtySendRead): string {
return `${read.delta}${read.truncated ? `${read.delta.endsWith('\n') || read.delta.length === 0 ? '' : '\n'}[output truncated]` : ''}`
}
/**
* Render one bounded historical page.
* @param result - retained scrollback page.
* @returns Page text plus pagination and truncation markers.
*/
export function renderRead(result: PtyReadResult): string {
const output = result.text || '(no retained output)'
return `${output}\n[lines: ${result.lineBegin}-${result.lineEnd} of ${result.totalLines}]${result.truncated ? '\n[output truncated]' : ''}`
}
/**
* Render owner-visible live sessions.
* @param sessions - fresh owner-scoped snapshots.
* @returns One line per session or the empty marker.
*/
export function renderList(sessions: PtySessionSnapshot[]): string {
if (sessions.length === 0) return '(no PTY sessions)'
return sessions.map((session) => {
const name = session.name === undefined ? '' : ` (${session.name})`
const pid = session.pid === undefined ? '' : ` pid=${session.pid}`
const status = session.status.kind === 'running'
? 'running'
: `exited code=${session.status.exitCode ?? 'null'} signal=${session.status.signal ?? 'null'}`
return `${session.sessionId}${name} [${session.type}] ${status}${pid}`
}).join('\n')
}