2025-12-25 20:20:38 +00:00
import { type ChildProcessWithoutNullStreams , spawn } from "node:child_process" ;
import { randomUUID } from "node:crypto" ;
2026-01-03 03:05:43 +00:00
import { existsSync , statSync } from "node:fs" ;
2026-01-03 21:35:44 +01:00
import fs from "node:fs/promises" ;
2026-01-03 03:05:43 +00:00
import { homedir } from "node:os" ;
2026-01-03 22:36:07 +00:00
import path from "node:path" ;
2026-01-02 23:37:08 +01:00
import type { AgentTool , AgentToolResult } from "@mariozechner/pi-agent-core" ;
2025-12-25 00:25:11 +00:00
import { Type } from "@sinclair/typebox" ;
2026-01-04 06:27:54 +01:00
import { logInfo } from "../logger.js" ;
2026-01-09 14:19:25 +01:00
import { sliceUtf16Safe } from "../utils.js" ;
2025-12-25 00:25:11 +00:00
import {
addSession ,
appendOutput ,
deleteSession ,
drainSession ,
getFinishedSession ,
getSession ,
listFinishedSessions ,
listRunningSessions ,
markBackgrounded ,
markExited ,
2025-12-25 17:58:19 +00:00
setJobTtlMs ,
2025-12-25 00:25:11 +00:00
} from "./bash-process-registry.js" ;
2026-01-03 21:35:44 +01:00
import { assertSandboxPath } from "./sandbox-paths.js" ;
2025-12-25 20:20:38 +00:00
import {
getShellConfig ,
killProcessTree ,
sanitizeBinaryOutput ,
} from "./shell-utils.js" ;
2025-12-25 00:25:11 +00:00
const CHUNK_LIMIT = 8 * 1024 ;
const DEFAULT_MAX_OUTPUT = clampNumber (
readEnvInt ( "PI_BASH_MAX_OUTPUT_CHARS" ) ,
30 _000 ,
1 _000 ,
150 _000 ,
) ;
2026-01-04 00:06:02 +01:00
const DEFAULT_PATH =
2026-01-06 04:05:21 +01:00
process . env . PATH ? ?
2026-01-04 00:06:02 +01:00
"/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" ;
2025-12-25 00:25:11 +00:00
2026-01-12 02:49:55 +00:00
export type ExecToolDefaults = {
2025-12-25 17:58:19 +00:00
backgroundMs? : number ;
timeoutSec? : number ;
2026-01-03 21:35:44 +01:00
sandbox? : BashSandboxConfig ;
2026-01-12 02:49:55 +00:00
elevated? : ExecElevatedDefaults ;
2026-01-07 23:18:21 +01:00
allowBackground? : boolean ;
2026-01-07 23:35:04 +01:00
scopeKey? : string ;
2026-01-10 05:36:09 +00:00
cwd? : string ;
2025-12-25 17:58:19 +00:00
} ;
export type ProcessToolDefaults = {
cleanupMs? : number ;
2026-01-07 23:35:04 +01:00
scopeKey? : string ;
2025-12-25 17:58:19 +00:00
} ;
2026-01-03 21:35:44 +01:00
export type BashSandboxConfig = {
containerName : string ;
workspaceDir : string ;
containerWorkdir : string ;
env? : Record < string , string > ;
} ;
2026-01-12 02:49:55 +00:00
export type ExecElevatedDefaults = {
2026-01-04 05:15:42 +00:00
enabled : boolean ;
allowed : boolean ;
defaultLevel : "on" | "off" ;
} ;
2026-01-12 02:49:55 +00:00
const execSchema = Type . Object ( {
command : Type.String ( { description : "Shell command to execute" } ) ,
2025-12-25 00:25:11 +00:00
workdir : Type.Optional (
Type . String ( { description : "Working directory (defaults to cwd)" } ) ,
) ,
env : Type.Optional ( Type . Record ( Type . String ( ) , Type . String ( ) ) ) ,
yieldMs : Type.Optional (
Type . Number ( {
2026-01-03 20:15:02 +00:00
description : "Milliseconds to wait before backgrounding (default 10000)" ,
2025-12-25 00:25:11 +00:00
} ) ,
) ,
background : Type.Optional (
Type . Boolean ( { description : "Run in background immediately" } ) ,
) ,
timeout : Type.Optional (
Type . Number ( {
description : "Timeout in seconds (optional, kills process on expiry)" ,
} ) ,
) ,
2026-01-04 05:15:42 +00:00
elevated : Type.Optional (
Type . Boolean ( {
description : "Run on the host with elevated permissions (if allowed)" ,
} ) ,
) ,
2025-12-25 00:25:11 +00:00
} ) ;
2026-01-12 02:49:55 +00:00
export type ExecToolDetails =
2025-12-25 00:25:11 +00:00
| {
status : "running" ;
sessionId : string ;
pid? : number ;
startedAt : number ;
2026-01-10 18:02:21 +01:00
cwd? : string ;
2025-12-25 00:25:11 +00:00
tail? : string ;
}
| {
status : "completed" | "failed" ;
exitCode : number | null ;
durationMs : number ;
aggregated : string ;
2026-01-10 18:02:21 +01:00
cwd? : string ;
2025-12-25 00:25:11 +00:00
} ;
2026-01-12 02:49:55 +00:00
export function createExecTool (
defaults? : ExecToolDefaults ,
2026-01-02 23:37:08 +01:00
// biome-ignore lint/suspicious/noExplicitAny: TypeBox schema type from pi-agent-core uses a different module instance.
2026-01-12 02:49:55 +00:00
) : AgentTool < any , ExecToolDetails > {
2025-12-25 17:58:19 +00:00
const defaultBackgroundMs = clampNumber (
defaults ? . backgroundMs ? ? readEnvInt ( "PI_BASH_YIELD_MS" ) ,
2026-01-03 20:15:02 +00:00
10 _000 ,
2025-12-25 17:58:19 +00:00
10 ,
120 _000 ,
) ;
2026-01-07 23:18:21 +01:00
const allowBackground = defaults ? . allowBackground ? ? true ;
2025-12-25 17:58:19 +00:00
const defaultTimeoutSec =
typeof defaults ? . timeoutSec === "number" && defaults . timeoutSec > 0
? defaults . timeoutSec
: 1800 ;
return {
2026-01-12 02:49:55 +00:00
name : "exec" ,
label : "exec" ,
2025-12-25 17:58:19 +00:00
description :
2026-01-12 02:49:55 +00:00
"Execute shell commands with background continuation. Use yieldMs/background to continue later via process tool. For real TTY mode, use the tmux skill." ,
parameters : execSchema ,
2025-12-25 20:20:38 +00:00
execute : async ( _toolCallId , args , signal , onUpdate ) = > {
2025-12-25 17:58:19 +00:00
const params = args as {
command : string ;
workdir? : string ;
env? : Record < string , string > ;
yieldMs? : number ;
background? : boolean ;
timeout? : number ;
2026-01-04 05:15:42 +00:00
elevated? : boolean ;
2025-12-25 17:58:19 +00:00
} ;
2025-12-25 00:25:11 +00:00
2025-12-25 17:58:19 +00:00
if ( ! params . command ) {
throw new Error ( "Provide a command to start." ) ;
}
2025-12-25 00:25:11 +00:00
2026-01-07 23:26:26 +01:00
const maxOutput = DEFAULT_MAX_OUTPUT ;
const startedAt = Date . now ( ) ;
const sessionId = randomUUID ( ) ;
const warnings : string [ ] = [ ] ;
2026-01-07 23:18:21 +01:00
const backgroundRequested = params . background === true ;
const yieldRequested = typeof params . yieldMs === "number" ;
if ( ! allowBackground && ( backgroundRequested || yieldRequested ) ) {
warnings . push (
"Warning: background execution is disabled; running synchronously." ,
) ;
}
const yieldWindow = allowBackground
? backgroundRequested
? 0
: clampNumber (
params . yieldMs ? ? defaultBackgroundMs ,
defaultBackgroundMs ,
10 ,
120 _000 ,
)
: null ;
2026-01-04 05:15:42 +00:00
const elevatedDefaults = defaults ? . elevated ;
2026-01-07 09:32:49 +00:00
const elevatedDefaultOn =
elevatedDefaults ? . defaultLevel === "on" &&
elevatedDefaults . enabled &&
elevatedDefaults . allowed ;
2026-01-04 05:15:42 +00:00
const elevatedRequested =
typeof params . elevated === "boolean"
? params . elevated
2026-01-07 09:32:49 +00:00
: elevatedDefaultOn ;
2026-01-04 05:15:42 +00:00
if ( elevatedRequested ) {
if ( ! elevatedDefaults ? . enabled || ! elevatedDefaults . allowed ) {
2026-01-10 20:28:34 +01:00
const runtime = defaults ? . sandbox ? "sandboxed" : "direct" ;
const gates : string [ ] = [ ] ;
if ( ! elevatedDefaults ? . enabled ) {
2026-01-10 19:47:17 +00:00
gates . push (
"enabled (tools.elevated.enabled / agents.list[].tools.elevated.enabled)" ,
) ;
2026-01-10 20:28:34 +01:00
} else {
2026-01-10 19:47:17 +00:00
gates . push (
"allowFrom (tools.elevated.allowFrom.<provider> / agents.list[].tools.elevated.allowFrom.<provider>)" ,
) ;
2026-01-10 20:28:34 +01:00
}
throw new Error (
[
` elevated is not available right now (runtime= ${ runtime } ). ` ,
` Failing gates: ${ gates . join ( ", " ) } ` ,
"Fix-it keys:" ,
"- tools.elevated.enabled" ,
"- tools.elevated.allowFrom.<provider>" ,
"- agents.list[].tools.elevated.enabled" ,
"- agents.list[].tools.elevated.allowFrom.<provider>" ,
] . join ( "\n" ) ,
) ;
2026-01-04 05:15:42 +00:00
}
logInfo (
2026-01-12 02:49:55 +00:00
` exec: elevated command ( ${ sessionId . slice ( 0 , 8 ) } ) ${ truncateMiddle (
2026-01-04 05:15:42 +00:00
params . command ,
120 ,
) } ` ,
) ;
}
const sandbox = elevatedRequested ? undefined : defaults ? . sandbox ;
2026-01-10 05:36:09 +00:00
const rawWorkdir =
params . workdir ? . trim ( ) || defaults ? . cwd || process . cwd ( ) ;
2026-01-03 21:35:44 +01:00
let workdir = rawWorkdir ;
let containerWorkdir = sandbox ? . containerWorkdir ;
if ( sandbox ) {
const resolved = await resolveSandboxWorkdir ( {
workdir : rawWorkdir ,
sandbox ,
warnings ,
} ) ;
workdir = resolved . hostWorkdir ;
containerWorkdir = resolved . containerWorkdir ;
} else {
workdir = resolveWorkdir ( rawWorkdir , warnings ) ;
}
2025-12-25 17:58:19 +00:00
const { shell , args : shellArgs } = getShellConfig ( ) ;
2026-01-03 21:35:44 +01:00
const baseEnv = coerceEnv ( process . env ) ;
const mergedEnv = params . env ? { . . . baseEnv , . . . params . env } : baseEnv ;
const env = sandbox
? buildSandboxEnv ( {
paramsEnv : params.env ,
sandboxEnv : sandbox.env ,
containerWorkdir : containerWorkdir ? ? sandbox . containerWorkdir ,
} )
: mergedEnv ;
const child = sandbox
? spawn (
"docker" ,
buildDockerExecArgs ( {
containerName : sandbox.containerName ,
command : params.command ,
workdir : containerWorkdir ? ? sandbox . containerWorkdir ,
env ,
tty : false ,
} ) ,
{
cwd : workdir ,
env : process.env ,
2026-01-11 16:35:21 -08:00
detached : process.platform !== "win32" ,
2026-01-03 21:35:44 +01:00
stdio : [ "pipe" , "pipe" , "pipe" ] ,
2026-01-11 16:35:21 -08:00
windowsHide : true ,
2026-01-03 21:35:44 +01:00
} ,
)
: spawn ( shell , [ . . . shellArgs , params . command ] , {
cwd : workdir ,
env ,
2026-01-11 16:35:21 -08:00
detached : process.platform !== "win32" ,
2026-01-03 21:35:44 +01:00
stdio : [ "pipe" , "pipe" , "pipe" ] ,
2026-01-11 16:35:21 -08:00
windowsHide : true ,
2026-01-03 21:35:44 +01:00
} ) ;
2026-01-03 03:05:43 +00:00
2025-12-25 17:58:19 +00:00
const session = {
id : sessionId ,
command : params.command ,
2026-01-07 23:35:04 +01:00
scopeKey : defaults?.scopeKey ,
2025-12-25 17:58:19 +00:00
child ,
2026-01-03 20:15:02 +00:00
pid : child?.pid ,
2025-12-25 17:58:19 +00:00
startedAt ,
cwd : workdir ,
maxOutputChars : maxOutput ,
totalOutputChars : 0 ,
pendingStdout : [ ] ,
pendingStderr : [ ] ,
aggregated : "" ,
tail : "" ,
exited : false ,
exitCode : undefined as number | null | undefined ,
exitSignal : undefined as NodeJS . Signals | number | null | undefined ,
truncated : false ,
backgrounded : false ,
} ;
addSession ( session ) ;
2025-12-25 00:25:11 +00:00
2025-12-25 17:58:19 +00:00
let settled = false ;
let yielded = false ;
let yieldTimer : NodeJS.Timeout | null = null ;
let timeoutTimer : NodeJS.Timeout | null = null ;
let timedOut = false ;
2025-12-25 00:25:11 +00:00
2025-12-25 17:58:19 +00:00
const settle = ( fn : ( ) = > void ) = > {
if ( settled ) return ;
settled = true ;
fn ( ) ;
2025-12-25 00:25:11 +00:00
} ;
2025-12-25 17:58:19 +00:00
const onAbort = ( ) = > {
2026-01-03 01:33:04 +00:00
killSession ( session ) ;
2025-12-25 00:25:11 +00:00
} ;
2025-12-25 17:58:19 +00:00
if ( signal ? . aborted ) onAbort ( ) ;
2025-12-25 20:20:38 +00:00
else if ( signal ) {
signal . addEventListener ( "abort" , onAbort , { once : true } ) ;
}
2025-12-25 17:58:19 +00:00
const effectiveTimeout =
typeof params . timeout === "number" ? params.timeout : defaultTimeoutSec ;
if ( effectiveTimeout > 0 ) {
timeoutTimer = setTimeout ( ( ) = > {
timedOut = true ;
onAbort ( ) ;
} , effectiveTimeout * 1000 ) ;
2025-12-25 00:25:11 +00:00
}
2025-12-25 17:58:19 +00:00
const emitUpdate = ( ) = > {
if ( ! onUpdate ) return ;
const tailText = session . tail || session . aggregated ;
2026-01-03 03:05:43 +00:00
const warningText = warnings . length ? ` ${ warnings . join ( "\n" ) } \ n \ n ` : "" ;
2025-12-25 17:58:19 +00:00
onUpdate ( {
2026-01-03 01:33:04 +00:00
content : [ { type : "text" , text : warningText + ( tailText || "" ) } ] ,
2025-12-25 17:58:19 +00:00
details : {
status : "running" ,
sessionId ,
2026-01-03 01:33:04 +00:00
pid : session.pid ? ? undefined ,
2025-12-25 17:58:19 +00:00
startedAt ,
2026-01-10 18:02:21 +01:00
cwd : session.cwd ,
2025-12-25 17:58:19 +00:00
tail : session.tail ,
} ,
} ) ;
} ;
2025-12-25 00:25:11 +00:00
2026-01-03 20:15:02 +00:00
child . stdout . on ( "data" , ( data ) = > {
const str = sanitizeBinaryOutput ( data . toString ( ) ) ;
for ( const chunk of chunkString ( str ) ) {
appendOutput ( session , "stdout" , chunk ) ;
emitUpdate ( ) ;
}
} ) ;
2026-01-03 01:33:04 +00:00
2026-01-03 20:15:02 +00:00
child . stderr . on ( "data" , ( data ) = > {
const str = sanitizeBinaryOutput ( data . toString ( ) ) ;
for ( const chunk of chunkString ( str ) ) {
appendOutput ( session , "stderr" , chunk ) ;
emitUpdate ( ) ;
}
} ) ;
2025-12-25 17:58:19 +00:00
2026-01-12 02:49:55 +00:00
return new Promise < AgentToolResult < ExecToolDetails > > (
2025-12-25 17:58:19 +00:00
( resolve , reject ) = > {
const resolveRunning = ( ) = > {
settle ( ( ) = >
resolve ( {
content : [
{
type : "text" ,
text :
2026-01-03 03:05:43 +00:00
` ${ warnings . length ? ` ${ warnings . join ( "\n" ) } \ n \ n ` : "" } ` +
2026-01-03 01:33:04 +00:00
` Command still running (session ${ sessionId } , pid ${ session . pid ? ? "n/a" } ). ` +
2025-12-25 17:58:19 +00:00
"Use process (list/poll/log/write/kill/clear/remove) for follow-up." ,
} ,
] ,
details : {
status : "running" ,
sessionId ,
2026-01-03 01:33:04 +00:00
pid : session.pid ? ? undefined ,
2025-12-25 17:58:19 +00:00
startedAt ,
2026-01-10 18:02:21 +01:00
cwd : session.cwd ,
2025-12-25 17:58:19 +00:00
tail : session.tail ,
} ,
} ) ,
) ;
} ;
const onYieldNow = ( ) = > {
if ( yieldTimer ) clearTimeout ( yieldTimer ) ;
if ( settled ) return ;
yielded = true ;
markBackgrounded ( session ) ;
resolveRunning ( ) ;
} ;
2026-01-07 23:18:21 +01:00
if ( allowBackground && yieldWindow !== null ) {
if ( yieldWindow === 0 ) {
onYieldNow ( ) ;
} else {
yieldTimer = setTimeout ( ( ) = > {
if ( settled ) return ;
yielded = true ;
markBackgrounded ( session ) ;
resolveRunning ( ) ;
} , yieldWindow ) ;
}
2025-12-25 17:58:19 +00:00
}
2026-01-03 01:33:04 +00:00
const handleExit = (
code : number | null ,
exitSignal : NodeJS.Signals | number | null ,
) = > {
2025-12-25 17:58:19 +00:00
if ( yieldTimer ) clearTimeout ( yieldTimer ) ;
if ( timeoutTimer ) clearTimeout ( timeoutTimer ) ;
const durationMs = Date . now ( ) - startedAt ;
const wasSignal = exitSignal != null ;
const isSuccess =
code === 0 && ! wasSignal && ! signal ? . aborted && ! timedOut ;
const status : "completed" | "failed" = isSuccess
? "completed"
: "failed" ;
markExited ( session , code , exitSignal , status ) ;
if ( yielded || session . backgrounded ) return ;
const aggregated = session . aggregated . trim ( ) ;
if ( ! isSuccess ) {
const reason = timedOut
? ` Command timed out after ${ effectiveTimeout } seconds `
: wasSignal && exitSignal
? ` Command aborted by signal ${ exitSignal } `
: code === null
? "Command aborted before exit code was captured"
: ` Command exited with code ${ code } ` ;
2025-12-25 20:20:38 +00:00
const message = aggregated
? ` ${ aggregated } \ n \ n ${ reason } `
: reason ;
2025-12-25 17:58:19 +00:00
settle ( ( ) = > reject ( new Error ( message ) ) ) ;
return ;
}
settle ( ( ) = >
resolve ( {
2026-01-03 01:33:04 +00:00
content : [
{
type : "text" ,
text :
2026-01-03 03:05:43 +00:00
` ${ warnings . length ? ` ${ warnings . join ( "\n" ) } \ n \ n ` : "" } ` +
2026-01-03 01:33:04 +00:00
( aggregated || "(no output)" ) ,
} ,
] ,
2025-12-25 17:58:19 +00:00
details : {
status : "completed" ,
exitCode : code ? ? 0 ,
durationMs ,
aggregated ,
2026-01-10 18:02:21 +01:00
cwd : session.cwd ,
2025-12-25 17:58:19 +00:00
} ,
} ) ,
) ;
2026-01-03 01:33:04 +00:00
} ;
2025-12-25 17:58:19 +00:00
2026-01-10 17:47:04 +01:00
// `exit` can fire before stdio fully flushes (notably on Windows).
// `close` waits for streams to close, so aggregated output is complete.
child . once ( "close" , ( code , exitSignal ) = > {
2026-01-03 20:15:02 +00:00
handleExit ( code , exitSignal ) ;
} ) ;
2026-01-03 01:33:04 +00:00
2026-01-03 20:15:02 +00:00
child . once ( "error" , ( err ) = > {
if ( yieldTimer ) clearTimeout ( yieldTimer ) ;
if ( timeoutTimer ) clearTimeout ( timeoutTimer ) ;
markExited ( session , null , null , "failed" ) ;
settle ( ( ) = > reject ( err ) ) ;
} ) ;
2025-12-25 17:58:19 +00:00
} ,
) ;
} ,
} ;
}
2026-01-12 02:49:55 +00:00
export const execTool = createExecTool ( ) ;
2025-12-25 00:25:11 +00:00
const processSchema = Type . Object ( {
2026-01-10 04:01:00 +01:00
action : Type.String ( { description : "Process action" } ) ,
2025-12-25 00:25:11 +00:00
sessionId : Type.Optional (
Type . String ( { description : "Session id for actions other than list" } ) ,
) ,
data : Type.Optional ( Type . String ( { description : "Data to write for write" } ) ) ,
eof : Type.Optional ( Type . Boolean ( { description : "Close stdin after write" } ) ) ,
offset : Type.Optional ( Type . Number ( { description : "Log offset" } ) ) ,
limit : Type.Optional ( Type . Number ( { description : "Log length" } ) ) ,
} ) ;
2025-12-25 17:58:19 +00:00
export function createProcessTool (
defaults? : ProcessToolDefaults ,
2026-01-02 23:37:08 +01:00
// biome-ignore lint/suspicious/noExplicitAny: TypeBox schema type from pi-agent-core uses a different module instance.
2026-01-01 17:30:19 +01:00
) : AgentTool < any > {
2025-12-25 17:58:19 +00:00
if ( defaults ? . cleanupMs !== undefined ) {
setJobTtlMs ( defaults . cleanupMs ) ;
}
2026-01-07 23:35:04 +01:00
const scopeKey = defaults ? . scopeKey ;
const isInScope = ( session ? : { scopeKey? : string } | null ) = >
! scopeKey || session ? . scopeKey === scopeKey ;
2025-12-25 00:25:11 +00:00
2025-12-25 17:58:19 +00:00
return {
name : "process" ,
label : "process" ,
2026-01-12 02:49:55 +00:00
description : "Manage running exec sessions: list, poll, log, write, kill." ,
2025-12-25 17:58:19 +00:00
parameters : processSchema ,
execute : async ( _toolCallId , args ) = > {
const params = args as {
action : "list" | "poll" | "log" | "write" | "kill" | "clear" | "remove" ;
sessionId? : string ;
data? : string ;
eof? : boolean ;
offset? : number ;
limit? : number ;
2025-12-25 00:25:11 +00:00
} ;
2025-12-25 17:58:19 +00:00
if ( params . action === "list" ) {
2026-01-07 23:35:04 +01:00
const running = listRunningSessions ( )
. filter ( ( s ) = > isInScope ( s ) )
. map ( ( s ) = > ( {
sessionId : s.id ,
status : "running" ,
pid : s.pid ? ? undefined ,
startedAt : s.startedAt ,
runtimeMs : Date.now ( ) - s . startedAt ,
cwd : s.cwd ,
command : s.command ,
name : deriveSessionName ( s . command ) ,
tail : s.tail ,
truncated : s.truncated ,
} ) ) ;
const finished = listFinishedSessions ( )
. filter ( ( s ) = > isInScope ( s ) )
. map ( ( s ) = > ( {
sessionId : s.id ,
status : s.status ,
startedAt : s.startedAt ,
endedAt : s.endedAt ,
runtimeMs : s.endedAt - s . startedAt ,
cwd : s.cwd ,
command : s.command ,
name : deriveSessionName ( s . command ) ,
tail : s.tail ,
truncated : s.truncated ,
exitCode : s.exitCode ? ? undefined ,
exitSignal : s.exitSignal ? ? undefined ,
} ) ) ;
2025-12-25 17:58:19 +00:00
const lines = [ . . . running , . . . finished ]
. sort ( ( a , b ) = > b . startedAt - a . startedAt )
. map ( ( s ) = > {
const label = s . name
? truncateMiddle ( s . name , 80 )
: truncateMiddle ( s . command , 120 ) ;
return ` ${ s . sessionId . slice ( 0 , 8 ) } ${ pad (
s . status ,
9 ,
) } $ { formatDuration ( s . runtimeMs ) } : : $ { label } ` ;
} ) ;
return {
content : [
{
type : "text" ,
text : lines.join ( "\n" ) || "No running or recent sessions." ,
} ,
] ,
details : { status : "completed" , sessions : [ . . . running , . . . finished ] } ,
} ;
}
2025-12-25 00:25:11 +00:00
2025-12-25 17:58:19 +00:00
if ( ! params . sessionId ) {
return {
content : [
{ type : "text" , text : "sessionId is required for this action." } ,
] ,
details : { status : "failed" } ,
} ;
}
2025-12-25 00:25:11 +00:00
2025-12-25 17:58:19 +00:00
const session = getSession ( params . sessionId ) ;
const finished = getFinishedSession ( params . sessionId ) ;
2026-01-07 23:35:04 +01:00
const scopedSession = isInScope ( session ) ? session : undefined ;
const scopedFinished = isInScope ( finished ) ? finished : undefined ;
2025-12-25 17:58:19 +00:00
switch ( params . action ) {
case "poll" : {
2026-01-07 23:35:04 +01:00
if ( ! scopedSession ) {
if ( scopedFinished ) {
2025-12-25 17:58:19 +00:00
return {
content : [
{
type : "text" ,
text :
2026-01-07 23:35:04 +01:00
( scopedFinished . tail ||
2025-12-25 17:58:19 +00:00
` (no output recorded ${
2026-01-07 23:35:04 +01:00
scopedFinished . truncated ? " — truncated to cap" : ""
2025-12-25 17:58:19 +00:00
} ) ` ) +
` \ n \ nProcess exited with ${
2026-01-07 23:35:04 +01:00
scopedFinished . exitSignal
? ` signal ${ scopedFinished . exitSignal } `
: ` code ${ scopedFinished . exitCode ? ? 0 } `
2025-12-25 17:58:19 +00:00
} . ` ,
} ,
] ,
details : {
status :
2026-01-07 23:35:04 +01:00
scopedFinished . status === "completed"
? "completed"
: "failed" ,
2025-12-25 17:58:19 +00:00
sessionId : params.sessionId ,
2026-01-07 23:35:04 +01:00
exitCode : scopedFinished.exitCode ? ? undefined ,
aggregated : scopedFinished.aggregated ,
name : deriveSessionName ( scopedFinished . command ) ,
2025-12-25 17:58:19 +00:00
} ,
} ;
}
2025-12-25 00:25:11 +00:00
return {
content : [
{
type : "text" ,
2025-12-25 17:58:19 +00:00
text : ` No session found for ${ params . sessionId } ` ,
2025-12-25 00:25:11 +00:00
} ,
] ,
2025-12-25 17:58:19 +00:00
details : { status : "failed" } ,
2025-12-25 00:25:11 +00:00
} ;
}
2026-01-07 23:35:04 +01:00
if ( ! scopedSession . backgrounded ) {
2025-12-25 17:58:19 +00:00
return {
content : [
{
type : "text" ,
text : ` Session ${ params . sessionId } is not backgrounded. ` ,
} ,
] ,
details : { status : "failed" } ,
} ;
}
2026-01-07 23:35:04 +01:00
const { stdout , stderr } = drainSession ( scopedSession ) ;
const exited = scopedSession . exited ;
const exitCode = scopedSession . exitCode ? ? 0 ;
const exitSignal = scopedSession . exitSignal ? ? undefined ;
2025-12-25 17:58:19 +00:00
if ( exited ) {
const status =
exitCode === 0 && exitSignal == null ? "completed" : "failed" ;
markExited (
2026-01-07 23:35:04 +01:00
scopedSession ,
scopedSession . exitCode ? ? null ,
scopedSession . exitSignal ? ? null ,
2025-12-25 17:58:19 +00:00
status ,
) ;
}
const status = exited
? exitCode === 0 && exitSignal == null
? "completed"
: "failed"
: "running" ;
const output = [ stdout . trimEnd ( ) , stderr . trimEnd ( ) ]
. filter ( Boolean )
. join ( "\n" )
. trim ( ) ;
2025-12-25 00:25:11 +00:00
return {
content : [
2025-12-25 03:29:36 +01:00
{
type : "text" ,
2025-12-25 17:58:19 +00:00
text :
( output || "(no new output)" ) +
( exited
? ` \ n \ nProcess exited with ${
exitSignal ? ` signal ${ exitSignal } ` : ` code ${ exitCode } `
} . `
: "\n\nProcess still running." ) ,
2025-12-25 00:25:11 +00:00
} ,
] ,
2025-12-25 17:58:19 +00:00
details : {
status ,
sessionId : params.sessionId ,
exitCode : exited ? exitCode : undefined ,
2026-01-07 23:35:04 +01:00
aggregated : scopedSession.aggregated ,
name : deriveSessionName ( scopedSession . command ) ,
2025-12-25 17:58:19 +00:00
} ,
2025-12-25 00:25:11 +00:00
} ;
}
2025-12-25 20:20:38 +00:00
case "log" : {
2026-01-07 23:35:04 +01:00
if ( scopedSession ) {
if ( ! scopedSession . backgrounded ) {
2025-12-25 20:20:38 +00:00
return {
content : [
{
type : "text" ,
text : ` Session ${ params . sessionId } is not backgrounded. ` ,
} ,
] ,
details : { status : "failed" } ,
} ;
}
const { slice , totalLines , totalChars } = sliceLogLines (
2026-01-07 23:35:04 +01:00
scopedSession . aggregated ,
2025-12-25 20:20:38 +00:00
params . offset ,
params . limit ,
) ;
return {
content : [ { type : "text" , text : slice || "(no output yet)" } ] ,
details : {
2026-01-07 23:35:04 +01:00
status : scopedSession.exited ? "completed" : "running" ,
2025-12-25 20:20:38 +00:00
sessionId : params.sessionId ,
total : totalLines ,
totalLines ,
totalChars ,
2026-01-07 23:35:04 +01:00
truncated : scopedSession.truncated ,
name : deriveSessionName ( scopedSession . command ) ,
2025-12-25 20:20:38 +00:00
} ,
} ;
}
2026-01-07 23:35:04 +01:00
if ( scopedFinished ) {
2025-12-25 20:20:38 +00:00
const { slice , totalLines , totalChars } = sliceLogLines (
2026-01-07 23:35:04 +01:00
scopedFinished . aggregated ,
2025-12-25 20:20:38 +00:00
params . offset ,
params . limit ,
) ;
const status =
2026-01-07 23:35:04 +01:00
scopedFinished . status === "completed" ? "completed" : "failed" ;
2025-12-25 00:25:11 +00:00
return {
content : [
2025-12-25 20:20:38 +00:00
{ type : "text" , text : slice || "(no output recorded)" } ,
2025-12-25 00:25:11 +00:00
] ,
2025-12-25 20:20:38 +00:00
details : {
status ,
sessionId : params.sessionId ,
total : totalLines ,
totalLines ,
totalChars ,
2026-01-07 23:35:04 +01:00
truncated : scopedFinished.truncated ,
exitCode : scopedFinished.exitCode ? ? undefined ,
exitSignal : scopedFinished.exitSignal ? ? undefined ,
name : deriveSessionName ( scopedFinished . command ) ,
2025-12-25 20:20:38 +00:00
} ,
2025-12-25 00:25:11 +00:00
} ;
}
return {
content : [
{
type : "text" ,
2025-12-25 20:20:38 +00:00
text : ` No session found for ${ params . sessionId } ` ,
2025-12-25 00:25:11 +00:00
} ,
] ,
details : { status : "failed" } ,
} ;
}
2025-12-25 20:20:38 +00:00
case "write" : {
2026-01-07 23:35:04 +01:00
if ( ! scopedSession ) {
2025-12-25 20:20:38 +00:00
return {
content : [
{
type : "text" ,
text : ` No active session found for ${ params . sessionId } ` ,
} ,
] ,
details : { status : "failed" } ,
} ;
}
2026-01-07 23:35:04 +01:00
if ( ! scopedSession . backgrounded ) {
2025-12-25 20:20:38 +00:00
return {
content : [
{
type : "text" ,
text : ` Session ${ params . sessionId } is not backgrounded. ` ,
} ,
] ,
details : { status : "failed" } ,
} ;
}
2026-01-07 23:35:04 +01:00
if (
! scopedSession . child ? . stdin ||
scopedSession . child . stdin . destroyed
) {
2026-01-03 20:15:02 +00:00
return {
content : [
{
type : "text" ,
text : ` Session ${ params . sessionId } stdin is not writable. ` ,
} ,
] ,
details : { status : "failed" } ,
} ;
}
await new Promise < void > ( ( resolve , reject ) = > {
2026-01-07 23:35:04 +01:00
scopedSession . child ? . stdin . write ( params . data ? ? "" , ( err ) = > {
2026-01-03 20:15:02 +00:00
if ( err ) reject ( err ) ;
else resolve ( ) ;
2025-12-25 20:20:38 +00:00
} ) ;
2026-01-03 20:15:02 +00:00
} ) ;
if ( params . eof ) {
2026-01-07 23:35:04 +01:00
scopedSession . child . stdin . end ( ) ;
2025-12-25 20:20:38 +00:00
}
2025-12-25 00:25:11 +00:00
return {
content : [
{
type : "text" ,
2025-12-25 20:20:38 +00:00
text : ` Wrote ${ ( params . data ? ? "" ) . length } bytes to session ${
params . sessionId
} $ { params . eof ? " (stdin closed)" : "" } . ` ,
2025-12-25 00:25:11 +00:00
} ,
] ,
2025-12-25 20:20:38 +00:00
details : {
status : "running" ,
sessionId : params.sessionId ,
2026-01-07 23:35:04 +01:00
name : scopedSession
? deriveSessionName ( scopedSession . command )
: undefined ,
2025-12-25 00:25:11 +00:00
} ,
} ;
}
2025-12-25 20:20:38 +00:00
case "kill" : {
2026-01-07 23:35:04 +01:00
if ( ! scopedSession ) {
2025-12-25 20:20:38 +00:00
return {
content : [
{
type : "text" ,
text : ` No active session found for ${ params . sessionId } ` ,
} ,
] ,
details : { status : "failed" } ,
} ;
}
2026-01-07 23:35:04 +01:00
if ( ! scopedSession . backgrounded ) {
2025-12-25 20:20:38 +00:00
return {
content : [
{
type : "text" ,
text : ` Session ${ params . sessionId } is not backgrounded. ` ,
} ,
] ,
details : { status : "failed" } ,
} ;
}
2026-01-07 23:35:04 +01:00
killSession ( scopedSession ) ;
markExited ( scopedSession , null , "SIGKILL" , "failed" ) ;
2025-12-25 00:25:11 +00:00
return {
content : [
2025-12-25 20:20:38 +00:00
{ type : "text" , text : ` Killed session ${ params . sessionId } . ` } ,
2025-12-25 00:25:11 +00:00
] ,
2025-12-25 20:20:38 +00:00
details : {
status : "failed" ,
2026-01-07 23:35:04 +01:00
name : scopedSession
? deriveSessionName ( scopedSession . command )
: undefined ,
2025-12-25 20:20:38 +00:00
} ,
2025-12-25 00:25:11 +00:00
} ;
}
2025-12-25 17:58:19 +00:00
case "clear" : {
2026-01-07 23:35:04 +01:00
if ( scopedFinished ) {
2025-12-25 17:58:19 +00:00
deleteSession ( params . sessionId ) ;
return {
content : [
{ type : "text" , text : ` Cleared session ${ params . sessionId } . ` } ,
] ,
details : { status : "completed" } ,
} ;
}
2025-12-25 00:25:11 +00:00
return {
content : [
2025-12-25 17:58:19 +00:00
{
type : "text" ,
text : ` No finished session found for ${ params . sessionId } ` ,
} ,
2025-12-25 00:25:11 +00:00
] ,
2025-12-25 17:58:19 +00:00
details : { status : "failed" } ,
2025-12-25 00:25:11 +00:00
} ;
}
2025-12-25 17:58:19 +00:00
case "remove" : {
2026-01-07 23:35:04 +01:00
if ( scopedSession ) {
killSession ( scopedSession ) ;
markExited ( scopedSession , null , "SIGKILL" , "failed" ) ;
2025-12-25 17:58:19 +00:00
return {
content : [
{ type : "text" , text : ` Removed session ${ params . sessionId } . ` } ,
] ,
details : {
status : "failed" ,
2026-01-07 23:35:04 +01:00
name : scopedSession
? deriveSessionName ( scopedSession . command )
: undefined ,
2025-12-25 17:58:19 +00:00
} ,
} ;
}
2026-01-07 23:35:04 +01:00
if ( scopedFinished ) {
2025-12-25 17:58:19 +00:00
deleteSession ( params . sessionId ) ;
return {
content : [
{ type : "text" , text : ` Removed session ${ params . sessionId } . ` } ,
] ,
details : { status : "completed" } ,
} ;
2025-12-25 00:25:11 +00:00
}
return {
content : [
2025-12-25 20:20:38 +00:00
{
type : "text" ,
text : ` No session found for ${ params . sessionId } ` ,
} ,
2025-12-25 00:25:11 +00:00
] ,
details : { status : "failed" } ,
} ;
}
}
2025-12-25 17:58:19 +00:00
return {
content : [
{ type : "text" , text : ` Unknown action ${ params . action as string } ` } ,
] ,
details : { status : "failed" } ,
} ;
} ,
} ;
}
export const processTool = createProcessTool ( ) ;
2025-12-25 00:25:11 +00:00
2026-01-03 21:35:44 +01:00
function buildSandboxEnv ( params : {
paramsEnv? : Record < string , string > ;
sandboxEnv? : Record < string , string > ;
containerWorkdir : string ;
} ) {
const env : Record < string , string > = {
PATH : DEFAULT_PATH ,
HOME : params.containerWorkdir ,
} ;
for ( const [ key , value ] of Object . entries ( params . sandboxEnv ? ? { } ) ) {
env [ key ] = value ;
}
for ( const [ key , value ] of Object . entries ( params . paramsEnv ? ? { } ) ) {
env [ key ] = value ;
}
return env ;
}
function coerceEnv ( env? : NodeJS.ProcessEnv | Record < string , string > ) {
const record : Record < string , string > = { } ;
if ( ! env ) return record ;
for ( const [ key , value ] of Object . entries ( env ) ) {
if ( typeof value === "string" ) record [ key ] = value ;
}
return record ;
}
function buildDockerExecArgs ( params : {
containerName : string ;
command : string ;
workdir? : string ;
env : Record < string , string > ;
tty : boolean ;
} ) {
const args = [ "exec" , "-i" ] ;
if ( params . tty ) args . push ( "-t" ) ;
if ( params . workdir ) {
args . push ( "-w" , params . workdir ) ;
}
for ( const [ key , value ] of Object . entries ( params . env ) ) {
args . push ( "-e" , ` ${ key } = ${ value } ` ) ;
}
args . push ( params . containerName , "sh" , "-lc" , params . command ) ;
return args ;
}
async function resolveSandboxWorkdir ( params : {
workdir : string ;
sandbox : BashSandboxConfig ;
warnings : string [ ] ;
} ) {
const fallback = params . sandbox . workspaceDir ;
try {
const resolved = await assertSandboxPath ( {
filePath : params.workdir ,
cwd : process.cwd ( ) ,
root : params.sandbox.workspaceDir ,
} ) ;
const stats = await fs . stat ( resolved . resolved ) ;
if ( ! stats . isDirectory ( ) ) {
throw new Error ( "workdir is not a directory" ) ;
}
const relative = resolved . relative
? resolved . relative . split ( path . sep ) . join ( path . posix . sep )
: "" ;
const containerWorkdir = relative
? path . posix . join ( params . sandbox . containerWorkdir , relative )
: params . sandbox . containerWorkdir ;
return { hostWorkdir : resolved.resolved , containerWorkdir } ;
} catch {
params . warnings . push (
` Warning: workdir " ${ params . workdir } " is unavailable; using " ${ fallback } ". ` ,
) ;
return {
hostWorkdir : fallback ,
containerWorkdir : params.sandbox.containerWorkdir ,
} ;
}
}
2026-01-03 01:33:04 +00:00
function killSession ( session : {
pid? : number ;
child? : ChildProcessWithoutNullStreams ;
} ) {
2026-01-03 20:15:02 +00:00
const pid = session . pid ? ? session . child ? . pid ;
2026-01-03 01:33:04 +00:00
if ( pid ) {
killProcessTree ( pid ) ;
}
}
2026-01-03 03:05:43 +00:00
function resolveWorkdir ( workdir : string , warnings : string [ ] ) {
const current = safeCwd ( ) ;
const fallback = current ? ? homedir ( ) ;
try {
const stats = statSync ( workdir ) ;
if ( stats . isDirectory ( ) ) return workdir ;
} catch {
// ignore, fallback below
}
warnings . push (
` Warning: workdir " ${ workdir } " is unavailable; using " ${ fallback } ". ` ,
) ;
return fallback ;
}
function safeCwd() {
try {
const cwd = process . cwd ( ) ;
return existsSync ( cwd ) ? cwd : null ;
} catch {
return null ;
}
}
2026-01-03 01:33:04 +00:00
2025-12-25 00:25:11 +00:00
function clampNumber (
value : number | undefined ,
defaultValue : number ,
min : number ,
max : number ,
) {
if ( value === undefined || Number . isNaN ( value ) ) return defaultValue ;
return Math . min ( Math . max ( value , min ) , max ) ;
}
function readEnvInt ( key : string ) {
const raw = process . env [ key ] ;
if ( ! raw ) return undefined ;
const parsed = Number . parseInt ( raw , 10 ) ;
return Number . isFinite ( parsed ) ? parsed : undefined ;
}
function chunkString ( input : string , limit = CHUNK_LIMIT ) {
const chunks : string [ ] = [ ] ;
for ( let i = 0 ; i < input . length ; i += limit ) {
chunks . push ( input . slice ( i , i + limit ) ) ;
}
return chunks ;
}
function truncateMiddle ( str : string , max : number ) {
if ( str . length <= max ) return str ;
const half = Math . floor ( ( max - 3 ) / 2 ) ;
2026-01-09 14:19:25 +01:00
return ` ${ sliceUtf16Safe ( str , 0 , half ) } ... ${ sliceUtf16Safe ( str , - half ) } ` ;
2025-12-25 00:25:11 +00:00
}
2025-12-25 17:58:19 +00:00
function sliceLogLines (
text : string ,
offset? : number ,
limit? : number ,
) : { slice : string ; totalLines : number ; totalChars : number } {
if ( ! text ) return { slice : "" , totalLines : 0 , totalChars : 0 } ;
const normalized = text . replace ( /\r\n/g , "\n" ) ;
const lines = normalized . split ( "\n" ) ;
if ( lines . length > 0 && lines [ lines . length - 1 ] === "" ) {
lines . pop ( ) ;
}
const totalLines = lines . length ;
const totalChars = text . length ;
let start =
typeof offset === "number" && Number . isFinite ( offset )
? Math . max ( 0 , Math . floor ( offset ) )
: 0 ;
if ( limit !== undefined && offset === undefined ) {
const tailCount = Math . max ( 0 , Math . floor ( limit ) ) ;
start = Math . max ( totalLines - tailCount , 0 ) ;
}
const end =
typeof limit === "number" && Number . isFinite ( limit )
? start + Math . max ( 0 , Math . floor ( limit ) )
: undefined ;
return { slice : lines.slice ( start , end ) . join ( "\n" ) , totalLines , totalChars } ;
}
function deriveSessionName ( command : string ) : string | undefined {
const tokens = tokenizeCommand ( command ) ;
if ( tokens . length === 0 ) return undefined ;
const verb = tokens [ 0 ] ;
let target = tokens . slice ( 1 ) . find ( ( t ) = > ! t . startsWith ( "-" ) ) ;
if ( ! target ) target = tokens [ 1 ] ;
if ( ! target ) return verb ;
const cleaned = truncateMiddle ( stripQuotes ( target ) , 48 ) ;
return ` ${ stripQuotes ( verb ) } ${ cleaned } ` ;
}
function tokenizeCommand ( command : string ) : string [ ] {
const matches =
command . match ( /(?:[^\s"']+|"(?:\\.|[^"])*"|'(?:\\.|[^'])*')+/g ) ? ? [ ] ;
return matches . map ( ( token ) = > stripQuotes ( token ) ) . filter ( Boolean ) ;
}
function stripQuotes ( value : string ) : string {
const trimmed = value . trim ( ) ;
if (
2025-12-25 20:20:38 +00:00
( trimmed . startsWith ( '"' ) && trimmed . endsWith ( '"' ) ) ||
2025-12-25 17:58:19 +00:00
( trimmed . startsWith ( "'" ) && trimmed . endsWith ( "'" ) )
) {
return trimmed . slice ( 1 , - 1 ) ;
}
return trimmed ;
}
2025-12-25 00:25:11 +00:00
function formatDuration ( ms : number ) {
if ( ms < 1000 ) return ` ${ ms } ms ` ;
const seconds = Math . floor ( ms / 1000 ) ;
if ( seconds < 60 ) return ` ${ seconds } s ` ;
const minutes = Math . floor ( seconds / 60 ) ;
const rem = seconds % 60 ;
return ` ${ minutes } m ${ rem . toString ( ) . padStart ( 2 , "0" ) } s ` ;
}
function pad ( str : string , width : number ) {
if ( str . length >= width ) return str ;
return str + " " . repeat ( width - str . length ) ;
}