2026-07-29 14:12:27 +08:00
/**
* Model-facing persistent `bash` tool over the owner-scoped PTY seam.
* @module @deepseek-ai/dsh-tool-bash-persistent
*/
import { randomUUID } from 'node:crypto'
import type { Context } from 'cordis'
import z from 'schemastery'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { PtyReadResult , PtySendResult , PtySessionId } from '@deepseek-ai/dsh-pty'
import { deadline , timeoutOf } from '@deepseek-ai/dsh-timeout'
import { defineTool } from '@deepseek-ai/dsh-tools'
2026-07-29 21:51:07 +08:00
// TODO: Replace the file-search advice; arbitrary command output need not come from a searchable file.
2026-07-29 14:12:27 +08:00
const TRUNCATED_MESSAGE = '<response clipped><NOTE>To save on context only part of this file has been shown to you. You should retry this tool after you have searched inside the file with `grep -n` in order to find the line numbers of what you are looking for.</NOTE>'
const LOST_PREFIX_MESSAGE = '<response clipped><NOTE>The beginning of this command output was dropped by the terminal scrollback limit. The following text is the earliest retained output.</NOTE>\n'
const SHELL_RESET_MESSAGE = 'The persistent bash shell was reset; the next bash call starts from the workspace with a fresh current directory and environment.'
const SHELL_PROMPT = '__DSH_PERSISTENT_BASH_PROMPT__ '
const TIMEOUT_CODE = 'PERSISTENT_BASH_TIMEOUT'
2026-07-29 15:21:56 +08:00
// One page is enough to find a just-emitted completion marker; the full
// scrollback is assembled only when a command settles or needs partial output.
2026-07-29 14:12:27 +08:00
const SCROLLBACK_PAGE_LINES = 1 _000
2026-07-29 15:21:56 +08:00
const POLL_INTERVAL_MS = 25
2026-07-29 14:12:27 +08:00
const DEFAULT_DESCRIPTION = 'Run commands in a persistent bash shell. State, including the current directory and exported environment variables, persists across calls for this agent.'
interface ResolvedConfig {
backendType : string
timeoutMs : number
maxOutputChars : number
description : string
}
interface CommandMarkers {
start : string
end : string
}
interface RetainedOutput {
text : string
truncated : boolean
}
interface CapturedOutput {
text : string
incomplete : boolean
2026-07-29 21:24:33 +08:00
exitCode? : number
2026-07-29 14:12:27 +08:00
}
interface PersistentShells {
get ( owner : Agent , signal : AbortSignal ) : Promise < PtySessionId >
reset ( owner : Agent , reason : string ) : Promise < void >
}
function maybeTruncate ( content : string , maxOutputChars : number , incomplete = false ) : string {
if ( content . length <= maxOutputChars && ! incomplete ) return content
return content . length <= maxOutputChars
? content + TRUNCATED_MESSAGE
: content.slice ( 0 , maxOutputChars ) + TRUNCATED_MESSAGE
}
function markers ( ) : CommandMarkers {
const nonce = randomUUID ( )
return {
start : ` __DSH_PERSISTENT_BASH_START_ ${ nonce } __ ` ,
end : ` __DSH_PERSISTENT_BASH_END_ ${ nonce } : ` ,
}
}
function quoteForBash ( value : string ) : string {
return ` $ ' ${ value
. replaceAll ( '\\' , '\\\\' )
. replaceAll ( "'" , "\\'" )
. replaceAll ( '\r' , '\\r' )
. replaceAll ( '\n' , '\\n' ) } ' `
}
function wrapCommand ( command : string , marker : CommandMarkers ) : string {
// Keep the wrapper on one physical line. An interactive bash prints PS2 for
// embedded newlines before executing the buffer, which would leak terminal
// prompts and marker source text into the model-facing result.
return ` printf '%s \\ n' ${ quoteForBash ( marker . start ) } ; eval -- ${ quoteForBash ( command ) } ; __dsh_persistent_bash_status= $ ?; printf '%s%s \\ n' ${ quoteForBash ( marker . end ) } " $ __dsh_persistent_bash_status" `
}
function stripPrompt ( text : string ) : string {
2026-07-29 15:33:46 +08:00
let result = text . replace ( /\r?\n$/ , '' )
2026-07-29 14:12:27 +08:00
while ( result . endsWith ( SHELL_PROMPT ) ) {
result = result . slice ( 0 , - SHELL_PROMPT . length )
}
return result . endsWith ( '\n' ) ? result . slice ( 0 , - 1 ) : result
}
function commandOutput (
snapshot : RetainedOutput ,
marker : CommandMarkers ,
2026-07-29 23:50:12 +08:00
) : CapturedOutput | undefined {
2026-07-29 14:12:27 +08:00
const text = snapshot . text
const end = text . lastIndexOf ( marker . end )
2026-07-29 23:50:12 +08:00
const status = /^(\d+)\r?\n/ . exec ( text . slice ( end + marker . end . length ) ) ? . [ 1 ]
if ( status === undefined ) return undefined
2026-07-29 14:12:27 +08:00
const startMarker = text . lastIndexOf ( marker . start , end )
const start = startMarker < 0 ? 0 : startMarker + marker . start . length
return {
text : stripPrompt ( text . slice ( start , end ) . replace ( /^\r?\n/ , '' ) ) ,
2026-07-29 15:21:56 +08:00
incomplete : startMarker < 0 ,
2026-07-29 23:50:12 +08:00
exitCode : Number ( status ) ,
2026-07-29 14:12:27 +08:00
}
}
function promptCompleted ( result : PtySendResult ) : boolean {
return result . viewport . endsWith ( SHELL_PROMPT )
|| result . viewport . endsWith ( ` ${ SHELL_PROMPT } \ r \ n ` )
|| result . viewport . endsWith ( ` ${ SHELL_PROMPT } \ n ` )
}
function partialOutput (
snapshot : RetainedOutput ,
marker : CommandMarkers ,
fallback : string ,
2026-07-29 15:21:56 +08:00
fallbackTruncated = false ,
2026-07-29 14:12:27 +08:00
) : CapturedOutput {
const startMarker = snapshot . text . lastIndexOf ( marker . start )
if ( startMarker >= 0 ) {
return {
text : stripPrompt ( snapshot . text . slice ( startMarker + marker . start . length ) . replace ( /^\r?\n/ , '' ) ) ,
2026-07-29 15:21:56 +08:00
incomplete : false ,
2026-07-29 14:12:27 +08:00
}
}
2026-07-29 15:21:56 +08:00
const fallbackStart = fallback . lastIndexOf ( marker . start )
const afterStart = fallbackStart < 0
? fallback
: fallback.slice ( fallbackStart + marker . start . length ) . replace ( /^\r?\n/ , '' )
const fallbackEnd = afterStart . lastIndexOf ( marker . end )
const beforeEnd = fallbackEnd < 0 ? afterStart : afterStart.slice ( 0 , fallbackEnd )
2026-07-29 14:12:27 +08:00
return {
2026-07-29 15:21:56 +08:00
text : stripPrompt ( beforeEnd . replaceAll ( SHELL_PROMPT , '' ) ) ,
incomplete : fallbackTruncated || fallbackStart < 0 ,
2026-07-29 14:12:27 +08:00
}
}
async function pause ( ) : Promise < void > {
2026-07-29 15:21:56 +08:00
await new Promise ( resolve = > setTimeout ( resolve , POLL_INTERVAL_MS ) )
2026-07-29 14:12:27 +08:00
}
function nextScrollbackOffset ( page : PtyReadResult , offset : number ) : number | undefined {
if ( page . text . length === 0 || page . lineEnd <= offset ) return undefined
return page . lineEnd
}
function retainedScrollback (
ctx : Context ,
owner : Agent ,
id : PtySessionId ,
2026-07-29 15:21:56 +08:00
latest = ctx . pty . read ( owner , id , { offset : 0 , count : SCROLLBACK_PAGE_LINES } ) ,
2026-07-29 14:12:27 +08:00
) : RetainedOutput {
2026-07-29 15:21:56 +08:00
const pages : string [ ] = latest . text . length === 0 ? [ ] : [ latest . text ]
let offset = latest . lineEnd
let truncated = latest . truncated
2026-07-29 14:12:27 +08:00
while ( true ) {
2026-07-29 15:21:56 +08:00
if ( offset >= latest . totalLines ) break
2026-07-29 14:12:27 +08:00
const page = ctx . pty . read ( owner , id , { offset , count : SCROLLBACK_PAGE_LINES } )
truncated || = page . truncated
if ( page . text . length > 0 ) pages . unshift ( page . text )
const next = nextScrollbackOffset ( page , offset )
if ( next === undefined || next >= page . totalLines ) break
offset = next
}
return { text : pages.join ( '\n' ) , truncated }
}
function renderCaptured ( output : CapturedOutput , maxOutputChars : number ) : string {
const rendered = maybeTruncate ( output . text , maxOutputChars , output . incomplete )
2026-07-29 21:24:33 +08:00
const withPrefix = output . incomplete && output . text . length > 0
2026-07-29 14:12:27 +08:00
? LOST_PREFIX_MESSAGE + rendered
: rendered
2026-07-30 00:15:39 +08:00
const marker = output . exitCode !== undefined && output . exitCode !== 0
? ` [exit code: ${ output . exitCode } ] `
: undefined
return appendStatusMarker ( withPrefix , marker )
2026-07-29 21:24:33 +08:00
}
2026-07-30 00:15:39 +08:00
function appendStatusMarker ( content : string , marker : string | undefined ) : string {
if ( marker === undefined ) return content
return content . length === 0 ? marker : ` ${ content } \ n ${ marker } `
}
function renderShellExitStatus (
2026-07-29 21:24:33 +08:00
content : string ,
exitCode : number | null ,
signal : NodeJS.Signals | null ,
) : string {
const marker = signal !== null
2026-07-30 00:15:39 +08:00
? ` [shell killed by signal: ${ signal } ] `
: exitCode !== null
? ` [shell exited: code ${ exitCode } ] `
: '[shell exited]'
return appendStatusMarker ( content , marker )
2026-07-29 14:12:27 +08:00
}
function persistentShells ( ctx : Context , config : ResolvedConfig ) : PersistentShells {
const pending = new WeakMap < Agent , Promise < PtySessionId > > ( )
const live = new Map < Agent , PtySessionId > ( )
2026-07-29 15:21:56 +08:00
const creating = new Set < Promise < PtySessionId > > ( )
2026-07-29 14:12:27 +08:00
const ownerCleanupInstalled = new WeakSet < Agent > ( )
2026-07-29 15:21:56 +08:00
const lifecycle = new AbortController ( )
2026-07-29 14:12:27 +08:00
const close = async ( owner : Agent , id : PtySessionId , reason : string ) : Promise < void > = > {
if ( ! ctx . pty . list ( owner ) . some ( snapshot = > snapshot . sessionId === id ) ) return
await ctx . pty . kill ( owner , id , reason )
}
ctx . effect ( ( ) = > async ( ) = > {
2026-07-29 15:21:56 +08:00
lifecycle . abort ( new Error ( 'tool-bash-persistent disposed during shell creation' ) )
await Promise . allSettled ( [ . . . creating ] )
2026-07-29 14:12:27 +08:00
const closing = [ . . . live ] . map ( async ( [ owner , id ] ) = > { await close ( owner , id , 'tool-bash-persistent disposed' ) } )
await Promise . all ( closing )
live . clear ( )
} , 'tool-bash-persistent shell cleanup' )
const reset = async ( owner : Agent , reason : string ) : Promise < void > = > {
pending . delete ( owner )
const id = live . get ( owner )
live . delete ( owner )
if ( id !== undefined ) await close ( owner , id , reason )
}
const get = ( owner : Agent , signal : AbortSignal ) : Promise < PtySessionId > = > {
const existing = pending . get ( owner )
if ( existing !== undefined ) return existing
2026-07-29 15:21:56 +08:00
const combinedSignal = AbortSignal . any ( [ signal , lifecycle . signal ] )
const creation = ( async ( ) = > {
2026-07-29 14:12:27 +08:00
try {
const cwd = owner . session . header . cwd
const spawned = await ctx . pty . spawn ( owner , {
type : config . backendType ,
. . . cwd === undefined ? { } : { cwd } ,
2026-07-29 15:21:56 +08:00
} , combinedSignal )
2026-07-29 14:12:27 +08:00
live . set ( owner , spawned . sessionId )
if ( ! ownerCleanupInstalled . has ( owner ) ) {
ownerCleanupInstalled . add ( owner )
owner . ctx . effect ( ( ) = > ( ) = > {
pending . delete ( owner )
live . delete ( owner )
} , 'tool-bash-persistent owner cache cleanup' )
}
const setup = ctx . pty . startSend ( owner , spawned . sessionId , {
text : ` stty -echo; PS1= ${ quoteForBash ( SHELL_PROMPT ) } ` ,
submit : true ,
2026-07-29 15:21:56 +08:00
signal : combinedSignal ,
2026-07-29 14:12:27 +08:00
} )
const result = await setup . done
if ( result . sessionStatus . kind === 'exited' || result . waitReason === 'timeout' ) {
throw new Error ( 'persistent bash shell did not accept initialization' )
}
return spawned . sessionId
} catch ( error : unknown ) {
await reset ( owner , 'persistent bash initialization failed' )
throw error
}
} ) ( )
2026-07-29 15:21:56 +08:00
const tracked = creation . finally ( ( ) = > {
creating . delete ( tracked )
} )
creating . add ( tracked )
pending . set ( owner , tracked )
return tracked
2026-07-29 14:12:27 +08:00
}
return { get , reset }
}
async function executeCommand (
ctx : Context ,
shells : PersistentShells ,
owner : Agent ,
command : string ,
config : ResolvedConfig ,
upstream : AbortSignal ,
) : Promise < string > {
using commandDeadline = deadline ( upstream , config . timeoutMs , TIMEOUT_CODE )
const id = await shells . get ( owner , commandDeadline . signal )
const marker = markers ( )
const wrapped = wrapCommand ( command , marker )
let first = true
let fallback = ''
2026-07-29 15:21:56 +08:00
let fallbackTruncated = false
2026-07-29 14:12:27 +08:00
while ( true ) {
2026-07-29 15:21:56 +08:00
let operation
let result
try {
operation = ctx . pty . startSend ( owner , id , {
text : first ? wrapped : '' ,
submit : first ,
signal : commandDeadline.signal ,
} )
first = false
result = await operation . done
} catch ( error : unknown ) {
await shells . reset ( owner , 'persistent bash send failed' )
throw error
}
const incremental = operation . readOutput ( )
fallback = incremental . delta . length > 0 ? fallback + incremental.delta : result.viewport
fallbackTruncated || = incremental . truncated || result . truncated
const latest = ctx . pty . read ( owner , id , { offset : 0 , count : SCROLLBACK_PAGE_LINES } )
2026-07-29 14:12:27 +08:00
const timedOut = timeoutOf ( commandDeadline . signal , TIMEOUT_CODE )
if ( timedOut !== undefined ) {
2026-07-29 15:21:56 +08:00
const snapshot = retainedScrollback ( ctx , owner , id , latest )
2026-07-29 14:12:27 +08:00
const partial = renderCaptured (
2026-07-29 15:21:56 +08:00
partialOutput ( snapshot , marker , fallback , fallbackTruncated ) ,
2026-07-29 14:12:27 +08:00
config . maxOutputChars ,
)
await shells . reset ( owner , 'persistent bash command timed out' )
return [
2026-07-29 21:51:07 +08:00
// TODO: Report a timeout only; this signal does not establish an OOM.
2026-07-29 14:12:27 +08:00
` Your command timed out after ${ Math . round ( timedOut . timeoutMs / 1000 ) } seconds or experienced an OOM error. Below is partial output: ` ,
partial ,
SHELL_RESET_MESSAGE ,
] . join ( '\n' )
}
2026-07-29 21:33:39 +08:00
if ( commandDeadline . signal . aborted ) {
await shells . reset ( owner , 'persistent bash command aborted' )
commandDeadline . signal . throwIfAborted ( )
}
2026-07-29 15:21:56 +08:00
if ( latest . text . includes ( marker . end ) ) {
const complete = commandOutput ( retainedScrollback ( ctx , owner , id , latest ) , marker )
2026-07-29 23:50:12 +08:00
if ( complete !== undefined ) return renderCaptured ( complete , config . maxOutputChars )
2026-07-29 15:21:56 +08:00
}
2026-07-29 14:12:27 +08:00
if ( result . sessionStatus . kind === 'exited' ) {
2026-07-29 15:21:56 +08:00
const snapshot = retainedScrollback ( ctx , owner , id , latest )
2026-07-29 14:12:27 +08:00
await shells . reset ( owner , 'persistent bash shell exited' )
return [
2026-07-30 00:15:39 +08:00
renderShellExitStatus (
2026-07-29 21:24:33 +08:00
renderCaptured ( partialOutput ( snapshot , marker , fallback , fallbackTruncated ) , config . maxOutputChars ) ,
result . sessionStatus . exitCode ,
result . sessionStatus . signal ,
) ,
2026-07-29 14:12:27 +08:00
SHELL_RESET_MESSAGE ,
] . filter ( part = > part . length > 0 ) . join ( '\n' )
}
if ( promptCompleted ( result ) ) {
2026-07-29 15:21:56 +08:00
const snapshot = retainedScrollback ( ctx , owner , id , latest )
return renderCaptured (
partialOutput ( snapshot , marker , fallback , fallbackTruncated ) ,
config . maxOutputChars ,
)
2026-07-29 14:12:27 +08:00
}
await pause ( )
}
}
/**
* Register the model-facing persistent `bash` tool.
* @param ctx - plugin context carrying tools and the owner-scoped PTY service.
* @param config - selected PTY backend and command deadline.
*/
function registerPersistentBash ( ctx : Context , config : ResolvedConfig ) : void {
const shells = persistentShells ( ctx , config )
const queues = new WeakMap < Agent , Promise < void > > ( )
const serialized = async < T > ( owner : Agent , operation : ( ) = > Promise < T > ) : Promise < T > = > {
const prior = queues . get ( owner ) ? ? Promise . resolve ( )
const run = prior . then ( operation , operation )
const tail = run . then ( ( ) = > undefined , ( ) = > undefined )
queues . set ( owner , tail )
try {
return await run
} finally {
if ( queues . get ( owner ) === tail ) queues . delete ( owner )
}
}
ctx . tools . register ( defineTool ( {
name : 'bash' ,
description : config.description ,
parameters : {
command : {
type : 'string' ,
required : true ,
description : 'The bash command to run. Relative path is preferred in the command.' ,
} ,
} ,
output : {
schema : { type : 'string' } ,
render : ( _args , value ) = > [ { type : 'text' , text : value } ] ,
} ,
async execute ( args , exec ) {
if ( args . command . trim ( ) . length === 0 ) throw new Error ( 'command must be a non-empty string' )
const owner = exec . agent
if ( owner === undefined ) throw new Error ( 'bash requires an owning agent session' )
return serialized ( owner , async ( ) = > {
exec . signal . throwIfAborted ( )
return executeCommand ( ctx , shells , owner , args . command , config , exec . signal )
} )
} ,
presentCall : args = > ( { card : 'terminal' , title : args.command } ) ,
} ) )
}
export const name = 'tool-bash-persistent'
export const inject = [ 'tools' , 'pty' ]
/** Configuration for the persistent Bash tool. */
export interface Config {
/** PTY backend used for each owner-isolated persistent shell (default `shell`). */
backendType? : string
/** Wall-clock limit for one command (default 300000). */
timeoutMs? : number
/** Maximum returned command-output characters before clipping (default 16000). */
maxOutputChars? : number
/** Model-facing tool description; deployments may describe their environment. */
description? : string
}
/** Runtime configuration schema for the persistent Bash tool. */
export const Config : z < Config > = z . object ( {
backendType : z.string ( ) . default ( 'shell' ) ,
timeoutMs : z.number ( ) . default ( 300 _000 ) ,
maxOutputChars : z.number ( ) . default ( 16 _000 ) ,
description : z.string ( ) . default ( DEFAULT_DESCRIPTION ) ,
} )
/** Register one owner-scoped persistent `bash` tool. */
export function apply ( ctx : Context , config : Config ) : void {
const resolved : ResolvedConfig = {
backendType : config.backendType ? ? 'shell' ,
timeoutMs : config.timeoutMs ? ? 300 _000 ,
maxOutputChars : config.maxOutputChars ? ? 16 _000 ,
description : config.description ? ? DEFAULT_DESCRIPTION ,
}
if ( resolved . backendType . trim ( ) . length === 0 ) {
throw new Error ( 'tool-bash-persistent: backendType must be non-empty' )
}
if ( ! Number . isSafeInteger ( resolved . timeoutMs ) || resolved . timeoutMs <= 0 ) {
throw new Error ( 'tool-bash-persistent: timeoutMs must be a positive safe integer' )
}
if ( ! Number . isSafeInteger ( resolved . maxOutputChars ) || resolved . maxOutputChars <= 0 ) {
throw new Error ( 'tool-bash-persistent: maxOutputChars must be a positive safe integer' )
}
if ( resolved . description . trim ( ) . length === 0 ) {
throw new Error ( 'tool-bash-persistent: description must be non-empty' )
}
registerPersistentBash ( ctx , resolved )
}