How execute_command and read_command_output work together — from invocation through output delivery, cancellation, and cleanup.
Host boundary. Terminal execution is a Category I capability: the portable engine in
packages/core/src/terminal(defaultexecabackend —ExecaTerminal/ExecaTerminalProcess) runs behind theHostTerminalsseam, and the VS Code integrated terminal is a Category II backend the host adapter can supply instead. Seehost-boundary.md.
flowchart TD
L["LLM calls execute_command<br/>command, cwd?, timeout?"]
subgraph TOOL["ExecuteCommandTool.execute()"]
direction TB
V["Validate command<br/>non-empty, .shofer/shoferignore"]
A["Ask user approval — 'command' ask"]
C["Resolve working directory"]
V --> A --> C
end
subgraph EXEC["executeCommandInTerminal(task, options)"]
direction TB
OI["OutputInterceptor — head/tail buffer"]
TR["TerminalRegistry.getOrCreateTerminal()"]
RC["terminal.runCommand(command, callbacks)"]
RACE["Dual-timeout race"]
FMT["Format response — inline or persisted"]
OI --> TR --> RC --> RACE --> FMT
end
D{"Output fits preview?"}
INL["Inline output + exit code"]
PER["Preview + artifact_id"]
R["LLM calls read_command_output<br/>artifact_id, search?, offset?, limit?"]
L --> V
C --> OI
FMT --> D
D -->|yes| INL
D -->|no| PER --> R
| Parameter | Type | Required | Behavior |
|---|---|---|---|
command |
string |
✅ | Shell command to execute |
cwd |
string | null |
– | Working directory; relative paths resolved from task.cwd; absent = task.cwd |
timeout |
number | null |
– | Agent-side soft timeout in seconds (see §4.2) |
Schema definition: packages/core/src/prompts/tools/native-tools/execute_command.ts
Tool handler: packages/core/src/tools/ExecuteCommandTool.ts
timeout(optional) — When exceeded, the command continues running in the background and you receive the output so far. This allows you to proceed with your turn without waiting for the command to exit. You can monitor the process output by callingexecute_commandagain (with no timeout) to get the latest output.
Commands produce output that streams through an OutputInterceptor, which uses a head/tail buffer strategy:
Preview budget (per terminalOutputPreviewSize setting)
├── Head buffer: 50% — first N bytes (always preserved)
└── Tail buffer: 50% — last N bytes (rolling, drops old lines)
The preview size is controlled by the user setting terminalOutputPreviewSize ("small", "medium" [default], or "large") with byte thresholds defined in TERMINAL_PREVIEW_BYTES.
flowchart TD
S["Command output stream"]
OI["OutputInterceptor<br/>head 50% + tail 50% of the<br/>TERMINAL_PREVIEW_BYTES budget"]
D{"Output exceeds<br/>preview budget?"}
IN["Inline: full output + exit code"]
SP["OutputInterceptor.spillToDisk()<br/>full lossless output to<br/>command-output/cmd-EXECUTIONID.txt"]
TN["Truncated response:<br/>head + omitted-bytes marker + tail<br/>plus the Artifact ID"]
RCO["read_command_output"]
CU["OutputInterceptor.cleanup()<br/>on Task.dispose()"]
S --> OI --> D
D -->|no| IN
D -->|yes| SP --> TN --> RCO
SP --> CU
The LLM receives the full output inline:
Command executed in terminal within working directory '/path/to/cwd'.
Exit code: 0
Output:
<full output>
The OutputInterceptor spills the full lossless output to disk at:
{globalStoragePath}/tasks/{taskId}/command-output/cmd-{executionId}.txt
The LLM receives a truncated response:
Command executed in '/path/to/cwd'. Exit code: 0
Output (1.5MB) persisted. Artifact ID: cmd-1780977431651.txt
Preview:
<head + [...N bytes omitted...] + tail>
Use read_command_output tool to view full output if needed.
Two independent timers race against the process. Both can be active simultaneously.
| Setting | Source | Default | Behavior |
|---|---|---|---|
commandExecutionTimeout |
VS Code config (seconds, 0 = disabled) |
0 |
Aborts the command via terminalProcess.abort() (SIGINT for shell-integration terminals, SIGKILL + process tree for execa). LLM receives: "The command was terminated after exceeding a user-configured Ns timeout. Do not try to re-run the command." |
Commands whose prefix matches commandTimeoutAllowlist are exempt from the user timeout.
When the LLM supplies a timeout value (in seconds), and the command hasn't completed by that time:
- The agent timeout fires
- The command is not killed — it continues running in the terminal
process.continue()is called so the process is unblocked- The LLM receives the output collected so far, wrapped in a
user_feedbackmessage if the user sent one - The LLM can monitor the process on subsequent
execute_commandcalls (without a timeout)
This is the mechanism for running dev servers, file watchers, or any long-lived process.
// In executeCommandInTerminal:
const racers: Promise<void>[] = [process]
if (agentTimeout > 0) {
racers.push(/* background timer: runInBackground=true, process.continue() */)
}
if (commandExecutionTimeout > 0) {
racers.push(/* abort timer: terminalProcess.abort(), reject */)
}
await Promise.race(racers)Both timers are cleaned up in the finally block regardless of which won the race.
Alongside the two timers, the race also carries the process itself and a listener
on task.abortSignal, so the global Stop button (§5.3) can unwind a
never-terminating command instead of leaving the await hanging:
flowchart TD
R["await Promise.race(racers)<br/>in executeCommandInTerminal"]
P["process — command exits naturally"]
AB["task.abortSignal 'abort' listener<br/>rejects with 'Task aborted'"]
AG["agentTimeout > 0<br/>soft timer"]
UT["commandExecutionTimeout > 0<br/>hard timer"]
AGA["runInBackground = true<br/>process.continue()<br/>task.supersedePendingAsk()<br/>command keeps running"]
UTA["isUserTimedOut = true<br/>task.terminalProcess?.abort()<br/>reject"]
UTR["status 'timeout' to webview<br/>didToolFailInCurrentTurn = true<br/>tool result: terminated after Ns"]
ABR["tool result:<br/>'Command execution was aborted by the user.'"]
FIN["finally:<br/>clearTimeout on both timers<br/>remove the abort listener<br/>task.terminalProcess = undefined"]
R --> P
R --> AB --> ABR
R --> AG --> AGA
R --> UT --> UTA --> UTR
P --> FIN
ABR --> FIN
AGA --> FIN
UTR --> FIN
There are four independent mechanisms that can kill a running command:
Source: CommandExecution.tsx
When a command is running (status === "started"), the UI renders an ⏹ stop button next to the PID. Clicking it posts:
{ type: "terminalOperation", terminalOperation: "abort" }
This flows through:
| Step | File | Line |
|---|---|---|
| UI click | CommandExecution.tsx |
178-181 |
| IPC handler | webviewMessageHandler.ts |
939-941 |
| Task dispatch | Task.ts |
2611-2616 |
| Terminal kill | TerminalProcess.ts (VS Code) or ExecaTerminalProcess.ts (execa) |
259-263 / 163-219 |
Kill mechanism:
- VS Code shell integration: Sends
Ctrl+C(\x03) viaterminal.sendText("\x03") - Execa fallback:
SIGKILLon subprocess + stored PID +psTreewalk for child processes
Source: ChatView.tsx
When the LLM is in a command_output ask (the "Kill Command" secondary button), clicking it posts { type: "terminalOperation", terminalOperation: "abort" }, routing through the same path as §5.1.
Source: Task.ts
When the user clicks the global Stop button:
ShoferProvider.cancelTask()→_cancelTaskInner()- Cancels the LLM HTTP request via
task.cancelCurrentRequest() - Calls
task.abortTask()which:- Sets
this.abort = true - Fires
_taskAbortController.abort()(cancels MCP calls, etc.) - Calls
this.terminalProcess?.abort()— kills the running command - Calls
dispose()→TerminalRegistry.releaseTerminalsForTask()(disassociates the terminal) - Cleans up command output artifacts via
OutputInterceptor.cleanup()
- Sets
Described in §4.1 — automatic kill after commandExecutionTimeout seconds.
| Action | Kills process? | Mechanism |
|---|---|---|
| Per-command OctagonX button | terminalProcess.abort() → SIGINT or SIGKILL + process tree |
|
Reject on command_output ask |
Same path as OctagonX | |
| Global Stop button | ✅ | task.terminalProcess?.abort() inside abortTask() |
User commandExecutionTimeout |
✅ | Automatic SIGINT/SIGKILL after timeout |
Agent timeout parameter |
❌ | Backgrounds process — keeps running, LLM can monitor later |
⚠️ Known gap (§5.6): The OctagonX button and Reject button are ineffective for backgrounded commands becausetask.terminalProcessis cleared unconditionally in thefinallyblock andTerminalProcess.abort()is guarded byisListening(which isfalseafter backgrounding). See the fix design below.
The OctagonX stop button in CommandExecution.tsx is designed to let users kill a running command at any time. However, the button is only functional before the execute_command tool returns. Once the tool exits — whether the command completes, the agent timeout fires, or the user clicks "Proceed While Running" — the kill path breaks in two independent ways.
Bug 1: task.terminalProcess is unconditionally cleared.
Source: ExecuteCommandTool.ts
} finally {
clearTimeout(agentTimeoutId)
clearTimeout(userTimeoutId)
clearTimeout(pendingCommandOutputEmitTimer)
task.terminalProcess = undefined // ← cleared even for backgrounded commands
}The finally block runs after the Promise.race resolves — which happens in three scenarios:
- The process exits naturally (
processpromise resolves) - The agent timeout fires (
runInBackground = true,process.continue(), racer resolves) - The user timeout fires (reject, caught by
catch)
In scenario 2, the command is still alive in the terminal, but task.terminalProcess is set to undefined. Any subsequent handleTerminalOperation("abort") call is a no-op:
// Task.ts:2935-2941
async handleTerminalOperation(terminalOperation: "continue" | "abort") {
if (terminalOperation === "continue") {
this.terminalProcess?.continue() // ← undefined?.continue() → no-op
} else if (terminalOperation === "abort") {
this.terminalProcess?.abort() // ← undefined?.abort() → no-op
}
}Bug 2: TerminalProcess.abort() is guarded by isListening.
Source: TerminalProcess.ts
public override abort() {
if (this.isListening) { // ← IS FALSE AFTER backgrounding
this.terminal.terminal.sendText("\x03")
}
}When the command is backgrounded (agent timeout or "Proceed While Running"), several things happen:
// TerminalProcess.ts:252-257
public override continue() {
this.emitRemainingBufferIfListening()
this.isListening = false // ← DISABLED
this.removeAllListeners("line")
this.emit("continue")
}After continue(), isListening is false, so even if task.terminalProcess were preserved, abort() would silently return without doing anything.
Note:
ExecaTerminalProcess.abort()does NOT have theisListeningguard — it always kills the subprocess. This bug is specific to the VS Code shell-integration backend.
Bug 3: The OctagonX button disappears after backgrounding.
Source: CommandExecution.tsx
{status?.status === "started" && (
// OctagonX button...
)}The button is only visible when status === "started". After backgrounding, the UI receives "output" status updates (not "started"), so the button disappears — the user has no UI affordance to kill the command even if the backend plumbing were functioning.
The fix spans three layers:
File: CommandExecution.tsx
Change the visibility condition from status?.status === "started" to a non-terminal check:
const isCommandAlive =
status !== null && status.status !== "exited" && status.status !== "fallback" && status.status !== "timeout"
{
isCommandAlive && (
<div className="flex flex-row items-center gap-2 font-mono text-xs">
{status?.status === "started" && status.pid && <div className="whitespace-nowrap">(PID: {status.pid})</div>}
<StandardTooltip content={t("chat:commandExecution.abort")}>
<Button
variant="ghost"
size="icon"
onClick={() =>
vscode.postMessage({
type: "terminalOperation",
terminalOperation: "abort",
executionId,
})
}>
<OctagonX className="size-4" />
</Button>
</StandardTooltip>
</div>
)
}Key changes:
- ✅ Show PID only during
"started"(it's not available after backgrounding) - ✅ Show OctagonX button for any status that isn't terminal (
"exited","fallback","timeout") - ✅ Include
executionIdin theterminalOperationmessage for future-proof routing
File: packages/types/src/vscode-extension-host.ts
Extend the terminalOperation message variant:
terminalOperation?: "continue" | "abort"
executionId?: string // populated when abort originates from CommandExecutionFile A: ExecuteCommandTool.ts
Guard the task.terminalProcess = undefined line so it only clears the reference when the command actually completed or was killed:
} finally {
clearTimeout(agentTimeoutId)
clearTimeout(userTimeoutId)
clearTimeout(pendingCommandOutputEmitTimer)
// Only clear terminal process reference if the command finished or was killed.
// Backgrounded commands continue running and need a live reference for UI abort.
if (!runInBackground) {
task.terminalProcess = undefined
}
}Corollary: The global Stop button path (
abortTask()→this.terminalProcess?.abort()→dispose()→TerminalRegistry.releaseTerminalsForTask()) still needs to work for backgrounded commands. SinceabortTask()callsterminalProcess?.abort(), the process reference must be non-null. This is satisfied by the guard above —task.terminalProcessremains set for backgrounded commands.
File B: TerminalProcess.ts
Remove the isListening guard so sendText("\x03") is always issued:
public override abort() {
// Send SIGINT using CTRL+C regardless of isListening state.
// Works for backgrounded commands where continue() set isListening=false.
this.terminal.terminal.sendText("\x03")
}The sendText("\x03") sends a literal Ctrl+C byte sequence to the VS Code terminal's stdin, which the shell interprets as SIGINT to the foreground process group. This works:
- ✅ While the command is actively running (
isListening = true) - ✅ After the command is backgrounded via
continue()(isListening = false) - ✅ Regardless of whether we're collecting output
If the terminal has no running foreground process, \x03 is simply ignored by the shell — it's harmless.
User clicks OctagonX button on a backgrounded command
│
▼
CommandExecution.tsx
postMessage({ type: "terminalOperation", terminalOperation: "abort", executionId })
│
▼
webviewMessageHandler.ts
provider.getCurrentTask()?.handleTerminalOperation("abort")
│
▼
Task.ts
this.terminalProcess?.abort() // ← reference is preserved (runInBackground guard)
│
▼
TerminalProcess.ts / ExecaTerminalProcess.ts
terminal.sendText("\x03") // ← sends SIGINT regardless of isListening
OR
subprocess.kill("SIGKILL") // ← execa path (no isListening guard)
│
▼
Shell receives SIGINT → process terminates → onShellExecutionComplete fires
│
▼
Webview receives "exited" status → OctagonX button disappears, exit badge appears
| File | Change | Risk |
|---|---|---|
CommandExecution.tsx |
Show button for all alive states; pass executionId |
Low — UI-visibility change only |
vscode-extension-host.ts |
Add optional executionId field |
Low — optional, backward-compatible |
ExecuteCommandTool.ts |
Guard task.terminalProcess = undefined with !runInBackground |
Medium — must not leak process references |
TerminalProcess.ts |
Remove isListening guard from abort() |
Low — harmless no-op if no process is running |
When a backgrounded command exits naturally after the tool has returned, onShellExecutionComplete fires → "exited" status is posted to webview → the button disappears automatically. The process reference is still set on task.terminalProcess but the subsequent handleTerminalOperation("abort") call would be a no-op (the terminal has no running command). It's harmless.
When the task ends (user stops it, task completes), the global abortTask() path calls this.terminalProcess?.abort() then dispose() → TerminalRegistry.releaseTerminalsForTask() cleans up the terminal association. No orphaned references.
Schema definition: packages/core/src/prompts/tools/native-tools/read_command_output.ts
Tool handler: packages/core/src/tools/ReadCommandOutputTool.ts
| Parameter | Type | Required | Behavior |
|---|---|---|---|
artifact_id |
string |
✅ | Filename from the truncation notice (e.g., "cmd-1706119234567.txt"). Validated against /^cmd-\d+\.txt$/ to block path traversal. |
search |
string | null |
– | Case-insensitive regex pattern (like grep). Invalid regex is auto-escaped to literal. Omit entirely if not searching — do not pass null or empty string. |
offset |
number | null |
– | Byte offset for pagination (default: 0). |
limit |
number | null |
– | Maximum bytes to return (default: 40KB). |
- Opens the artifact file handle
- Reads
[offset, offset+limit]bytes - Calculates correct line numbers by counting newlines before the offset (chunked 64KB reads — avoids allocating huge buffers)
- Adds right-padded line numbers
- Returns a metadata header + numbered content:
[Command Output: cmd-1706119234567.txt]
Total size: 52.0KB | Showing bytes 0-40960 | TRUNCATED
1 | first line
2 | second line
...
- Streams the file in 64KB chunks (bounded memory — safe for 100MB+ files)
- Handles partial lines across chunk boundaries via a carry-over buffer
- Tests each complete line against the case-insensitive regex
- Stops accumulating when the byte limit is exceeded
- Returns match metadata + numbered matching lines:
[Command Output: cmd-1706119234567.txt] (search: "error|failed")
Total matches: 42 | Showing first 42
12 | Error: connection refused
89 | test_foo: FAILED
...
| Aspect | Detail |
|---|---|
| Storage path | {globalStoragePath}/tasks/{taskId}/command-output/cmd-{executionId}.txt |
| Created by | OutputInterceptor.spillToDisk() |
| Cleaned by | OutputInterceptor.cleanup() — deletes all cmd-*.txt (called during Task.dispose()) |
| Selective cleanup | OutputInterceptor.cleanupByIds() — preserves specific execution IDs |
The terminal provider is chosen based on the terminalShellIntegrationDisabled setting, with an automatic override for worktree-scoped tasks:
| Condition | Backend | Execution | Kill mechanism |
|---|---|---|---|
false (default), non-worktree |
VS Code Terminal | Shell integration via sendText |
SIGINT via \x03 |
true, non-worktree |
Execa | Subprocess via execa |
SIGKILL + psTree for child processes |
| Worktree task on Linux | Execa (forced) | Sandboxed via shofer-sandbox |
SIGKILL + psTree for child processes |
| Worktree task on macOS/Windows | User's setting | Advisory warning in approval | Per backend |
If the VS Code terminal throws a ShellIntegrationError, the tool automatically retries with execa (without requiring the user to change settings).
flowchart TD
Q1{"Worktree task on Linux?"}
Q2{"terminalShellIntegrationDisabled"}
EXS["Execa — forced<br/>shofer-sandbox wrapper is outermost<br/>SIGKILL + psTree"]
VS["VS Code Terminal<br/>shell integration via sendText<br/>SIGINT via Ctrl+C"]
EX["Execa<br/>subprocess via execa<br/>SIGKILL + psTree"]
Q1 -->|yes| EXS
Q1 -->|"no — macOS/Windows worktree<br/>gets an advisory warning only"| Q2
Q2 -->|"false (default)"| VS
Q2 -->|true| EX
VS -.->|"ShellIntegrationError — retry with<br/>terminalShellIntegrationDisabled: true"| EX
When a task runs inside an embedded worktree on Linux, execute_command prepends the shofer-sandbox wrapper binary (src/sandbox/main.go) to the shell command. The wrapper:
- Applies a Landlock write-only sandbox (kernel 5.13+) — writes are restricted to the worktree directory,
/tmp, and/dev/null; reads remain unrestricted - Falls back to bubblewrap (
bwrap) on older kernels — creates a private mount namespace with the worktree as the only writable location - Forces the execa backend — the VS Code terminal path cannot be sandboxed because VS Code owns the process lifecycle
The sandbox wrapper is the outermost process: shofer-sandbox <worktree-dir> -- /bin/sh -c '<user-command>'. This ensures the shell itself and all subprocesses inherit the Landlock ruleset. On macOS and Windows, no kernel sandbox is available — the advisory warning remains the only guard.
Key files: getWorktreeSandboxPrefix(), src/sandbox/main.go
| File | Role |
|---|---|
packages/core/src/prompts/tools/native-tools/execute_command.ts |
OpenAI function-calling schema for execute_command |
packages/core/src/tools/ExecuteCommandTool.ts |
Tool handler: validation, approval, timeout, terminal orchestration |
packages/core/src/prompts/tools/native-tools/read_command_output.ts |
OpenAI function-calling schema for read_command_output |
packages/core/src/tools/ReadCommandOutputTool.ts |
Tool handler: artifact reads, search, pagination |
packages/core/src/terminal/OutputInterceptor.ts |
Head/tail buffer, spill-to-disk, preview formatting |
src/integrations/terminal/TerminalProcess.ts |
VS Code shell-integration terminal process |
packages/core/src/terminal/ExecaTerminalProcess.ts |
Execa fallback terminal process |
packages/core/src/terminal/TerminalRegistry.ts |
Terminal lifecycle management |
packages/core/src/utils/worktreePathGuard.ts |
Worktree sandbox prefix resolution (getWorktreeSandboxPrefix) |
src/sandbox/main.go |
Landlock/bwrap sandbox wrapper binary (Go, static-linked) |
packages/core/src/task/Task.ts |
Task-level abort (Stop button → terminalProcess.abort()) |
webview-ui/src/components/chat/CommandExecution.tsx |
UI: command output display + OctagonX abort button |
webview-ui/src/components/chat/ChatView.tsx |
UI: Reject button → terminal abort |
packages/types/src/terminal.ts |
PersistedCommandOutput type + TERMINAL_PREVIEW_BYTES |
packages/types/src/global-settings.ts |
terminalOutputPreviewSize setting |
native_tools.md— Full native tools reference with parameter tables and mode availabilitycancellation.md— End-to-end Stop-button propagation through the task lifecycleconfiguration.md— User-facing configuration options includingcommandExecutionTimeoutandcommandTimeoutAllowlist