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) <noreply@anthropic.com>
This commit is contained in:
parent
df39cea85e
commit
6acd001a59
1 changed files with 142 additions and 0 deletions
|
|
@ -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<string, string>; 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<string, string> = {};
|
||||
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<typeof getGroupStatus> | undefined;
|
||||
if (gs) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue