2026-07-21 16:01:00 +08:00
/**
2026-07-21 19:29:56 +08:00
* Six model-facing persistent terminal tools. Owner identity comes from the exact
2026-07-21 16:01:00 +08:00
* tool execution Agent; generic `ctx.tasks` owns background ids and collection.
* @module @deepseek-ai/dsh-tool-pty
*/
import { Context } from 'cordis'
2026-07-22 22:37:20 +08:00
import z from 'schemastery'
2026-07-21 16:01:00 +08:00
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'
2026-07-23 20:50:45 +08:00
import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
2026-07-22 22:37:20 +08:00
import { boundTerminalText , renderList , renderRead , renderSend , renderSendRead , renderSpawn } from './render.ts'
2026-07-21 16:01:00 +08:00
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' ]
2026-07-22 22:37:20 +08:00
/** Default cap for one complete model-facing terminal result. */
export const DEFAULT_MAX_RESULT_BYTES = 256 * 1024
2026-07-23 00:48:00 +08:00
/** Smallest cap that preserves every counter-backed PTY and task id in its creation acknowledgement. */
export const MIN_MAX_RESULT_BYTES = 64
2026-07-22 22:37:20 +08:00
/** 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 < Config > = z . object ( {
enableRunInBackground : z.boolean ( ) . default ( true ) ,
2026-07-23 00:48:00 +08:00
maxResultBytes : z.number ( ) . step ( 1 ) . min ( MIN_MAX_RESULT_BYTES ) . max ( Number . MAX_SAFE_INTEGER ) . default ( DEFAULT_MAX_RESULT_BYTES ) ,
2026-07-22 22:37:20 +08:00
} )
2026-07-21 16:01:00 +08:00
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
}
2026-07-22 23:23:01 +08:00
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
2026-07-22 23:24:43 +08:00
const BACKGROUND_TASK_OUTPUT_SCHEMA = {
type : 'object' ,
additionalProperties : false ,
properties : {
kind : { type : 'string' , required : true , const : 'background' } ,
taskId : { type : 'string' , required : true } ,
} ,
} as const
2026-07-21 16:01:00 +08:00
function requireAgent ( agent : Agent | undefined ) : Agent {
2026-07-21 19:29:56 +08:00
if ( agent === undefined ) throw new Error ( 'terminal tools require an initiating agent' )
2026-07-21 16:01:00 +08:00
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 )
}
2026-07-22 22:37:20 +08:00
function textResult ( text : string , maxBytes : number ) : ContentBlock [ ] {
return [ { type : 'text' , text : boundTerminalText ( text , maxBytes ) } ]
2026-07-21 16:01:00 +08:00
}
2026-07-23 01:21:14 +08:00
function rawContentText ( content : readonly ContentBlock [ ] ) : string | undefined {
if ( content . length !== 1 ) return undefined
const block = content [ 0 ]
2026-07-21 16:01:00 +08:00
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' } `
}
2026-07-21 19:29:56 +08:00
/** Register all terminal tools and the minimal usage guidance. */
2026-07-22 22:37:20 +08:00
export function apply ( ctx : Context , config : Config = { } ) : void {
const enableRunInBackground = config . enableRunInBackground ? ? true
const maxResultBytes = config . maxResultBytes ? ? DEFAULT_MAX_RESULT_BYTES
2026-07-23 00:48:00 +08:00
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 } ` )
2026-07-22 22:37:20 +08:00
}
2026-07-23 02:53:43 +08:00
const finalizeContent : NonNullable < ToolDefinition [ 'finalizeContent' ] > = ( _exec , result ) = > {
const raw = rawContentText ( result . content )
return raw === undefined ? undefined : textResult ( raw , maxResultBytes )
}
2026-07-21 16:01:00 +08:00
ctx . systemPrompt . section ( {
name : 'tool:pty' ,
order : 106 ,
2026-07-21 19:29:56 +08:00
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.' ,
2026-07-21 16:01:00 +08:00
} )
ctx . tools . register ( defineTool ( {
2026-07-21 19:29:56 +08:00
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.' ,
2026-07-21 16:01:00 +08:00
parameters : {
2026-07-21 19:29:56 +08:00
type : { type : 'string' , required : true , description : 'Registered terminal backend type, usually "shell".' } ,
2026-07-21 16:01:00 +08:00
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.' } ,
} ,
2026-07-23 02:53:43 +08:00
finalizeContent ,
2026-07-22 23:23:01 +08:00
output : {
schema : {
type : 'object' ,
additionalProperties : false ,
properties : {
. . . SESSION_SNAPSHOT_PROPERTIES ,
motd : { type : 'string' , required : true } ,
} ,
} ,
2026-07-23 20:50:45 +08:00
render : ( _args , value ) = > [ { type : 'text' , text : renderSpawn ( value , maxResultBytes ) } ] ,
2026-07-22 23:23:01 +08:00
} ,
2026-07-21 16:01:00 +08:00
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 )
2026-07-22 23:23:01 +08:00
return result
2026-07-21 16:01:00 +08:00
} ,
presentCall : ( args ) = > {
const parsed = args
2026-07-21 19:29:56 +08:00
return { card : 'generic' , title : ` Open terminal ${ parsed . name ? ? parsed . type } ` , kind : 'execute' }
2026-07-21 16:01:00 +08:00
} ,
} ) )
ctx . tools . register ( defineTool ( {
2026-07-21 19:29:56 +08:00
name : 'terminal_send' ,
2026-07-22 22:37:20 +08:00
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.' : '' ) ,
2026-07-21 16:01:00 +08:00
parameters : {
2026-07-21 19:29:56 +08:00
sessionId : { type : 'string' , required : true , description : 'Terminal session id returned by terminal_open or terminal_list.' } ,
2026-07-21 16:01:00 +08:00
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.' } ,
2026-07-22 22:37:20 +08:00
. . . enableRunInBackground
? { run_in_background : { type : 'boolean' as const , description : 'Return a task id immediately; collect with task_output or stop with task_kill.' } }
: { } ,
2026-07-21 16:01:00 +08:00
} ,
2026-07-23 02:53:43 +08:00
finalizeContent ,
2026-07-22 23:23:01 +08:00
output : {
schema : {
oneOf : [
2026-07-22 23:24:43 +08:00
BACKGROUND_TASK_OUTPUT_SCHEMA ,
2026-07-22 23:23:01 +08:00
{
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 } `
2026-07-23 20:50:45 +08:00
: renderSend ( value , maxResultBytes ) ,
2026-07-22 23:23:01 +08:00
} ] ,
presentationMeta : ( _args , value ) = > value . kind === 'foreground'
? {
viewport : value.viewport ,
waitReason : value.waitReason ,
sessionStatus : value.sessionStatus ,
truncated : value.truncated ,
}
: null ,
} ,
async execute ( args : SendArgs , exec ) {
2026-07-21 16:01:00 +08:00
const owner = requireAgent ( exec . agent )
const id = sessionId ( args )
const request = { text : args.text , submit : args.submit ? ? true }
if ( args . run_in_background === true ) {
2026-07-22 22:37:20 +08:00
if ( ! enableRunInBackground ) throw new Error ( 'background terminal sends are disabled by tool-pty configuration' )
2026-07-21 16:01:00 +08:00
const tasks = ctx . get ( 'tasks' )
2026-07-21 19:29:56 +08:00
if ( tasks === undefined ) throw new Error ( 'background terminal sends require @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks' )
2026-07-21 16:01:00 +08:00
let cancelRequested = false
const taskId = tasks . start ( {
kind : 'pty-send' ,
label : ` ${ id } : ${ args . text || '(input)' } ` ,
owner ,
2026-07-22 22:37:20 +08:00
outputLimitBytes : maxResultBytes ,
2026-07-21 16:01:00 +08:00
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 ( ) ) ,
}
} ,
} )
2026-07-22 23:23:01 +08:00
return { kind : 'background' as const , taskId }
2026-07-21 16:01:00 +08:00
}
2026-07-22 21:12:16 +08:00
const operation = ctx . pty . startSend ( owner , id , { . . . request , signal : exec.signal } )
2026-07-21 16:01:00 +08:00
const result = await operation . done
2026-07-22 21:12:16 +08:00
if ( exec . signal . aborted ) throw new Error ( 'terminal send aborted' )
2026-07-22 23:23:01 +08:00
return { kind : 'foreground' as const , . . . result }
2026-07-21 16:01:00 +08:00
} ,
presentCall ( args ) {
const parsed = args as Partial < SendArgs >
if ( parsed . run_in_background === true ) {
2026-07-21 19:29:56 +08:00
return { card : 'generic' , title : ` Send to terminal ${ parsed . sessionId as string } in background ` , kind : 'execute' , rawInput : parsed.text }
2026-07-21 16:01:00 +08:00
}
2026-07-21 19:29:56 +08:00
return { card : 'terminal' , title : parsed.text || '(send input)' , description : ` Terminal ${ parsed . sessionId as string } ` }
2026-07-21 16:01:00 +08:00
} ,
presentResult ( args , result ) {
if ( ( args as Partial < SendArgs > ) . run_in_background === true || result . isError ) return undefined
2026-07-23 01:21:14 +08:00
const raw = rawContentText ( result . content )
2026-07-21 16:01:00 +08:00
return raw === undefined ? undefined : { card : 'terminal' , output : raw }
} ,
} ) )
ctx . tools . register ( defineTool ( {
2026-07-21 19:29:56 +08:00
name : 'terminal_read' ,
description : 'Read a bounded page of retained output from a persistent terminal without sending input.' ,
2026-07-21 16:01:00 +08:00
parameters : {
2026-07-21 19:29:56 +08:00
sessionId : { type : 'string' , required : true , description : 'Terminal session id.' } ,
2026-07-21 16:01:00 +08:00
offset : { type : 'number' , description : 'Newest-relative line offset (default 0).' } ,
count : { type : 'number' , description : 'Requested line count (default 500; backend caps apply).' } ,
} ,
2026-07-23 02:53:43 +08:00
finalizeContent ,
2026-07-22 23:23:01 +08:00
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 } ,
} ,
} ,
2026-07-23 20:50:45 +08:00
render : ( _args , value ) = > [ { type : 'text' , text : renderRead ( value , maxResultBytes ) } ] ,
2026-07-22 23:23:01 +08:00
} ,
2026-07-21 16:01:00 +08:00
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 } : { } ,
} )
2026-07-22 23:23:01 +08:00
return Promise . resolve ( result )
2026-07-21 16:01:00 +08:00
} ,
2026-07-21 19:29:56 +08:00
presentCall : args = > ( { card : 'generic' , title : ` Read terminal ${ ( args ) . sessionId } ` , kind : 'read' , rawInput : args } ) ,
2026-07-21 16:01:00 +08:00
} ) )
ctx . tools . register ( defineTool ( {
2026-07-21 19:29:56 +08:00
name : 'terminal_signal' ,
description : 'Send an allowed signal to the current foreground process group of a persistent terminal.' ,
2026-07-21 16:01:00 +08:00
parameters : {
2026-07-21 19:29:56 +08:00
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.' } ,
2026-07-21 16:01:00 +08:00
} ,
2026-07-23 02:53:43 +08:00
finalizeContent ,
2026-07-22 23:23:01 +08:00
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 } ` } ] ,
} ,
2026-07-21 16:01:00 +08:00
async execute ( args : SignalArgs , exec ) {
2026-07-22 23:23:01 +08:00
return ctx . pty . signal ( requireAgent ( exec . agent ) , sessionId ( args ) , args . signal )
2026-07-21 16:01:00 +08:00
} ,
2026-07-22 23:04:46 +08:00
presentCall : args = > ( { card : 'generic' , title : ` Signal terminal ${ args . sessionId } ` , kind : 'execute' , rawInput : args } ) ,
2026-07-21 16:01:00 +08:00
} ) )
ctx . tools . register ( defineTool ( {
2026-07-21 19:29:56 +08:00
name : 'terminal_close' ,
description : 'Close one persistent terminal and wait until its captured owned process tree is gone.' ,
2026-07-21 16:01:00 +08:00
parameters : {
2026-07-21 19:29:56 +08:00
sessionId : { type : 'string' , required : true , description : 'Terminal session id.' } ,
2026-07-21 16:01:00 +08:00
} ,
2026-07-23 02:53:43 +08:00
finalizeContent ,
2026-07-22 23:23:01 +08:00
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 ` ,
} ] ,
} ,
2026-07-21 16:01:00 +08:00
async execute ( args : SessionArgs , exec ) {
const id = sessionId ( args )
2026-07-21 19:29:56 +08:00
const closed = await ctx . pty . kill ( requireAgent ( exec . agent ) , id )
2026-07-22 23:23:01 +08:00
return { sessionId : id , outcome : closed ? 'closed' as const : 'already-closing' as const }
2026-07-21 16:01:00 +08:00
} ,
2026-07-21 19:29:56 +08:00
presentCall : args = > ( { card : 'generic' , title : ` Close terminal ${ ( args ) . sessionId } ` , kind : 'delete' } ) ,
2026-07-21 16:01:00 +08:00
} ) )
ctx . tools . register ( defineTool ( {
2026-07-21 19:29:56 +08:00
name : 'terminal_list' ,
description : 'List persistent terminal sessions owned by the current agent.' ,
2026-07-21 16:01:00 +08:00
parameters : { } ,
2026-07-23 02:53:43 +08:00
finalizeContent ,
2026-07-22 23:23:01 +08:00
output : {
schema : { type : 'array' , items : SESSION_SNAPSHOT_SCHEMA } ,
2026-07-23 20:50:45 +08:00
render : ( _args , value ) = > [ { type : 'text' , text : renderList ( value , maxResultBytes ) } ] ,
2026-07-22 23:23:01 +08:00
} ,
2026-07-21 16:01:00 +08:00
execute ( _args : Record < string , never > , exec ) {
2026-07-22 23:23:01 +08:00
return Promise . resolve ( ctx . pty . list ( requireAgent ( exec . agent ) ) )
2026-07-21 16:01:00 +08:00
} ,
2026-07-21 19:29:56 +08:00
presentCall : ( ) = > ( { card : 'generic' , title : 'List terminal sessions' , kind : 'read' } ) ,
2026-07-21 16:01:00 +08:00
} ) )
}