From 6acd001a59638f802de3344963fcd22f090e5494 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=82CHES?= Date: Fri, 13 Mar 2026 13:05:18 -0600 Subject: [PATCH] feat(bg-shell): add env action to query shell session state (#238) Enables querying the current working directory and environment variables of a persistent shell session. Sends introspection commands to the shell's stdin, captures output via sentinel-demarcated blocks, and parses key environment variables. Useful for understanding accumulated shell state after cd, source, or export commands. Co-authored-by: Claude Opus 4.6 (1M context) --- src/resources/extensions/bg-shell/index.ts | 142 +++++++++++++++++++++ 1 file changed, 142 insertions(+) diff --git a/src/resources/extensions/bg-shell/index.ts b/src/resources/extensions/bg-shell/index.ts index 7d0f76ec8..050cc3ff2 100644 --- a/src/resources/extensions/bg-shell/index.ts +++ b/src/resources/extensions/bg-shell/index.ts @@ -890,6 +890,73 @@ async function waitForReady(bg: BgProcess, timeout: number, signal?: AbortSignal return { ready: false, detail: `Timed out after ${timeout}ms waiting for ready signal` }; } +// ── Query Shell Environment ──────────────────────────────────────────────── + +async function queryShellEnv( + bg: BgProcess, + timeout: number, + signal?: AbortSignal, +): Promise<{ cwd: string; env: Record; shell: string } | null> { + const sentinel = `__GSD_ENV_${randomUUID().slice(0, 8)}__`; + const startIndex = bg.output.length; + + const cmd = [ + `echo "${sentinel}_START"`, + `echo "CWD=$(pwd)"`, + `echo "SHELL=$SHELL"`, + `echo "PATH=$PATH"`, + `echo "VIRTUAL_ENV=$VIRTUAL_ENV"`, + `echo "NODE_ENV=$NODE_ENV"`, + `echo "HOME=$HOME"`, + `echo "USER=$USER"`, + `echo "NVM_DIR=$NVM_DIR"`, + `echo "GOPATH=$GOPATH"`, + `echo "CARGO_HOME=$CARGO_HOME"`, + `echo "PYTHONPATH=$PYTHONPATH"`, + `echo "${sentinel}_END"`, + ].join(" && "); + + bg.proc.stdin?.write(cmd + "\n"); + + const start = Date.now(); + while (Date.now() - start < timeout) { + if (signal?.aborted) return null; + if (!bg.alive) return null; + + const newEntries = bg.output.slice(startIndex); + const endIdx = newEntries.findIndex(e => e.line.includes(`${sentinel}_END`)); + if (endIdx >= 0) { + const startIdx = newEntries.findIndex(e => e.line.includes(`${sentinel}_START`)); + if (startIdx >= 0) { + const envLines = newEntries.slice(startIdx + 1, endIdx); + const env: Record = {}; + let cwd = ""; + let shell = ""; + + for (const entry of envLines) { + const match = entry.line.match(/^([A-Z_]+)=(.*)$/); + if (match) { + const [, key, value] = match; + if (key === "CWD") { + cwd = value; + } else if (key === "SHELL") { + shell = value; + } else if (value) { + env[key] = value; + } + } + } + + return { cwd, env, shell }; + } + } + + await new Promise(r => setTimeout(r, 100)); + } + + return null; +} + // ── Send and Wait ────────────────────────────────────────────────────────── async function sendAndWait( @@ -1279,6 +1346,7 @@ export default function (pi: ExtensionAPI) { "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), " + + "env (query shell cwd and environment variables), " + "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).", @@ -1293,6 +1361,7 @@ export default function (pi: ExtensionAPI) { "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 'env' to check the current working directory and active environment variables of a shell session — useful after cd, source, or export commands.", "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.", "Process crashes and errors are automatically surfaced as alerts at the start of your next turn — you don't need to poll.", @@ -1309,6 +1378,7 @@ export default function (pi: ExtensionAPI) { "send", "send_and_wait", "run", + "env", "signal", "list", "kill", @@ -1716,6 +1786,59 @@ export default function (pi: ExtensionAPI) { }; } + // ── env ─────────────────────────────────────────── + case "env": { + if (!params.id) { + return { + content: [{ type: "text" as const, text: "Error: 'id' is required for env" }], + 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 timeout = params.timeout || 5000; + const envResult = await queryShellEnv(bg, timeout, signal ?? undefined); + + if (!envResult) { + return { + content: [{ type: "text" as const, text: `Failed to query environment for process ${bg.id} (timed out or process died)` }], + isError: true, details: undefined as unknown, + }; + } + + let text = `Shell environment for ${bg.id} (${bg.label}):\n`; + text += ` cwd: ${envResult.cwd}\n`; + text += ` shell: ${envResult.shell}\n`; + + const envEntries = Object.entries(envResult.env); + if (envEntries.length > 0) { + text += ` environment:\n`; + for (const [key, value] of envEntries) { + const displayValue = value.length > 100 ? value.slice(0, 97) + "..." : value; + text += ` ${key}=${displayValue}\n`; + } + } + + return { + content: [{ type: "text" as const, text: text.trimEnd() }], + details: { action: "env", process: getInfo(bg), env: envResult }, + }; + } + // ── signal ───────────────────────────────────────── case "signal": { if (!params.id) { @@ -2113,6 +2236,25 @@ export default function (pi: ExtensionAPI) { ); } + case "env": { + const proc = details.process as BgProcessInfo; + const envData = details.env as { cwd: string; shell: string } | undefined; + let text = theme.fg("accent", proc.id) + " " + theme.fg("muted", proc.label); + if (envData) { + text += " " + theme.fg("dim", `cwd: ${envData.cwd}`); + } + 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, 15)) { + text += "\n " + theme.fg("dim", line); + } + } + } + return new Text(text, 0, 0); + } + case "group_status": { const gs = details.groupStatus as ReturnType | undefined; if (gs) {