Skip to content

Commit df5e33a

Browse files
committed
Flush live shell tails on a timer and isolate per-call feeds
A rate-limited second write could sit until the next chunk or settle, and a shared feed mixed parallel run_shell tails. Hydrate and rollback also dropped older member ids and truncated call snapshots.
1 parent dbfc613 commit df5e33a

16 files changed

Lines changed: 338 additions & 79 deletions

docs/TUI.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ The past tense is a map keyed by raw tool name (`src/tui/tool-formatter.ts`:
100100
name.
101101

102102
Because a lane's row identity moves to the newest call, a lane also carries
103-
the call ids it absorbed (`memberIds`, newest appended, last 32 kept). A
103+
the call ids it absorbed (`memberIds`, newest appended). A
104104
result resolves its lane when its call id is the row's own id **or** one of
105105
its members — this is what pairs a resumed transcript's parallel batch
106106
(call, call, result, result) correctly. An id matching nothing still answers

src/agent/posix-tool-plugins.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ import {
3030
import type { PermissionGate } from "../permission/gate.js";
3131
import { createWorktreeRootsProvider } from "../permission/worktree-roots.js";
3232
import type { CompactionArchive } from "../session/compaction-archive.js";
33-
import type { ShellOutputFeed } from "../session/shell-output-feed.js";
33+
import type { ShellOutputFeedMap } from "../session/shell-output-feed.js";
3434

3535
export interface CorePosixToolPluginsArgs {
3636
cwd: string;
@@ -48,9 +48,9 @@ export interface CorePosixToolPluginsArgs {
4848
// Live getter for the background-shell registry (run_shell background:true).
4949
// Omitted makes background runs fail closed in shell-guard.
5050
getBackgroundShellRegistry?: () => BackgroundShellRegistry | undefined;
51-
// Live getter for the bounded shell-output feed the transcript polls for a
52-
// running command's live tail. Omitted leaves the tail unwired.
53-
getShellOutputFeed?: () => ShellOutputFeed | undefined;
51+
// Live getter for the per-call bounded shell-output feeds the transcript
52+
// polls for a running command's live tail. Omitted leaves the tail unwired.
53+
getShellOutputFeeds?: () => ShellOutputFeedMap | undefined;
5454
/** Primary-only evidence archive; workers omit this getter. */
5555
getEvidenceArchive?: () => CompactionArchive | undefined;
5656
}
@@ -90,7 +90,7 @@ export function buildCorePosixToolPlugins(
9090
getContextDir,
9191
shellEnv,
9292
getBackgroundShellRegistry,
93-
getShellOutputFeed,
93+
getShellOutputFeeds,
9494
getEvidenceArchive,
9595
} = args;
9696
// Pre-gate sandboxes honor yolo mode so outside-workspace path tools and shell
@@ -124,7 +124,7 @@ export function buildCorePosixToolPlugins(
124124
...(getBackgroundShellRegistry !== undefined
125125
? { getBackgroundShellRegistry }
126126
: {}),
127-
...(getShellOutputFeed !== undefined ? { getShellOutputFeed } : {}),
127+
...(getShellOutputFeeds !== undefined ? { getShellOutputFeeds } : {}),
128128
}),
129129
...(getEvidenceArchive !== undefined
130130
? [evidenceArchiveSearchPlugin(getEvidenceArchive)]

src/agent/tools.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ import {
9090
createBackgroundShellRegistry,
9191
type BackgroundShellExit,
9292
} from "../shell/background-shell.js";
93-
import { createShellOutputFeed } from "../session/shell-output-feed.js";
93+
import { createShellOutputFeedMap } from "../session/shell-output-feed.js";
9494
import { createListDirTool } from "../util/list-dir.js";
9595
import {
9696
createExaMCPWebFetchTool,
@@ -290,9 +290,9 @@ export interface AgentToolset {
290290
callbacks: MCPConnectCallbacks,
291291
signal?: AbortSignal,
292292
) => Promise<void>;
293-
// Bounded live-output tail of the session's foreground shell, polled by the
294-
// transcript for the pending run_shell row's live lines.
295-
shellOutputFeed: ReturnType<typeof createShellOutputFeed>;
293+
// Per-call bounded live-output tails of foreground shells, polled by the
294+
// transcript for each pending run_shell row's live lines.
295+
shellOutputFeed: ReturnType<typeof createShellOutputFeedMap>;
296296
// Connect one newly persisted server through the same lifecycle as startup MCP.
297297
connectMCPServer: (
298298
config: MCPServerConfig,
@@ -364,10 +364,10 @@ export async function createAgentToolset(
364364
: {}),
365365
});
366366
const shellCollect = createShellCollectTool(backgroundShells);
367-
// Bounded live-output tail of the foreground shell, polled by the TUI for
368-
// the pending run_shell row's live lines. Workers get one too; nothing
367+
// Per-call bounded live-output tails of foreground shells, polled by the TUI
368+
// for each pending run_shell row's live lines. Workers get a map too; nothing
369369
// reads it unless a transcript polls it (silent degradation).
370-
const shellOutputFeed = createShellOutputFeed();
370+
const shellOutputFeed = createShellOutputFeedMap();
371371
const sessionBlobReader =
372372
getBlobReader !== undefined
373373
? createLazyBlobReader(getBlobReader)
@@ -455,7 +455,7 @@ export async function createAgentToolset(
455455
...(getEvidenceArchive !== undefined ? { getEvidenceArchive } : {}),
456456
...(shellEnv !== undefined ? { shellEnv } : {}),
457457
getBackgroundShellRegistry: () => backgroundShells,
458-
getShellOutputFeed: () => shellOutputFeed,
458+
getShellOutputFeeds: () => shellOutputFeed,
459459
}),
460460
});
461461

src/plugins/shell-guard-plugin.test.ts

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { createShellOutputFeed } from "../session/shell-output-feed.js";
1515
import {
1616
BoundedShellOutput,
1717
MAX_SHELL_OUTPUT_BYTES,
18+
SHELL_FEED_EMIT_MS,
1819
advertiseShellGuardTimeout,
1920
resolveShellTimeoutMs,
2021
reapLiveChildren,
@@ -39,6 +40,35 @@ describe("runGuardedShell", () => {
3940
expect(output).toContain("hello");
4041
});
4142

43+
test("a rate-limited second write reaches the feed before the process exits", async () => {
44+
const feed = createShellOutputFeed();
45+
let finished = false;
46+
const running = runGuardedShell(
47+
{ command: "echo first; sleep 0.02; echo second; sleep 0.4" },
48+
neverAbort(),
49+
undefined,
50+
undefined,
51+
(text) => {
52+
feed.append(text);
53+
},
54+
).then((result) => {
55+
finished = true;
56+
return result;
57+
});
58+
const deadline = Date.now() + SHELL_FEED_EMIT_MS + 80;
59+
while (
60+
!feed.snapshot().includes("second") &&
61+
Date.now() < deadline &&
62+
!finished
63+
) {
64+
await Bun.sleep(10);
65+
}
66+
expect(finished).toBe(false);
67+
expect(feed.snapshot()).toContain("second");
68+
const result = await running;
69+
expect(result.exitCode).toBe(0);
70+
});
71+
4272
test("omitted timeout does not arm a timer", async () => {
4373
const start = Date.now();
4474
const { exitCode, timedOut, output } = await runGuardedShell(
@@ -329,15 +359,20 @@ describe("background run_shell (shellGuardPlugin)", () => {
329359
let emits = 0;
330360
const handler = defined(
331361
shellGuardPlugin(process.cwd(), undefined, undefined, {
332-
getShellOutputFeed: () => {
333-
return {
334-
append: (text) => {
362+
getShellOutputFeeds: () => {
363+
const wrapped = {
364+
append: (text: string) => {
335365
emits += 1;
336366
feed.append(text);
337367
},
338368
snapshot: () => feed.snapshot(),
339369
clear: () => feed.clear(),
340370
};
371+
return {
372+
forCall: () => wrapped,
373+
get: () => wrapped,
374+
drop: () => undefined,
375+
};
341376
},
342377
}).middleware,
343378
)(fallback);

src/plugins/shell-guard-plugin.ts

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { spawn, type ChildProcess } from "node:child_process";
22
import { realpathSync } from "node:fs";
33
import { StringDecoder } from "node:string_decoder";
44
import type { ToolPlugin } from "@intx/tools-posix";
5-
import type { ShellOutputFeed } from "../session/shell-output-feed.js";
5+
import type { ShellOutputFeedMap } from "../session/shell-output-feed.js";
66
import {
77
killProcessTree,
88
type BackgroundShellRegistry,
@@ -321,20 +321,43 @@ export async function runGuardedShell(
321321

322322
let settled = false;
323323
let timer: ReturnType<typeof setTimeout> | undefined;
324+
let emitTimer: ReturnType<typeof setTimeout> | undefined;
324325

325326
// Live-output cadence for the transcript's shell tail (when wired).
326327
const decoder = new StringDecoder("utf8");
327328
let pendingOutput = "";
328329
let lastEmitAt = 0;
330+
const clearEmitTimer = (): void => {
331+
if (emitTimer !== undefined) {
332+
clearTimeout(emitTimer);
333+
emitTimer = undefined;
334+
}
335+
};
329336
const emitPendingOutput = (final: boolean): void => {
330-
if (onOutput === undefined || pendingOutput.length === 0) return;
331-
if (!final && Date.now() - lastEmitAt < SHELL_FEED_EMIT_MS) return;
337+
if (onOutput === undefined || pendingOutput.length === 0) {
338+
if (final) clearEmitTimer();
339+
return;
340+
}
341+
if (!final) {
342+
const wait = SHELL_FEED_EMIT_MS - (Date.now() - lastEmitAt);
343+
if (wait > 0) {
344+
if (emitTimer === undefined) {
345+
emitTimer = setTimeout(() => {
346+
emitTimer = undefined;
347+
emitPendingOutput(false);
348+
}, wait);
349+
}
350+
return;
351+
}
352+
}
353+
clearEmitTimer();
332354
lastEmitAt = Date.now();
333355
onOutput(pendingOutput);
334356
pendingOutput = "";
335357
};
336358
const clearTimer = () => {
337359
if (timer !== undefined) clearTimeout(timer);
360+
clearEmitTimer();
338361
};
339362

340363
const settle = (err?: Error) => {
@@ -436,9 +459,9 @@ export interface ShellGuardPluginOptions {
436459
// Live getter for the background-shell registry. Unwired (undefined result)
437460
// makes `background: true` fail closed: nothing spawns, no handle returns.
438461
getBackgroundShellRegistry?: () => BackgroundShellRegistry | undefined;
439-
// Live getter for the bounded shell-output feed the transcript polls for a
440-
// running command's live tail. Unwired, the tail is simply not painted.
441-
getShellOutputFeed?: () => ShellOutputFeed | undefined;
462+
// Live getter for the per-call bounded shell-output feeds the transcript
463+
// polls for a running command's live tail. Unwired, the tail is simply not painted.
464+
getShellOutputFeeds?: () => ShellOutputFeedMap | undefined;
442465
}
443466

444467
function resolveAllowOutsideCwd(
@@ -569,8 +592,8 @@ export function shellGuardPlugin(
569592
};
570593
}
571594
const wrappedCommand = wrapCommandWithPwdProbe(command);
572-
const feed = options.getShellOutputFeed?.();
573-
if (feed !== undefined) feed.clear();
595+
const feeds = options.getShellOutputFeeds?.();
596+
const feed = feeds?.forCall(call.id);
574597
try {
575598
const { output, exitCode, timedOut, outputTruncated } =
576599
await runGuardedShell(
@@ -627,6 +650,8 @@ export function shellGuardPlugin(
627650
content: err instanceof Error ? err.message : String(err),
628651
isError: true,
629652
};
653+
} finally {
654+
feeds?.drop(call.id);
630655
}
631656
}).catch((err: unknown) => ({
632657
callId: call.id,

src/session/shell-output-feed.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
22

33
import {
44
createShellOutputFeed,
5+
createShellOutputFeedMap,
56
SHELL_FEED_LIMIT_BYTES,
67
} from "./shell-output-feed.js";
78

@@ -43,3 +44,23 @@ describe("shell output feed", () => {
4344
expect(feed.snapshot()).toBe("");
4445
});
4546
});
47+
48+
describe("shell output feed map", () => {
49+
test("isolates tails per call and keeps the 8 KiB bound on each", () => {
50+
const feeds = createShellOutputFeedMap();
51+
const a = feeds.forCall("a");
52+
const b = feeds.forCall("b");
53+
a.append("alpha\n");
54+
b.append("beta\n");
55+
expect(feeds.get("a")?.snapshot()).toBe("alpha\n");
56+
expect(feeds.get("b")?.snapshot()).toBe("beta\n");
57+
a.append("x".repeat(SHELL_FEED_LIMIT_BYTES + 1));
58+
expect(new TextEncoder().encode(a.snapshot()).length).toBeLessThanOrEqual(
59+
SHELL_FEED_LIMIT_BYTES,
60+
);
61+
expect(feeds.get("b")?.snapshot()).toBe("beta\n");
62+
feeds.drop("a");
63+
expect(feeds.get("a")).toBeUndefined();
64+
expect(feeds.get("b")?.snapshot()).toBe("beta\n");
65+
});
66+
});

src/session/shell-output-feed.ts

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
*
44
* Pure and synchronous: the shell-guard plugin appends output chunks, the TUI
55
* polls `snapshot()` on its sticky tick and paints the tail onto the pending
6-
* `run_shell` row. Nothing here is persisted; the whole feed is a display
7-
* affordance for the wait.
6+
* `run_shell` row that owns that call. Nothing here is persisted; the whole
7+
* feed is a display affordance for the wait.
88
*/
99

1010
/** Tail of output a feed keeps before the oldest chunk is dropped. */
@@ -14,10 +14,17 @@ export interface ShellOutputFeed {
1414
append(chunk: string): void;
1515
/** The retained tail, oldest first. Empty string when nothing has landed. */
1616
snapshot(): string;
17-
/** Drop everything (a new foreground shell is about to run). */
17+
/** Drop everything retained in this feed. */
1818
clear(): void;
1919
}
2020

21+
/** Per-call live tails so parallel run_shells cannot share or wipe a sibling. */
22+
export interface ShellOutputFeedMap {
23+
forCall(callId: string): ShellOutputFeed;
24+
get(callId: string): ShellOutputFeed | undefined;
25+
drop(callId: string): void;
26+
}
27+
2128
function byteLength(text: string): number {
2229
return new TextEncoder().encode(text).length;
2330
}
@@ -60,3 +67,23 @@ export function createShellOutputFeed(
6067
},
6168
};
6269
}
70+
71+
export function createShellOutputFeedMap(): ShellOutputFeedMap {
72+
const feeds = new Map<string, ShellOutputFeed>();
73+
return {
74+
forCall(callId: string): ShellOutputFeed {
75+
let feed = feeds.get(callId);
76+
if (feed === undefined) {
77+
feed = createShellOutputFeed();
78+
feeds.set(callId, feed);
79+
}
80+
return feed;
81+
},
82+
get(callId: string): ShellOutputFeed | undefined {
83+
return feeds.get(callId);
84+
},
85+
drop(callId: string): void {
86+
feeds.delete(callId);
87+
},
88+
};
89+
}

src/subagent/run.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import {
2222
workerPermissionGate,
2323
} from "../permission/reactor-authorize.js";
2424
import { createSessionStores } from "../session/optimized-context-store.js";
25-
import { createShellOutputFeed } from "../session/shell-output-feed.js";
25+
import { createShellOutputFeedMap } from "../session/shell-output-feed.js";
2626
import { createAgentWithLiveToolDispatch } from "../agent/live-tool-dispatch.js";
2727
import { type } from "arktype";
2828
import { createPosixTools } from "@intx/tools-posix";
@@ -598,9 +598,9 @@ async function runSubAgentInner(
598598
let childBlobReader: BlobReader | undefined;
599599
let childBlobWriter: SpillBlobWriter | undefined;
600600
let childContextDir: string | undefined;
601-
// Live shell tail for this worker's transcript; nothing polls it unless a
602-
// transcript host does (silent degradation), but the run_shell path shares it.
603-
const childShellOutputFeed = createShellOutputFeed();
601+
// Per-call live shell tails for this worker's transcript; nothing polls them
602+
// unless a transcript host does (silent degradation).
603+
const childShellOutputFeed = createShellOutputFeedMap();
604604
const sessionBlobReader = createCompositeBlobReader(
605605
() => childBlobReader,
606606
params.getBlobReader,
@@ -617,7 +617,7 @@ async function runSubAgentInner(
617617
...(params.shellEnv !== undefined ? { shellEnv: params.shellEnv } : {}),
618618
readFileGuard: { blobReader: sessionBlobReader },
619619
getBackgroundShellRegistry: () => backgroundShells,
620-
getShellOutputFeed: () => childShellOutputFeed,
620+
getShellOutputFeeds: () => childShellOutputFeed,
621621
getBlobWriter: () => childBlobWriter,
622622
getContextDir: () => childContextDir,
623623
extraToolPlugins: [

src/tui/product-host.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -179,11 +179,12 @@ export interface ProductHostConfig {
179179
*/
180180
readonly subAgentSessions?: () => readonly TaskProgressSession[];
181181
/**
182-
* The session's bounded shell-output feed, polled on the same sticky tick to
183-
* paint a running command's live output tail onto its pending row. Omitted
184-
* hosts (tests, the demo shell) paint pending shell rows without a tail.
182+
* Per-call bounded shell-output feeds, polled on the same sticky tick to
183+
* paint a running command's live output tail onto the pending row that owns
184+
* that call. Omitted hosts (tests, the demo shell) paint pending shell rows
185+
* without a tail.
185186
*/
186-
readonly shellOutputFeed?: () => ShellOutputFeed | undefined;
187+
readonly shellOutputFeed?: (callId: string) => ShellOutputFeed | undefined;
187188
/**
188189
* Renderer factory override for headless mounting in tests.
189190
* Defaults to the real `createCliRenderer`; tests inject a
@@ -391,7 +392,7 @@ export async function mountProductHost(
391392
}
392393
// Live shell tail: deduped in the bridge, so an unchanged snapshot is a
393394
// no-op and this poll cadence (200 ms) is the paint cadence.
394-
bridge.syncShellOutputs(config.shellOutputFeed?.());
395+
bridge.syncShellOutputs(config.shellOutputFeed);
395396
// Elapsed clock, stall flip, and post-finish linger are wall-time — repaint
396397
// the strip on this tick while sticky is needed. paintChromeZones re-enters
397398
// setChromeZones (which may paintChrome again on an unchanged-zone path),

0 commit comments

Comments
 (0)