feat: add run action to bg_shell for blocking command execution on persistent shells (#237)

Adds sentinel-based output demarcation to execute commands on existing
shell sessions, block until completion, and return structured output
with exit codes. Enables using bg-shell as a persistent execution
environment where shell state (env vars, cwd, virtualenvs) accumulates
across commands.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
TÂCHES 2026-03-13 13:02:11 -06:00 committed by GitHub
parent df1c2b5b54
commit aaa96e1d1a

View file

@ -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;