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:
parent
df1c2b5b54
commit
aaa96e1d1a
1 changed files with 173 additions and 4 deletions
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue