Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/IMPLEMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ src/
permission-plugin.ts Tiered operator approval (owns catastrophic shell deny)
shell/
run-shell-authz.ts Shared run_shell deny policy (gate-enforced)
background-shell.ts Background run_shell registry (start/collect/cancel/disposeAll)
background-shell.ts Background run_shell registry (start/cancel/disposeAll)
verify-plugin.ts Write/edit verification (per-path lock)
file-mutation-lock.ts Serialize mutations per file for verify
lsp-hint-plugin.ts TS/JS LSP setup hint on unavailable server
Expand Down Expand Up @@ -208,7 +208,7 @@ Listings are list-free, dumps are dump-locked: a bounded `ls`/`tree` prints name

#### Background shell mode

`run_shell` accepts `background: true` (shell-guard plugin, after the permission chain — a denied command spawns nothing). The starting call resolves `cwd` as usual and applies a per-call `timeout` only when passed (no 120s default on background), skips the pwd probe, spawns a detached process group via the registry in `src/shell/background-shell.ts`, and returns `{shell_id, status: "running"}` immediately; the retained shell cwd is never mutated by a background run. Limits: 8 running, 8 completed entries (ring; evicted ids collect as not-found — truncated output is spilled to a `tool-output:///bg-shell-<id>` blob named in the completion message). Completion is process-exit, not stdio-close: a grandchild holding the pipe does not delay the completion message. On process exit the host delivers `buildShellBackgroundMessage(exit)` (exit status, timed-out marker, ~2KB output preview, spill URI) through the same continuation channel as compaction — wired in all three loop hosts (TUI, exec, sub-agent). `run_shell` with `stop: <shell_id>` kills the process group; it is ungated by design (cancel only kills the session's own child), and a finished run needs no poll because its result is delivered on its own. Timeout keeps its meaning: expiry kills the group and reports exit code 124 with `timed_out: true`. The tool watchdog exempts background starts because the process outlives the turn. A worker whose capability filter drops `run_shell` has no background registry wired — background starts fail closed. Parent abort, deadline, and `close_agent` release parked registry waiters and dispose live background groups; `interrupt_agent` only releases waiters so the session (and its children) stay resumable. Toolset dispose calls `disposeAll("session closed")` before the posix teardown, so `/clear`, interrupt, and reload kill every live background process group.
`run_shell` accepts `background: true` (shell-guard plugin, after the permission chain — a denied command spawns nothing). The starting call resolves `cwd` as usual and applies a per-call `timeout` only when passed (no 120s default on background), skips the pwd probe, spawns a detached process group via the registry in `src/shell/background-shell.ts`, and returns `{shell_id, status: "running"}` immediately; the retained shell cwd is never mutated by a background run. Limit: 8 running (a ninth start fails closed; truncated output is spilled to a `tool-output:///bg-shell-<id>` blob named in the completion message). Completion is process-exit, not stdio-close: a grandchild holding the pipe does not delay the completion message. On process exit the host delivers `buildShellBackgroundMessage(exit)` (exit status, timed-out marker, ~2KB output preview, spill URI) through the same continuation channel as compaction — wired in all three loop hosts (TUI, exec, sub-agent). `run_shell` with `stop: <shell_id>` kills the process group; it is ungated by design (cancel only kills the session's own child), and a finished run needs no poll because its result is delivered on its own. Timeout keeps its meaning: expiry kills the group and reports exit code 124 with `timed_out: true`. The tool watchdog exempts background starts because the process outlives the turn. A worker whose capability filter drops `run_shell` has no background registry wired — background starts fail closed. Parent abort, deadline, and `close_agent` dispose live background groups; `interrupt_agent` leaves them so the session (and its children) stay resumable. Toolset dispose calls `disposeAll("session closed")` before the posix teardown, so `/clear`, interrupt, and reload kill every live background process group.

- **Alt+Enter** queues a follow-up (kind `"queue"`) delivered only on **session-idle** — parent-idle **and** no live fleet lanes (`run` goes idle). Session-idle Alt+Enter is a no-op. **Ctrl+C** stops the run.

Expand Down
23 changes: 13 additions & 10 deletions src/plugins/shell-guard-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { spawnSync, type ChildProcess } from "node:child_process";
import { randomUUID } from "node:crypto";

import { createBackgroundShellRegistry } from "../shell/background-shell.js";
import { createExitRecorder } from "../shell/background-shell-test-harness.js";
import { createShellOutputFeed } from "../session/shell-output-feed.js";

import {
Expand Down Expand Up @@ -267,7 +268,10 @@ describe("background run_shell (shellGuardPlugin)", () => {
}

test("background:true returns a running handle immediately", async () => {
const registry = createBackgroundShellRegistry();
const recorder = createExitRecorder();
const registry = createBackgroundShellRegistry({
onExit: recorder.onExit,
});
const start = Date.now();
const result = await runWith(registry, {
id: "bg1",
Expand All @@ -283,8 +287,7 @@ describe("background run_shell (shellGuardPlugin)", () => {
expect(parsed.shell_id.length).toBeGreaterThan(0);
// The START call resolved while the child was still sleeping.
expect(Date.now() - start).toBeLessThan(400);
const snapshot = await registry.collect(parsed.shell_id, 5_000);
expect(snapshot.state).toBe("completed");
await recorder.waitFor(parsed.shell_id);
registry.disposeAll("test done");
});

Expand Down Expand Up @@ -334,7 +337,10 @@ describe("background run_shell (shellGuardPlugin)", () => {
});

test("background omitted timeout skips the foreground default", async () => {
const registry = createBackgroundShellRegistry();
const recorder = createExitRecorder();
const registry = createBackgroundShellRegistry({
onExit: recorder.onExit,
});
const handler = defined(
shellGuardPlugin(process.cwd(), { defaultMs: 90 }, undefined, {
getBackgroundShellRegistry: () => registry,
Expand All @@ -353,12 +359,9 @@ describe("background run_shell (shellGuardPlugin)", () => {
);
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(String(result.content)) as { shell_id: string };
const snapshot = await registry.collect(parsed.shell_id, 5_000);
expect(snapshot.state).toBe("completed");
if (snapshot.state === "completed") {
expect(snapshot.exit.timedOut).toBe(false);
expect(snapshot.exit.output).toContain("survived");
}
const exit = await recorder.waitFor(parsed.shell_id);
expect(exit.timedOut).toBe(false);
expect(exit.output).toContain("survived");
registry.disposeAll("test done");
});

Expand Down
25 changes: 25 additions & 0 deletions src/shell/background-shell-test-harness.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { BackgroundShellExit } from "./background-shell.js";

/** Collects `onExit` deliveries so tests can await one shell's completion. */
export function createExitRecorder(): {
onExit: (exit: BackgroundShellExit) => void;
waitFor: (id: string, timeoutMs?: number) => Promise<BackgroundShellExit>;
} {
const exits = new Map<string, BackgroundShellExit>();
return {
onExit: (exit) => {
exits.set(exit.id, exit);
},
waitFor: async (id, timeoutMs = 5_000) => {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const exit = exits.get(id);
if (exit !== undefined) return exit;
await new Promise((resolve) => setTimeout(resolve, 20));
}
throw new Error(
`no exit delivered for shell ${id} within ${timeoutMs}ms`,
);
},
};
}
Loading
Loading