Skip to content

Commit 0ed36ed

Browse files
committed
feat(shell): background mode + shell_output/shell_kill + model nudge on exit
Fixes /model auto-reverting to Codebase Auto: buildProxiedConfig used to return null when pi-ai's local registry didn't recognize a provider+model combo (common for backend-only ids like "codebase:MiniMax-M2.7"). The proxy speaks openai-completions for every upstream so synthesizing an openai-compat model from a template + the user's chosen id works regardless of whether pi-ai has native cost/context data. Without this fallback the picker silently reverted to default on every restart. Background-shell MVP (the headline feature): - shell tool gains `background?: boolean` — when true, spawn detached and return a task_id immediately instead of blocking. - shell_output(task_id) — read accumulated stdout+stderr from a tracked shell. Status, elapsed time, byte count in the header. - shell_kill(task_id) — SIGTERM with 2s SIGKILL fallback. - BackgroundShellStore — per-bundle singleton tracking spawned shells. 64KB rolling buffer per shell (head-truncated when full). - BackgroundShellPanel — bottom-of-screen row per running/recently-exited shell with status, elapsed, command preview. - Model nudge on exit: when a backgrounded shell ends, App.tsx uses pi-agent-core's agent.steer() to inject a system-reminder if the agent is busy, or just appends a status line if idle. The model can call shell_output() to read the captured output without polling. - Process-exit cleanup: SIGTERMs all running shells via App.tsx's exit handler so children don't leak to the parent shell. What we deliberately skip from CC's design: auto-background heuristic (model can opt in explicitly with cleaner contract), monitor streaming (polling shell_output covers the use case), disallow lists (only needed because of auto-background). Net ~600 LOC vs CC's ~640 + extras.
1 parent 008a6ba commit 0ed36ed

13 files changed

Lines changed: 593 additions & 6 deletions

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "codebase-cli",
3-
"version": "2.0.0-pre.52",
3+
"version": "2.0.0-pre.53",
44
"description": "Codebase CLI — a TypeScript coding agent on the pi-mono runtime. OAuth-aware, any LLM provider, single install.",
55
"keywords": [
66
"ai",

src/agent/agent.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { MemoryStore } from "../memory/store.js";
1616
import { PermissionStore } from "../permissions/store.js";
1717
import { PlanModeStore } from "../plan/store.js";
1818
import { SessionStore } from "../sessions/store.js";
19+
import { BackgroundShellStore } from "../tools/background-shell-store.js";
1920
import { FileStateCache } from "../tools/file-state-cache.js";
2021
import { buildTools } from "../tools/registry.js";
2122
import { TaskStore } from "../tools/task-store.js";
@@ -110,6 +111,8 @@ export interface AgentBundle {
110111
* model's context. Empty when starting fresh.
111112
*/
112113
resumedMessages: AgentMessage[];
114+
/** Tracks long-running shells the agent spawned via background mode. */
115+
backgroundShells: BackgroundShellStore;
113116
}
114117

115118
export function createAgent(opts: CreateAgentOptions = {}): AgentBundle {
@@ -166,6 +169,7 @@ export function createAgent(opts: CreateAgentOptions = {}): AgentBundle {
166169
planMode,
167170
memory,
168171
hooks,
172+
backgroundShells: new BackgroundShellStore(),
169173
spawnSubagent: ({ systemPrompt: subPrompt, tools: subTools }) =>
170174
new Agent({
171175
initialState: { model, systemPrompt: subPrompt, tools: subTools },
@@ -360,5 +364,6 @@ export function createAgent(opts: CreateAgentOptions = {}): AgentBundle {
360364
subscribe,
361365
resumedFrom: resumed ? { updatedAt: resumed.updatedAt, messageCount: resumed.messages.length } : undefined,
362366
resumedMessages: opts.initialMessages ?? resumed?.messages ?? [],
367+
backgroundShells: toolContext.backgroundShells,
363368
};
364369
}

src/agent/config.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -209,9 +209,29 @@ function buildProxiedConfig(
209209
const modelId = explicitModel ?? DEFAULT_MODELS[explicitProvider];
210210
if (!modelId) return null;
211211
const baseModel = getModel(explicitProvider, modelId as never) as Model<string> | undefined;
212-
if (!baseModel) return null;
213-
const proxiedModel: Model<string> = { ...baseModel, baseUrl: proxyBase };
214-
return { model: proxiedModel, apiKey: accessToken, source: "proxy" };
212+
if (baseModel) {
213+
const proxiedModel: Model<string> = { ...baseModel, baseUrl: proxyBase };
214+
return { model: proxiedModel, apiKey: accessToken, source: "proxy" };
215+
}
216+
// pi-ai's registry doesn't know this provider+modelId combo (common for
217+
// backend-only models the proxy exposes, e.g. "codebase:MiniMax-M2.7"
218+
// or custom in-house ids). The proxy speaks openai-completions for
219+
// every upstream, so synthesizing an openai-compat model from a known
220+
// template + the user's chosen id works regardless of whether pi-ai
221+
// recognizes it locally. Without this fallback, /model would silently
222+
// revert to Codebase Auto whenever the user picked anything pi-ai
223+
// didn't have native cost / context-window data for.
224+
const template = getModel("groq", "llama-3.3-70b-versatile") as Model<string> | undefined;
225+
if (!template) return null;
226+
const synthesized: Model<string> = {
227+
...template,
228+
id: modelId,
229+
name: modelId,
230+
baseUrl: proxyBase,
231+
provider: explicitProvider as Model<string>["provider"],
232+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
233+
};
234+
return { model: synthesized, apiKey: accessToken, source: "proxy" };
215235
}
216236

217237
/**

src/tools/__test__/mock-tool-context.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import { MemoryStore } from "../../memory/store.js";
1111
import { PlanModeStore } from "../../plan/store.js";
1212
import { UserQueryStore } from "../../user-queries/store.js";
13+
import { BackgroundShellStore } from "../background-shell-store.js";
1314
import { FileStateCache } from "../file-state-cache.js";
1415
import { TaskStore } from "../task-store.js";
1516
import type { ToolContext } from "../types.js";
@@ -22,6 +23,7 @@ export function makeMockToolContext(cwd: string): ToolContext {
2223
userQueries: new UserQueryStore(),
2324
planMode: new PlanModeStore(),
2425
memory: new MemoryStore({ cwd }),
26+
backgroundShells: new BackgroundShellStore(),
2527
// spawnSubagent is the only field a test can't supply a real
2628
// implementation for (it depends on the live agent factory).
2729
// We throw to make the boundary explicit: any test that calls a
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
2+
import { BackgroundShellStore } from "./background-shell-store.js";
3+
4+
function wait(ms: number): Promise<void> {
5+
return new Promise((resolve) => setTimeout(resolve, ms));
6+
}
7+
8+
async function waitUntil<T>(check: () => T | undefined, timeoutMs = 2000): Promise<T> {
9+
const start = Date.now();
10+
for (;;) {
11+
const v = check();
12+
if (v !== undefined) return v;
13+
if (Date.now() - start > timeoutMs) throw new Error("waitUntil: timeout");
14+
await wait(15);
15+
}
16+
}
17+
18+
describe("BackgroundShellStore", () => {
19+
let store: BackgroundShellStore;
20+
21+
beforeEach(() => {
22+
store = new BackgroundShellStore();
23+
});
24+
25+
afterEach(() => {
26+
store.killAllSync();
27+
});
28+
29+
it("spawns a short-lived command, captures output, and marks exited", async () => {
30+
const record = store.spawn("echo hello world", process.cwd());
31+
expect(record.status).toBe("running");
32+
expect(record.id).toMatch(/^bg-\d+$/);
33+
const final = await waitUntil(() => {
34+
const cur = store.get(record.id);
35+
return cur && cur.status !== "running" ? cur : undefined;
36+
});
37+
expect(final.status).toBe("exited");
38+
expect(final.exitCode).toBe(0);
39+
expect(final.output).toContain("hello world");
40+
});
41+
42+
it("captures stderr alongside stdout", async () => {
43+
const record = store.spawn("printf 'err\\n' 1>&2; printf 'out\\n'", process.cwd());
44+
const final = await waitUntil(() => {
45+
const cur = store.get(record.id);
46+
return cur && cur.status !== "running" ? cur : undefined;
47+
});
48+
expect(final.output).toContain("err");
49+
expect(final.output).toContain("out");
50+
});
51+
52+
it("kill() terminates a long-running shell and marks it killed", async () => {
53+
const record = store.spawn("sleep 30", process.cwd());
54+
expect(store.get(record.id)?.status).toBe("running");
55+
await store.kill(record.id);
56+
const final = store.get(record.id);
57+
expect(final?.status).toBe("killed");
58+
expect(final?.endedAt).toBeGreaterThan(0);
59+
});
60+
61+
it("kill() on an unknown id is a no-op", async () => {
62+
await expect(store.kill("does-not-exist")).resolves.toBeUndefined();
63+
});
64+
65+
it("kill() on an already-exited shell is a no-op", async () => {
66+
const record = store.spawn("true", process.cwd());
67+
await waitUntil(() => (store.get(record.id)?.status !== "running" ? true : undefined));
68+
await expect(store.kill(record.id)).resolves.toBeUndefined();
69+
});
70+
71+
it("killAllSync() SIGTERMs every running shell", async () => {
72+
const a = store.spawn("sleep 30", process.cwd());
73+
const b = store.spawn("sleep 30", process.cwd());
74+
store.killAllSync();
75+
await waitUntil(() => {
76+
const ra = store.get(a.id);
77+
const rb = store.get(b.id);
78+
return ra && rb && ra.status !== "running" && rb.status !== "running" ? true : undefined;
79+
});
80+
expect(store.get(a.id)?.status).toBe("killed");
81+
expect(store.get(b.id)?.status).toBe("killed");
82+
});
83+
84+
it("subscribe() fires on spawn, output growth, and exit", async () => {
85+
const events: number[] = [];
86+
const unsubscribe = store.subscribe((shells) => events.push(shells.length));
87+
const a = store.spawn("echo a", process.cwd());
88+
await waitUntil(() => (store.get(a.id)?.status !== "running" ? true : undefined));
89+
unsubscribe();
90+
// First event is the initial snapshot (empty); then spawn → output → exit
91+
expect(events[0]).toBe(0);
92+
expect(events.length).toBeGreaterThan(1);
93+
});
94+
95+
it("assigns unique sequential ids", () => {
96+
const a = store.spawn("true", process.cwd());
97+
const b = store.spawn("true", process.cwd());
98+
expect(a.id).not.toBe(b.id);
99+
});
100+
101+
it("list() returns oldest-first", () => {
102+
const a = store.spawn("true", process.cwd());
103+
const b = store.spawn("true", process.cwd());
104+
const ids = store.list().map((r) => r.id);
105+
expect(ids).toEqual([a.id, b.id]);
106+
});
107+
108+
it("returns copies from get() / list() so callers can't mutate live state", () => {
109+
const record = store.spawn("true", process.cwd());
110+
const copy = store.get(record.id);
111+
// biome-ignore lint/style/noNonNullAssertion: just spawned, definitely present
112+
copy!.output = "tampered";
113+
expect(store.get(record.id)?.output).not.toBe("tampered");
114+
});
115+
});
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
import { type ChildProcess, spawn } from "node:child_process";
2+
3+
const MAX_BUFFER_BYTES = 64 * 1024; // 64KB rolling buffer per shell
4+
5+
export interface BackgroundShellRecord {
6+
id: string;
7+
command: string;
8+
cwd: string;
9+
startedAt: number;
10+
endedAt?: number;
11+
status: "running" | "exited" | "killed";
12+
exitCode?: number;
13+
signal?: NodeJS.Signals;
14+
/** Captured stdout+stderr, head-truncated when past MAX_BUFFER_BYTES. */
15+
output: string;
16+
/** Total bytes ever emitted (across head-truncated history) — lets callers detect truncation. */
17+
bytesEmitted: number;
18+
}
19+
20+
export type BackgroundShellListener = (shells: readonly BackgroundShellRecord[]) => void;
21+
22+
/**
23+
* Tracks long-running shell processes that the agent spawned with
24+
* `shell({ background: true })`. The agent's tool turn returns
25+
* immediately with a `task_id`; the process keeps running in the
26+
* background, output streams into a rolling buffer here, and the
27+
* agent can poll via `shell_output` or terminate via `shell_kill`.
28+
*
29+
* Cleanup invariant: every spawned process is SIGTERM'd on app exit
30+
* via terminal-restore so we don't leak children when the CLI quits.
31+
*/
32+
export class BackgroundShellStore {
33+
private records = new Map<string, BackgroundShellRecord>();
34+
private processes = new Map<string, ChildProcess>();
35+
private readonly listeners = new Set<BackgroundShellListener>();
36+
private nextId = 1;
37+
38+
/**
39+
* Spawn a detached shell. Returns the new record immediately —
40+
* caller doesn't wait for the process to do anything. The shell
41+
* runs via `sh -c` so the command string can contain pipes /
42+
* redirects / etc., the same surface a normal `shell` tool call has.
43+
*/
44+
spawn(command: string, cwd: string): BackgroundShellRecord {
45+
const id = `bg-${this.nextId++}`;
46+
const record: BackgroundShellRecord = {
47+
id,
48+
command,
49+
cwd,
50+
startedAt: Date.now(),
51+
status: "running",
52+
output: "",
53+
bytesEmitted: 0,
54+
};
55+
const child = spawn(command, {
56+
shell: true,
57+
cwd,
58+
env: process.env,
59+
detached: false, // we want to track and kill on exit, not orphan
60+
stdio: ["ignore", "pipe", "pipe"],
61+
});
62+
63+
const onChunk = (chunk: Buffer): void => {
64+
const text = chunk.toString("utf8");
65+
record.bytesEmitted += chunk.byteLength;
66+
record.output += text;
67+
// Trim head when the rolling buffer exceeds the cap. Lose the
68+
// oldest output, keep the most recent — that's almost always
69+
// what's relevant (errors and recent state).
70+
if (record.output.length > MAX_BUFFER_BYTES) {
71+
record.output = record.output.slice(record.output.length - MAX_BUFFER_BYTES);
72+
}
73+
this.notify();
74+
};
75+
child.stdout?.on("data", onChunk);
76+
child.stderr?.on("data", onChunk);
77+
child.on("exit", (code, signal) => {
78+
record.endedAt = Date.now();
79+
record.status = signal === "SIGTERM" || signal === "SIGKILL" ? "killed" : "exited";
80+
record.exitCode = code ?? undefined;
81+
record.signal = signal ?? undefined;
82+
this.processes.delete(id);
83+
this.notify();
84+
});
85+
child.on("error", (err) => {
86+
record.output += `\n[spawn error: ${err.message}]\n`;
87+
record.endedAt = Date.now();
88+
record.status = "exited";
89+
record.exitCode = -1;
90+
this.processes.delete(id);
91+
this.notify();
92+
});
93+
94+
this.records.set(id, record);
95+
this.processes.set(id, child);
96+
this.notify();
97+
return { ...record };
98+
}
99+
100+
/**
101+
* Read the current state of a tracked shell. Returns a copy so
102+
* callers can't mutate the live record. Returns undefined for
103+
* unknown ids.
104+
*/
105+
get(id: string): BackgroundShellRecord | undefined {
106+
const record = this.records.get(id);
107+
return record ? { ...record } : undefined;
108+
}
109+
110+
/** All known shells, oldest-first. Returns copies. */
111+
list(): readonly BackgroundShellRecord[] {
112+
return Array.from(this.records.values()).map((r) => ({ ...r }));
113+
}
114+
115+
/**
116+
* Terminate a running shell. SIGTERM first; if the process is still
117+
* around after `gracePeriodMs`, SIGKILL. Resolves once the exit
118+
* handler has fired. No-op if the id is unknown or already exited.
119+
*/
120+
async kill(id: string, gracePeriodMs = 2000): Promise<void> {
121+
const child = this.processes.get(id);
122+
if (!child) return;
123+
const record = this.records.get(id);
124+
if (!record || record.status !== "running") return;
125+
return new Promise<void>((resolve) => {
126+
const onExit = () => {
127+
clearTimeout(killTimer);
128+
resolve();
129+
};
130+
child.once("exit", onExit);
131+
try {
132+
child.kill("SIGTERM");
133+
} catch {
134+
// Process already gone; the exit handler may have fired
135+
// or be about to. Resolve once child emits or after the
136+
// timer trips.
137+
}
138+
const killTimer = setTimeout(() => {
139+
try {
140+
child.kill("SIGKILL");
141+
} catch {
142+
// Same — best effort.
143+
}
144+
}, gracePeriodMs);
145+
});
146+
}
147+
148+
/**
149+
* SIGTERM all running shells. Used by terminal-restore on app exit
150+
* so we don't leak children to the parent shell. Doesn't wait for
151+
* graceful exits — the parent process is already going down.
152+
*/
153+
killAllSync(): void {
154+
for (const [_id, child] of this.processes) {
155+
try {
156+
child.kill("SIGTERM");
157+
} catch {
158+
// Best effort during shutdown.
159+
}
160+
}
161+
}
162+
163+
/** Subscribe to mutation events. Returns an unsubscribe function. */
164+
subscribe(listener: BackgroundShellListener): () => void {
165+
this.listeners.add(listener);
166+
listener(this.list());
167+
return () => {
168+
this.listeners.delete(listener);
169+
};
170+
}
171+
172+
private notify(): void {
173+
const snapshot = this.list();
174+
for (const fn of this.listeners) fn(snapshot);
175+
}
176+
}

src/tools/registry.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import { createNotebookEdit } from "./notebook-edit.js";
1818
import { createPlanModeTools } from "./plan-mode.js";
1919
import { createReadFile } from "./read-file.js";
2020
import { createShell } from "./shell.js";
21+
import { createShellKill } from "./shell-kill.js";
22+
import { createShellOutput } from "./shell-output.js";
2123
import { createTaskTools } from "./tasks.js";
2224
import type { ToolContext } from "./types.js";
2325
import { createWebFetch } from "./web-fetch.js";
@@ -36,6 +38,8 @@ export function buildTools(ctx: ToolContext): AgentTool<any>[] {
3638
createNotebookEdit(ctx),
3739
createWriteFile(ctx),
3840
createShell(ctx),
41+
createShellOutput(ctx),
42+
createShellKill(ctx),
3943
createListFiles(ctx),
4044
createGlob(ctx),
4145
createGrep(ctx),

0 commit comments

Comments
 (0)