Files
cline/apps/vscode/src/sdk/vscode-run-commands-tool.ts
T
Dominic Cooney 8b4d1973b5 fix(vscode): reclaim unobserved fallback terminals (#12352)
Classify unobservable terminal outcomes so cleanup and reporting share one source of truth. Reclaim managed sendText fallbacks at the disclosed next-acquisition boundary while preserving markerless, continued, detached, and uncertain-error terminals. Make cleanup, CWD reservations, process listeners, and detached logs failure-safe.
2026-07-22 16:22:11 -07:00

568 lines
21 KiB
TypeScript

/**
* Custom `run_commands` tool that replaces the SDK's built-in version.
*
* This is an IDE-level feature built on top of the SDK, NOT part of the SDK.
* It supports two execution modes, switchable dynamically per invocation:
*
* - **Foreground (vscodeTerminal):** Uses VscodeTerminalManager for visible
* VS Code terminals with shell integration.
*
* - **Background (backgroundExec):** Delegates to the SDK's createShellExecutor()
* for headless child_process.spawn execution.
*/
import {
CommandExitError,
createShellExecutor,
createShellTool,
MAX_COMMAND_OUTPUT_CHARS,
type ShellExecutor,
type StructuredCommandInput,
truncateCommandOutput,
} from "@cline/core"
import type { AgentTool } from "@cline/shared"
import { TerminalUserInterventionAction, telemetryService } from "@services/telemetry"
import { ClineTempManager } from "@services/temp"
import * as fs from "fs"
import { StateManager } from "@/core/storage/StateManager"
import type { VscodeTerminalManager } from "@/hosts/vscode/terminal/VscodeTerminalManager"
import { MAX_UNRETRIEVED_LINES } from "@/integrations/terminal/constants"
import {
getUnobservedTerminalCommandDisposition,
type ITerminalProcess,
type TerminalCompletionDetails,
} from "@/integrations/terminal/types"
import { Logger } from "@/shared/services/Logger"
import { getShellForProfile } from "@/utils/shell"
import type { SdkForegroundCommandCoordinator } from "./sdk-foreground-command-coordinator"
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type ShellCommand = string | StructuredCommandInput
type VscodeTerminalExecutionMode = "vscodeTerminal" | "backgroundExec"
/** Foreground VS Code terminals cannot be forcibly terminated; give long-running commands room to finish. */
export const VSCODE_FOREGROUND_RUN_COMMANDS_TIMEOUT_MS = 60 * 60 * 1000
/**
* Cap on the "Proceed While Running" log file. A detached devserver can log
* for days; once the cap is hit we stop appending and note the truncation.
* ClineTempManager's periodic cleanup (age + total-size caps) is the backstop
* for the files themselves.
*/
export const PROCEED_LOG_MAX_BYTES = 10 * 1024 * 1024
const PROCEED_LOG_FINAL_MESSAGE_MAX_CHARS = 4096
/** Options for creating the VSCode run_commands tool. */
export interface VscodeRunCommandsToolOptions {
/** Workspace root directory. */
cwd: string
/** Lazy factory for the VscodeTerminalManager. Called once on first foreground use. */
getTerminalManager: () => VscodeTerminalManager
/** Timeout passed to the SDK shell tool wrapper and timeout telemetry. */
bashTimeoutMs?: number
/** Terminal execution mode captured when this session's tool set is built. */
vscodeTerminalExecutionMode?: VscodeTerminalExecutionMode
/**
* Registry of in-flight foreground executions, owned by SdkController.
* When provided, each foreground command can be detached via the
* "Proceed While Running" button. Foreground-only: background (SDK
* child_process) executions cannot be detached — their abort signal
* kills the process tree.
*/
foregroundCommands?: SdkForegroundCommandCoordinator
}
// ---------------------------------------------------------------------------
// Foreground execution — VscodeTerminalManager
// ---------------------------------------------------------------------------
function quoteShellArg(arg: string): string {
if (arg.length === 0) {
return "''"
}
if (!/[\s"'\\$`!&|;<>(){}[\]*?~]/.test(arg)) {
return arg
}
return `'${arg.replace(/'/g, `'\\''`)}'`
}
export function formatCommandForTerminal(command: ShellCommand): string {
if (typeof command === "string") {
return command
}
if (!("args" in command)) {
return command.command
}
return [command.command, ...(command.args ?? [])].map(quoteShellArg).join(" ")
}
/**
* Stream the rest of a detached command's output to a log file: write the
* lines buffered so far, then append each further 'line' event until
* 'completed'. The write volume is capped at PROCEED_LOG_MAX_BYTES; the
* stream is closed when the command completes or its process/acquisition
* fails. Completion covers command end, Ctrl+C, terminal close, and the
* markerless fallback.
*/
interface DetachedCommandLog {
path: string
attach(process: ITerminalProcess): void
fail(error: unknown): void
}
function createDetachedCommandLog(terminalCommand: string, existingLines: string[]): DetachedCommandLog {
const logFilePath = ClineTempManager.createTempFilePath("proceed-while-running")
const stream = fs.createWriteStream(logFilePath, { flags: "a" })
const sizeCapMessage = `[Log size cap of ${PROCEED_LOG_MAX_BYTES} bytes reached; further output is not logged.]`
stream.on("error", (error) => {
Logger.error(`[VscodeRunCommands] Failed writing proceed-while-running log ${logFilePath}:`, error)
})
let bytesWritten = 0
const tryWriteLine = (line: string): boolean => {
const chunk = `${line}\n`
const chunkBytes = Buffer.byteLength(chunk)
if (bytesWritten + chunkBytes > PROCEED_LOG_MAX_BYTES) {
return false
}
bytesWritten += chunkBytes
stream.write(chunk)
return true
}
let sizeCapReached = !tryWriteLine(`[Running command: ${terminalCommand}]`)
for (const line of existingLines) {
if (!tryWriteLine(line)) {
sizeCapReached = true
break
}
}
let settled = false
let removeAttachedListeners = (): void => {}
const end = (message: string): void => {
if (settled) {
return
}
settled = true
removeAttachedListeners()
// Output obeys the strict cap, but reserve a small bounded allowance for
// the terminal status so a full log never hides completion or failure.
stream.write(`${message.slice(0, PROCEED_LOG_FINAL_MESSAGE_MAX_CHARS)}\n`)
stream.end()
}
return {
path: logFilePath,
attach: (process) => {
const onLine = (line: string): void => {
// Check the cap before writing: a single huge line (e.g. a dumped
// binary blob or minified bundle) must not blow past the cap.
if (!tryWriteLine(line)) {
tryWriteLine(sizeCapMessage)
process.removeListener("line", onLine)
}
}
const onCompleted = (details?: TerminalCompletionDetails): void => {
const exitCode = details?.exitCode
end(
details?.terminalClosed
? "[Terminal closed while the command was running; output may be incomplete]"
: details?.unobservedCommand
? "[Command completion could not be observed; the command may still be running]"
: exitCode !== undefined && exitCode !== null
? `[Command completed with exit code ${exitCode}]`
: "[Command completed]",
)
}
const onError = (error: Error): void => end(`[Command failed after detaching: ${error.message}]`)
removeAttachedListeners = () => {
process.removeListener("line", onLine)
process.removeListener("completed", onCompleted)
process.removeListener("error", onError)
}
if (sizeCapReached) {
tryWriteLine(sizeCapMessage)
} else {
process.on("line", onLine)
}
process.once("completed", onCompleted)
process.once("error", onError)
},
fail: (error) => {
const message = error instanceof Error ? error.message : String(error)
end(`[Command failed before log capture completed: ${message}]`)
},
}
}
function formatDetachedResult(logFilePath: string, output: string): string {
return [
"The user chose to proceed while the command is starting or still running in their terminal.",
`This is partial output; further output is being redirected to this file, which you can read to check progress: ${logFilePath}`,
output.length > 0 ? `Output so far:\n${output}` : "No output so far.",
].join("\n")
}
type PreStartControl = "detach" | "abort"
/** Exported for direct unit testing of the CommandExitError/terminalClosed mapping. */
export async function executeForeground(
command: ShellCommand,
cwd: string,
terminalManager: VscodeTerminalManager,
maxOutputChars: number,
abortSignal?: AbortSignal,
foregroundCommands?: SdkForegroundCommandCoordinator,
terminalProfileId?: string,
): Promise<string> {
const terminalCommand = formatCommandForTerminal(command)
// "Proceed While Running": register a per-invocation handle so the user
// can detach this command. Detaching redirects the remaining output to a
// log file and resolves the awaited promise; the command keeps running in
// the user's terminal (and the terminal stays busy until it completes).
//
// Registered BEFORE terminal acquisition so every command in a parallel
// run_commands batch is registered before the user can click the button.
// A command still awaiting its terminal when the user detaches records the
// request and applies it the moment its process starts — otherwise that
// late command would re-block the turn the button just released.
const state: { phase: "waiting" | "started" | "detached" | "aborted" } = { phase: "waiting" }
let detachedLog: DetachedCommandLog | undefined
let applyDetach: (() => void) | undefined
let resolvePreStartControl!: (control: PreStartControl) => void
const preStartControl = new Promise<PreStartControl>((resolve) => {
resolvePreStartControl = resolve
})
const unregister = foregroundCommands?.register({
detach: () => {
if (state.phase === "waiting") {
state.phase = "detached"
detachedLog = createDetachedCommandLog(terminalCommand, [])
telemetryService.captureTerminalUserIntervention(TerminalUserInterventionAction.PROCESS_WHILE_RUNNING, "vscode")
resolvePreStartControl("detach")
} else if (state.phase === "started") {
applyDetach?.()
}
},
})
const onAbort = (): void => {
if (state.phase === "waiting") {
state.phase = "aborted"
resolvePreStartControl("abort")
} else if (state.phase === "started") {
applyAbort?.()
}
}
let applyAbort: (() => void) | undefined
abortSignal?.addEventListener("abort", onAbort, { once: true })
if (abortSignal?.aborted) {
onAbort()
}
try {
const terminalPromise = terminalManager.getOrCreateTerminal(cwd, terminalProfileId)
const startDetached = (terminalInfo: Awaited<typeof terminalPromise>, log: DetachedCommandLog): void => {
try {
const process = terminalManager.runCommand(terminalInfo, terminalCommand)
log.attach(process)
void process.catch((error) => log.fail(error))
process.detach()
} catch (error) {
log.fail(error)
}
}
const acquisition = terminalPromise.then(
(terminalInfo) => ({ type: "terminal" as const, terminalInfo }),
(error: unknown) => ({ type: "error" as const, error }),
)
const finishDetachedAcquisition = (outcome: Awaited<typeof acquisition>, log: DetachedCommandLog): void => {
if (outcome.type === "terminal") {
startDetached(outcome.terminalInfo, log)
} else {
log.fail(outcome.error)
}
}
const finishAbortedAcquisition = (outcome: Awaited<typeof acquisition>): void => {
if (outcome.type === "terminal") {
terminalManager.releaseTerminalReservation(outcome.terminalInfo)
}
}
const firstOutcome = await Promise.race([
acquisition,
preStartControl.then((control) => ({ type: "control" as const, control })),
])
if (firstOutcome.type === "control") {
if (firstOutcome.control === "abort") {
// Acquisition is already in flight and may return a synchronously
// reserved terminal after this tool result settles. Consume it so a
// pre-start cancellation cannot leave that terminal permanently busy.
void acquisition.then(finishAbortedAcquisition)
throw new Error("Command execution aborted")
}
const log = detachedLog
if (!log) {
throw new Error("Detached command log was not initialized")
}
void acquisition.then((outcome) => finishDetachedAcquisition(outcome, log))
return formatDetachedResult(log.path, "")
}
// Acquisition and a user action can resolve in the same microtask turn.
// Re-check the invocation phase after the await so an already-queued
// terminal outcome cannot overwrite a detach or abort that happened while
// the promise continuation was pending.
if (state.phase === "aborted") {
finishAbortedAcquisition(firstOutcome)
throw new Error("Command execution aborted")
}
if (state.phase === "detached") {
const log = detachedLog
if (!log) {
throw new Error("Detached command log was not initialized")
}
finishDetachedAcquisition(firstOutcome, log)
return formatDetachedResult(log.path, "")
}
if (firstOutcome.type === "error") {
throw firstOutcome.error
}
state.phase = "started"
const { terminalInfo } = firstOutcome
const process = terminalManager.runCommand(terminalInfo, terminalCommand)
const outputLines: string[] = []
let droppedLines = 0
// Accumulate output lines to return the full output once the command completes.
// The chat shows command output at completion, not incrementally.
//
// This is a second buffer on top of the process's own `fullOutput` (capped at
// MAX_FULL_OUTPUT_SIZE — see VscodeTerminalProcess), so it needs its own cap:
// a long-running command emitting many lines must not accumulate them here
// without bound. Once the cap is hit, keep only the head and tail — matching
// truncateCommandOutput's own head/tail strategy below — since build/test
// failures usually appear at the end of output.
const maxBufferedLines = MAX_UNRETRIEVED_LINES
const bufferLine = (line: string): void => {
if (outputLines.length < maxBufferedLines) {
outputLines.push(line)
} else {
outputLines.shift()
outputLines.push(line)
droppedLines++
}
}
process.on("line", bufferLine)
try {
applyAbort = () => process.continue()
applyDetach = () => {
if (detachedLog !== undefined) {
return
}
detachedLog = createDetachedCommandLog(terminalCommand, outputLines)
detachedLog.attach(process)
telemetryService.captureTerminalUserIntervention(TerminalUserInterventionAction.PROCESS_WHILE_RUNNING, "vscode")
// detach() flushes any partial line (reaching both bufferLine and
// the log) before resolving the awaited promise. After that the
// partial output is final: stop buffering so the remaining
// (log-only) output doesn't mutate outputLines while it's read.
process.detach()
process.removeListener("line", bufferLine)
}
// Wait for completion (or detach, which also resolves the promise)
await process
if (abortSignal?.aborted) {
throw new Error("Command execution aborted")
}
const bufferedOutput =
droppedLines > 0
? [...outputLines, `\n... (${droppedLines} earlier lines dropped) ...\n`].join("\n")
: outputLines.join("\n")
const output = truncateCommandOutput(bufferedOutput.trim(), {
maxChars: maxOutputChars,
})
if (detachedLog !== undefined) {
return formatDetachedResult(detachedLog.path, output)
}
const completionDetails = process.getCompletionDetails?.()
// A terminal closed mid-command has no exit code and no reliable output —
// whatever the command was doing (e.g. running a test suite) was interrupted,
// so this must never look like success to the agent.
if (completionDetails?.terminalClosed) {
const result =
output.length > 0
? `[Terminal closed while the command was running; output may be incomplete]\n${output}`
: "[Terminal closed while the command was running; no output was captured]"
throw new CommandExitError(1, result)
}
if (completionDetails?.unobservedCommand) {
const disposition = getUnobservedTerminalCommandDisposition(completionDetails.unobservedCommand)
const lifecycle =
disposition === "disposeBeforeNextTerminalAcquisition"
? "The terminal remains open for now, but starting another foreground command will attempt to close it, stopping the command if it is still running."
: "The terminal has been left open and will not be closed automatically."
const result =
output.length > 0
? `[Command completion could not be observed; the command may still be running and must not be assumed to have succeeded. ${lifecycle}]\n${output}`
: `[Command completion could not be observed; the command may still be running and must not be assumed to have succeeded. ${lifecycle}]`
throw new CommandExitError(1, result)
}
// Plumb the exit code from onDidEndTerminalShellExecution through to the tool
// result. When shell integration reports a non-zero exit code, throw
// CommandExitError so the SDK's shell tool wrapper marks the result as
// `success: false` and includes the exit code in the error message —
// matching the background (child_process) executor's behavior.
// If no exit code was captured after an observed completion, return the
// output as-is. Unobserved completion is handled explicitly above.
const exitCode = completionDetails?.exitCode
if (exitCode !== undefined && exitCode !== null && exitCode !== 0) {
const result =
output.length > 0
? `[Command exited with code ${exitCode}]\n${output}`
: `[Command exited with code ${exitCode}]`
throw new CommandExitError(exitCode, result)
}
return output
} finally {
process.removeListener("line", bufferLine)
}
} finally {
abortSignal?.removeEventListener("abort", onAbort)
unregister?.()
}
}
// ---------------------------------------------------------------------------
// Tool factory
// ---------------------------------------------------------------------------
/**
* The shell selected by the user's terminal profile setting at one moment:
* the profile ID for foreground terminal creation and the shell executable
* it resolves to for background spawning and description building.
*/
interface ShellSnapshot {
profileId: string
shell: string
}
/** Resolves the shell the user's terminal profile setting selects right now. */
function takeShellSnapshot(): ShellSnapshot {
// The setting is typed string, but guard empty values the same way the
// settings handlers do (they skip persisting "" but older stores may hold one).
const profileId = StateManager.get().getGlobalSettingsKey("defaultTerminalProfile") || "default"
return { profileId, shell: getShellForProfile(profileId) }
}
/**
* Creates the custom `run_commands` tool for the VSCode extension.
*
* This tool suppresses and replaces the SDK's built-in `run_commands` tool.
* The terminal execution mode is captured when the session's tool set is
* built; switching modes rebuilds the active SDK session so the tool timeout
* and execution path follow it.
*
* The shell is snapshotted each time the runtime reads the tool description,
* which happens when a model request is built. Tool calls produced by that
* request execute with the same snapshot, so changing the terminal profile
* while the model is generating does not change the shell under commands the
* model has already planned: the new shell is named in the next request (the
* one carrying these tool results) and used by the commands it produces.
*/
export function createVscodeRunCommandsTool(options: VscodeRunCommandsToolOptions): AgentTool {
const state = { snapshot: takeShellSnapshot() }
return createShellTool(createVscodeShellExecutor(options, state), {
cwd: options.cwd,
bashTimeoutMs: options.bashTimeoutMs,
shell: () => {
state.snapshot = takeShellSnapshot()
return state.snapshot.shell
},
})
}
function createVscodeShellExecutor(options: VscodeRunCommandsToolOptions, state: { snapshot: ShellSnapshot }): ShellExecutor {
const { cwd, getTerminalManager } = options
const executionMode = options.vscodeTerminalExecutionMode ?? "backgroundExec"
// Lazy-init background executor — recreated when the snapshotted shell changes.
let bgExecutor: ShellExecutor | undefined
let bgExecutorShell: string | undefined
// Lazy-init terminal manager reference
let terminalManager: VscodeTerminalManager | undefined
return async (command, commandCwd, context): Promise<string> => {
Logger.log(`[VscodeRunCommands] Executing command in ${executionMode} mode`)
// Execute with the shell named in the model request that produced this
// tool call, not the setting's current value (see createVscodeRunCommandsTool).
const { profileId, shell } = state.snapshot
if (executionMode === "backgroundExec") {
// Background path — use SDK's createShellExecutor.
// Recreate the executor if the shell has changed
if (!bgExecutor || bgExecutorShell !== shell) {
bgExecutorShell = shell
bgExecutor = createShellExecutor({
shell,
// Set SHELL env to match the shell we're spawning so child
// processes see the correct value instead of the inherited parent's.
env: { SHELL: shell },
})
Logger.log(`[VscodeRunCommands] Background executor using shell: ${shell}`)
}
// Record execution outcomes so background mode is comparable with
// foreground mode in the same task.terminal_execution event —
// essential for judging the backgroundExec-by-default change.
try {
const result = await bgExecutor(command, commandCwd || cwd, context)
telemetryService.captureTerminalExecution(true, "vscode", "child_process", {
exitCode: 0,
terminalExecutionMode: "backgroundExec",
})
return result
} catch (error) {
telemetryService.captureTerminalExecution(false, "vscode", "child_process", {
...(error instanceof CommandExitError && { exitCode: error.exitCode }),
terminalExecutionMode: "backgroundExec",
})
throw error
}
}
// Foreground path — use VscodeTerminalManager
if (!terminalManager) {
terminalManager = getTerminalManager()
}
return await executeForeground(
command,
commandCwd || cwd,
terminalManager,
MAX_COMMAND_OUTPUT_CHARS,
context.signal,
options.foregroundCommands,
profileId,
)
}
}