Skip to content

Commit b5ef811

Browse files
committed
feat(monitor): push-style line monitor over background shells
Lets the agent attach a regex monitor to a background shell and get notified per match instead of polling shell_output. Pattern: shell({background:true, command:"tail -f logs/app.log"}) → "bg-1" monitor({task_id:"bg-1", match:"ERROR|FATAL", max_matches:5}) From then on, matching lines steer system-reminders into the conversation as they arrive. Auto-cleans when the shell exits or max_matches is reached; monitor_stop unregisters early. Components: - BackgroundShellStore.subscribeOutput(id, fn) — new hook fires per chunk with text. Auto-removes the subscriber when the shell exits. - src/tools/monitor-store.ts — MonitorRegistry that holds active monitors, line-buffers chunks across writes, applies regex, and emits MonitorMatchEvent. Auto-cleanup on watched-shell exit. - src/tools/monitor.ts + monitor-stop.ts — the agent-facing tools. - src/ui/App.tsx — subscribes to MonitorStore.onMatch and either steers a system-reminder (if agent busy) or appendStatus (if idle), same pattern as the existing bg-shell exit notifier. - ToolContext gains `monitors: MonitorStore`; threaded through agent.ts. Mock-tool-context updated. 7 new tests (registry behavior across regex, max_matches, exit auto-clean, partial-line buffering, listener-error isolation). 781 total passing.
1 parent 5463fdc commit b5ef811

13 files changed

Lines changed: 580 additions & 5 deletions

CLAUDE.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,50 @@ boundaries on the remote.
353353
`name` must match `[a-z0-9][a-z0-9_-]*`. `host` rejects anything
354354
that looks like `user@host:port` syntax — use separate fields.
355355

356+
## Background shells + monitors
357+
358+
Long-running commands (dev servers, log tails, build watchers) go in
359+
the background so the agent doesn't block on them. Two flavors:
360+
361+
### Background shell
362+
363+
```ts
364+
shell({ background: true, command: "npm run dev" })
365+
// → returns task_id "bg-3" immediately
366+
shell_output({ task_id: "bg-3" }) // poll buffered output
367+
shell_kill({ task_id: "bg-3" }) // terminate
368+
```
369+
370+
The agent gets notified automatically when a background shell exits —
371+
no need to poll for completion.
372+
373+
### Monitor (push-style line notifications)
374+
375+
The agent can ATTACH a monitor to a running background shell to be
376+
notified as matching lines arrive, instead of polling. Use this for
377+
"watch the log for ERROR" or "tell me the first time the server
378+
prints 'Listening on'."
379+
380+
```ts
381+
shell({ background: true, command: "tail -f logs/app.log" })
382+
// → "bg-1"
383+
monitor({
384+
task_id: "bg-1",
385+
match: "ERROR|FATAL", // regex; default = match every line
386+
flags: "i", // default; "" for case-sensitive
387+
max_matches: 5, // auto-stop after N (optional)
388+
note: "watching app errors" // free-form hint
389+
})
390+
// → "mon-1". Now any matching line steers a system-reminder
391+
// into the agent mid-conversation.
392+
393+
monitor_stop({ monitor_id: "mon-1" }) // unregister early
394+
```
395+
396+
Monitors auto-clean when the watched shell exits or when `max_matches`
397+
is reached. The background shell itself keeps running until
398+
`shell_kill``monitor_stop` only unsubscribes from notifications.
399+
356400
## In-flight features
357401

358402
- **MCP**: real MCP client support hasn't shipped (the `/mcp`

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

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.60",
3+
"version": "2.0.0-pre.61",
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: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import type { AssetRegistry } from "../skills/loader.js";
2020
import { buildAssetRegistry } from "../skills/registry-factory.js";
2121
import { BackgroundShellStore } from "../tools/background-shell-store.js";
2222
import { FileStateCache } from "../tools/file-state-cache.js";
23+
import { MonitorStore } from "../tools/monitor-store.js";
2324
import { buildTools } from "../tools/registry.js";
2425
import { TaskStore } from "../tools/task-store.js";
2526
import type { ToolContext } from "../tools/types.js";
@@ -128,6 +129,9 @@ export interface AgentBundle {
128129
resumedMessages: AgentMessage[];
129130
/** Tracks long-running shells the agent spawned via background mode. */
130131
backgroundShells: BackgroundShellStore;
132+
/** Push-style line monitors over background shells. App subscribes
133+
* here and steers matched lines into the agent as system-reminders. */
134+
monitors: MonitorStore;
131135
}
132136

133137
export function createAgent(opts: CreateAgentOptions = {}): AgentBundle {
@@ -184,6 +188,8 @@ export function createAgent(opts: CreateAgentOptions = {}): AgentBundle {
184188
const sessions = new SessionStore({ cwd });
185189
const resumed = opts.resume ? sessions.load(model.id) : null;
186190

191+
const backgroundShells = new BackgroundShellStore();
192+
const monitors = new MonitorStore(backgroundShells);
187193
const toolContext: ToolContext = {
188194
cwd,
189195
fileStateCache: new FileStateCache(),
@@ -192,7 +198,8 @@ export function createAgent(opts: CreateAgentOptions = {}): AgentBundle {
192198
planMode,
193199
memory,
194200
hooks,
195-
backgroundShells: new BackgroundShellStore(),
201+
backgroundShells,
202+
monitors,
196203
spawnSubagent: ({ systemPrompt: subPrompt, tools: subTools }) =>
197204
new Agent({
198205
initialState: { model, systemPrompt: subPrompt, tools: subTools },
@@ -452,6 +459,7 @@ export function createAgent(opts: CreateAgentOptions = {}): AgentBundle {
452459
resumedFrom: resumed ? { updatedAt: resumed.updatedAt, messageCount: resumed.messages.length } : undefined,
453460
resumedMessages: opts.initialMessages ?? resumed?.messages ?? [],
454461
backgroundShells: toolContext.backgroundShells,
462+
monitors: toolContext.monitors,
455463
};
456464
}
457465

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,18 +12,21 @@ import { PlanModeStore } from "../../plan/store.js";
1212
import { UserQueryStore } from "../../user-queries/store.js";
1313
import { BackgroundShellStore } from "../background-shell-store.js";
1414
import { FileStateCache } from "../file-state-cache.js";
15+
import { MonitorStore } from "../monitor-store.js";
1516
import { TaskStore } from "../task-store.js";
1617
import type { ToolContext } from "../types.js";
1718

1819
export function makeMockToolContext(cwd: string): ToolContext {
20+
const backgroundShells = new BackgroundShellStore();
1921
return {
2022
cwd,
2123
fileStateCache: new FileStateCache(),
2224
tasks: new TaskStore(),
2325
userQueries: new UserQueryStore(),
2426
planMode: new PlanModeStore(),
2527
memory: new MemoryStore({ cwd }),
26-
backgroundShells: new BackgroundShellStore(),
28+
backgroundShells,
29+
monitors: new MonitorStore(backgroundShells),
2730
// spawnSubagent is the only field a test can't supply a real
2831
// implementation for (it depends on the live agent factory).
2932
// We throw to make the boundary explicit: any test that calls a

src/tools/background-shell-store.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export interface BackgroundShellRecord {
1818
}
1919

2020
export type BackgroundShellListener = (shells: readonly BackgroundShellRecord[]) => void;
21+
export type BackgroundShellOutputListener = (text: string) => void;
2122

2223
/**
2324
* Result returned by `BackgroundShellStore.kill`. The caller needs to know
@@ -45,6 +46,9 @@ export class BackgroundShellStore {
4546
private records = new Map<string, BackgroundShellRecord>();
4647
private processes = new Map<string, ChildProcess>();
4748
private readonly listeners = new Set<BackgroundShellListener>();
49+
/** Per-shell-id output subscribers. Used by the monitor tool so the
50+
* agent can react to incoming lines without polling shell_output. */
51+
private readonly outputListeners = new Map<string, Set<BackgroundShellOutputListener>>();
4852
private nextId = 1;
4953

5054
/**
@@ -83,6 +87,7 @@ export class BackgroundShellStore {
8387
record.output = record.output.slice(record.output.length - MAX_BUFFER_BYTES);
8488
}
8589
this.notify();
90+
this.notifyOutput(id, text);
8691
};
8792
child.stdout?.on("data", onChunk);
8893
child.stderr?.on("data", onChunk);
@@ -92,6 +97,8 @@ export class BackgroundShellStore {
9297
record.exitCode = code ?? undefined;
9398
record.signal = signal ?? undefined;
9499
this.processes.delete(id);
100+
// Drop output subscribers now that there'll be no more output.
101+
this.outputListeners.delete(id);
95102
this.notify();
96103
});
97104
child.on("error", (err) => {
@@ -200,8 +207,45 @@ export class BackgroundShellStore {
200207
};
201208
}
202209

210+
/**
211+
* Subscribe to raw output chunks from a specific shell. The text
212+
* passed to the listener is exactly what was just appended to the
213+
* record's output buffer — caller does its own line-splitting +
214+
* matching (the monitor tool does regex matching on top).
215+
*
216+
* Returns a no-op unsubscribe if `id` is unknown or already exited;
217+
* the subscription is auto-removed when the shell exits.
218+
*/
219+
subscribeOutput(id: string, listener: BackgroundShellOutputListener): () => void {
220+
let set = this.outputListeners.get(id);
221+
if (!set) {
222+
set = new Set();
223+
this.outputListeners.set(id, set);
224+
}
225+
set.add(listener);
226+
return () => {
227+
const current = this.outputListeners.get(id);
228+
if (!current) return;
229+
current.delete(listener);
230+
if (current.size === 0) this.outputListeners.delete(id);
231+
};
232+
}
233+
203234
private notify(): void {
204235
const snapshot = this.list();
205236
for (const fn of this.listeners) fn(snapshot);
206237
}
238+
239+
private notifyOutput(id: string, text: string): void {
240+
const set = this.outputListeners.get(id);
241+
if (!set) return;
242+
for (const fn of set) {
243+
try {
244+
fn(text);
245+
} catch {
246+
// A misbehaving subscriber shouldn't take down the whole
247+
// stream of other subscribers — best-effort.
248+
}
249+
}
250+
}
207251
}

src/tools/monitor-stop.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import type { AgentTool } from "@earendil-works/pi-agent-core";
2+
import { type Static, Type } from "typebox";
3+
import type { ToolContext } from "./types.js";
4+
5+
const Params = Type.Object({
6+
monitor_id: Type.String({
7+
description: 'Monitor id from a prior `monitor(...)` call (e.g. "mon-3").',
8+
}),
9+
});
10+
11+
export type MonitorStopParams = Static<typeof Params>;
12+
13+
const DESCRIPTION = `Unregister a previously-registered monitor. Use this when you've gotten
14+
what you needed from a long-running monitor and want to silence further
15+
notifications. The watched background shell keeps running — only the
16+
notification subscription is removed. (Use shell_kill to stop the shell
17+
itself.)`;
18+
19+
export function createMonitorStop(ctx: ToolContext): AgentTool<typeof Params> {
20+
return {
21+
name: "monitor_stop",
22+
label: "Stop monitor",
23+
description: DESCRIPTION,
24+
parameters: Params,
25+
executionMode: "parallel",
26+
execute: async (_toolCallId, params) => {
27+
const removed = ctx.monitors.remove(params.monitor_id);
28+
if (!removed) {
29+
return {
30+
details: undefined,
31+
content: [
32+
{
33+
type: "text",
34+
text: `No active monitor with id "${params.monitor_id}". It may have already auto-stopped (target shell exited or max_matches reached).`,
35+
},
36+
],
37+
};
38+
}
39+
return {
40+
details: undefined,
41+
content: [{ type: "text", text: `Unregistered monitor ${params.monitor_id}.` }],
42+
};
43+
},
44+
};
45+
}

src/tools/monitor-store.test.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2+
import { BackgroundShellStore } from "./background-shell-store.js";
3+
import { type MonitorMatchEvent, MonitorStore } from "./monitor-store.js";
4+
5+
async function wait(ms: number): Promise<void> {
6+
return new Promise((resolve) => setTimeout(resolve, ms));
7+
}
8+
9+
async function waitUntil<T>(fn: () => T | undefined, timeoutMs = 4000): Promise<T> {
10+
const deadline = Date.now() + timeoutMs;
11+
while (Date.now() < deadline) {
12+
const v = fn();
13+
if (v !== undefined) return v;
14+
await wait(20);
15+
}
16+
throw new Error("waitUntil timed out");
17+
}
18+
19+
describe("MonitorStore", () => {
20+
let bg: BackgroundShellStore;
21+
let monitors: MonitorStore;
22+
let captured: MonitorMatchEvent[];
23+
let unsub: () => void;
24+
25+
beforeEach(() => {
26+
bg = new BackgroundShellStore();
27+
monitors = new MonitorStore(bg);
28+
captured = [];
29+
unsub = monitors.onMatch((e) => captured.push(e));
30+
});
31+
32+
afterEach(() => {
33+
unsub();
34+
bg.killAllSync();
35+
});
36+
37+
it("emits a match event for every line when no regex is set", async () => {
38+
const record = bg.spawn("printf 'one\\ntwo\\nthree\\n'", process.cwd());
39+
monitors.register({ taskId: record.id });
40+
await waitUntil(() => (captured.length >= 3 ? captured : undefined));
41+
expect(captured.map((e) => e.line)).toEqual(["one", "two", "three"]);
42+
});
43+
44+
it("filters lines by regex", async () => {
45+
const record = bg.spawn("printf 'info: ok\\nERROR: bad\\ninfo: ok\\nFATAL: dead\\n'", process.cwd());
46+
monitors.register({ taskId: record.id, regex: /ERROR|FATAL/ });
47+
await waitUntil(() => (captured.length >= 2 ? captured : undefined));
48+
expect(captured.map((e) => e.line)).toEqual(["ERROR: bad", "FATAL: dead"]);
49+
});
50+
51+
it("auto-unregisters after maxMatches is reached", async () => {
52+
const record = bg.spawn("printf 'one\\ntwo\\nthree\\nfour\\n'", process.cwd());
53+
const mon = monitors.register({ taskId: record.id, maxMatches: 2 });
54+
await waitUntil(() => (captured.length >= 2 ? captured : undefined));
55+
await wait(100);
56+
expect(captured.length).toBe(2);
57+
expect(monitors.get(mon.id)).toBeUndefined();
58+
});
59+
60+
it("auto-cleans the monitor when the watched shell exits", async () => {
61+
const record = bg.spawn("printf 'hi\\n' && true", process.cwd());
62+
const mon = monitors.register({ taskId: record.id });
63+
// Wait until the shell exits.
64+
await waitUntil(() => (bg.get(record.id)?.status === "exited" ? true : undefined));
65+
// The subscription is dropped synchronously in the BgShell exit
66+
// handler; the MonitorStore subscriber sees the status flip and
67+
// removes the monitor. Give one event-loop tick to settle.
68+
await wait(20);
69+
expect(monitors.get(mon.id)).toBeUndefined();
70+
});
71+
72+
it("remove() returns true the first time, false on a repeat", async () => {
73+
const record = bg.spawn("sleep 1", process.cwd());
74+
const mon = monitors.register({ taskId: record.id });
75+
expect(monitors.remove(mon.id)).toBe(true);
76+
expect(monitors.remove(mon.id)).toBe(false);
77+
});
78+
79+
it("buffers a partial-line tail across chunks", async () => {
80+
// We can't easily split chunks at the test level — printf delivers
81+
// in one chunk on this platform. Verify behavior by registering
82+
// after some output has already been buffered by hand-feeding the
83+
// subscriber: directly exercise consumeChunk via a fake by spawning
84+
// a process that emits two writes (sleep between them).
85+
const record = bg.spawn("printf 'partial-' && sleep 0.05 && printf 'line\\ndone\\n'", process.cwd());
86+
monitors.register({ taskId: record.id });
87+
await waitUntil(() => (captured.length >= 2 ? captured : undefined));
88+
expect(captured.map((e) => e.line)).toEqual(["partial-line", "done"]);
89+
});
90+
91+
it("a misbehaving listener doesn't break the others", async () => {
92+
const record = bg.spawn("printf 'x\\ny\\n'", process.cwd());
93+
const noisy = vi.fn(() => {
94+
throw new Error("boom");
95+
});
96+
monitors.onMatch(noisy);
97+
monitors.register({ taskId: record.id });
98+
await waitUntil(() => (captured.length >= 2 ? captured : undefined));
99+
expect(noisy).toHaveBeenCalled();
100+
expect(captured.length).toBe(2);
101+
});
102+
});

0 commit comments

Comments
 (0)