diff --git a/src/resources/extensions/bg-shell/index.ts b/src/resources/extensions/bg-shell/index.ts index a9000d509..7d0f76ec8 100644 --- a/src/resources/extensions/bg-shell/index.ts +++ b/src/resources/extensions/bg-shell/index.ts @@ -16,7 +16,7 @@ * - Context injection: proactive alerts for crashes and state changes * * Tools: - * bg_shell — start, output, digest, wait_for_ready, send, send_and_wait, + * bg_shell — start, output, digest, wait_for_ready, send, send_and_wait, run, * signal, list, kill, restart, group_status * * Commands: @@ -929,6 +929,92 @@ async function sendAndWait( return { matched: false, output: newEntries.map(e => e.line).join("\n") || "(no output)" }; } +// ── Run on Session ───────────────────────────────────────────────────────── + +async function runOnSession( + bg: BgProcess, + command: string, + timeout: number, + signal?: AbortSignal, +): Promise<{ exitCode: number; output: string; timedOut: boolean }> { + const sentinel = randomUUID().slice(0, 8); + const startMarker = `__GSD_SENTINEL_${sentinel}_START__`; + const endMarker = `__GSD_SENTINEL_${sentinel}_END__`; + const exitVar = `__GSD_EXIT_${sentinel}__`; + + // Snapshot current output buffer position + const startIndex = bg.output.length; + + // Write the sentinel-wrapped command to stdin + const wrappedCommand = [ + `echo ${startMarker}`, + command, + `${exitVar}=$?`, + `echo ${endMarker} $${exitVar}`, + ].join("\n"); + bg.proc.stdin?.write(wrappedCommand + "\n"); + + const start = Date.now(); + while (Date.now() - start < timeout) { + if (signal?.aborted) { + const newEntries = bg.output.slice(startIndex); + return { exitCode: -1, output: newEntries.map(e => e.line).join("\n") || "(cancelled)", timedOut: false }; + } + + // Process died while waiting + if (!bg.alive) { + const newEntries = bg.output.slice(startIndex); + const lines = newEntries.map(e => e.line); + return { exitCode: bg.proc.exitCode ?? -1, output: lines.join("\n") || "(process exited)", timedOut: false }; + } + + const newEntries = bg.output.slice(startIndex); + for (let i = 0; i < newEntries.length; i++) { + if (newEntries[i].line.includes(endMarker)) { + // Parse exit code from the END sentinel line + const endLine = newEntries[i].line; + const exitMatch = endLine.match(new RegExp(`${endMarker}\\s+(\\d+)`)); + const exitCode = exitMatch ? parseInt(exitMatch[1], 10) : -1; + + // Extract output between START and END sentinels + const outputLines: string[] = []; + let capturing = false; + for (let j = 0; j < newEntries.length; j++) { + if (newEntries[j].line.includes(startMarker)) { + capturing = true; + continue; + } + if (newEntries[j].line.includes(endMarker)) { + break; + } + if (capturing) { + outputLines.push(newEntries[j].line); + } + } + + return { exitCode, output: outputLines.join("\n"), timedOut: false }; + } + } + + await new Promise(r => setTimeout(r, 100)); + } + + // Timed out + const newEntries = bg.output.slice(startIndex); + const outputLines: string[] = []; + let capturing = false; + for (const entry of newEntries) { + if (entry.line.includes(startMarker)) { + capturing = true; + continue; + } + if (capturing) { + outputLines.push(entry.line); + } + } + return { exitCode: -1, output: outputLines.join("\n") || "(no output)", timedOut: true }; +} + // ── Group Operations ─────────────────────────────────────────────────────── function getGroupProcesses(group: string): BgProcess[] { @@ -1192,6 +1278,7 @@ export default function (pi: ExtensionAPI) { "Actions: start (launch with auto-classification & readiness detection), digest (structured summary ~30 tokens vs ~2000 raw), " + "output (raw lines with incremental delivery), wait_for_ready (block until process signals readiness), " + "send (write stdin), send_and_wait (expect-style: send + wait for output pattern), " + + "run (execute a command on a persistent shell session, block until done, return output + exit code), " + "signal (send OS signal), list (all processes with status), kill (terminate), restart (kill + relaunch), " + "group_status (health of a process group), highlights (significant output lines only).", @@ -1204,6 +1291,7 @@ export default function (pi: ExtensionAPI) { "The 'output' action returns only new output since the last check (incremental). Repeated calls are cheap on context.", "Set type:'server' and ready_port:3000 for dev servers so readiness detection is automatic.", "Set group:'my-stack' on related processes to manage them together with 'group_status'.", + "Use 'run' to execute a command on a persistent shell session and block until it completes — returns structured output + exit code. Shell state (env vars, cwd, virtualenvs) persists across runs.", "Use 'send_and_wait' for interactive CLIs: send input and wait for expected output pattern.", "Use 'restart' to kill and relaunch with the same config — preserves restart count.", "Background processes are auto-classified (server/build/test/watcher) based on the command.", @@ -1220,6 +1308,7 @@ export default function (pi: ExtensionAPI) { "wait_for_ready", "send", "send_and_wait", + "run", "signal", "list", "kill", @@ -1227,13 +1316,13 @@ export default function (pi: ExtensionAPI) { "group_status", ] as const), command: Type.Optional( - Type.String({ description: "Shell command to run (for start)" }), + Type.String({ description: "Shell command to run (for start, run)" }), ), label: Type.Optional( Type.String({ description: "Short human-readable label for the process (for start)" }), ), id: Type.Optional( - Type.String({ description: "Process ID (for digest, output, highlights, wait_for_ready, send, send_and_wait, signal, kill, restart)" }), + Type.String({ description: "Process ID (for digest, output, highlights, wait_for_ready, send, send_and_wait, run, signal, kill, restart)" }), ), stream: Type.Optional( StringEnum(["stdout", "stderr", "both"] as const), @@ -1254,7 +1343,7 @@ export default function (pi: ExtensionAPI) { Type.String({ description: "OS signal to send, e.g. SIGINT, SIGTERM, SIGHUP (for signal)" }), ), timeout: Type.Optional( - Type.Number({ description: "Timeout in milliseconds (for wait_for_ready, send_and_wait). Default: 30000" }), + Type.Number({ description: "Timeout in milliseconds (for wait_for_ready, send_and_wait, run). Default: 30000 for wait_for_ready/send_and_wait, 120000 for run" }), ), type: Type.Optional( StringEnum(["server", "build", "test", "watcher", "generic", "shell"] as const), @@ -1581,6 +1670,52 @@ export default function (pi: ExtensionAPI) { }; } + // ── run ──────────────────────────────────────────── + case "run": { + if (!params.id) { + return { + content: [{ type: "text" as const, text: "Error: 'id' is required for run" }], + isError: true, details: undefined as unknown, + }; + } + if (!params.command) { + return { + content: [{ type: "text" as const, text: "Error: 'command' is required for run" }], + isError: true, details: undefined as unknown, + }; + } + + const bg = processes.get(params.id); + if (!bg) { + return { + content: [{ type: "text" as const, text: `Error: No process found with id '${params.id}'` }], + isError: true, details: undefined as unknown, + }; + } + + if (!bg.alive) { + return { + content: [{ type: "text" as const, text: `Error: Process ${params.id} has already exited` }], + isError: true, details: undefined as unknown, + }; + } + + const runTimeout = params.timeout || 120000; + const result = await runOnSession(bg, params.command, runTimeout, signal ?? undefined); + + let text: string; + if (result.timedOut) { + text = `Command timed out after ${runTimeout}ms\nOutput:\n${result.output}`; + } else { + text = `Exit code: ${result.exitCode}\n${result.output}`; + } + + return { + content: [{ type: "text" as const, text }], + details: { action: "run", process: getInfo(bg), exitCode: result.exitCode, timedOut: result.timedOut }, + }; + } + // ── signal ───────────────────────────────────────── case "signal": { if (!params.id) { @@ -1901,6 +2036,40 @@ export default function (pi: ExtensionAPI) { ); } + case "run": { + const proc = details.process as BgProcessInfo; + const exitCode = details.exitCode as number; + const timedOut = details.timedOut as boolean; + if (timedOut) { + let text = theme.fg("warning", "⏱ Timed out ") + theme.fg("accent", proc.id); + if (expanded) { + const rawText = result.content[0]; + if (rawText?.type === "text") { + const lines = rawText.text.split("\n").slice(1); + for (const line of lines.slice(0, 30)) { + text += "\n " + theme.fg("toolOutput", line); + } + } + } + return new Text(text, 0, 0); + } + const icon = exitCode === 0 ? theme.fg("success", "✓") : theme.fg("error", "✗"); + let text = `${icon} ${theme.fg("accent", proc.id)} ${theme.fg("dim", `exit:${exitCode}`)}`; + if (expanded) { + const rawText = result.content[0]; + if (rawText?.type === "text") { + const lines = rawText.text.split("\n").slice(1); + for (const line of lines.slice(0, 30)) { + text += "\n " + theme.fg("toolOutput", line); + } + if (lines.length > 30) { + text += `\n ${theme.fg("dim", `... ${lines.length - 30} more lines`)}`; + } + } + } + return new Text(text, 0, 0); + } + case "signal": { const sig = details.signal as string; const proc = details.process as BgProcessInfo;