pi-famulus 0.1.7 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,9 +1,9 @@
1
1
  <div align="center">
2
2
  <img src="https://raw.githubusercontent.com/Leechael/pi-famulus/main/assets/logo.png" alt="pi-famulus" width="120" />
3
3
  <h1>pi-famulus</h1>
4
- <p><b>Let your agent delegate, and keep working.</b></p>
4
+ <p><b>Keep your main agent focused. Hand off the rest.</b></p>
5
5
  <p>English · <a href="https://github.com/Leechael/pi-famulus/blob/main/README_CN.md">中文</a></p>
6
- <p>Subagents and long-running bash for <a href="https://pi.dev">pi</a> and agents built on it. Results wake the agent when work finishes, so it never has to poll. One daemon owns every shell it starts.</p>
6
+ <p>Subagents and long-running bash for <a href="https://pi.dev">pi</a> and agents built on it. Results wake the agent when work finishes, so the conversation keeps going. One Rust daemon runs the shells and monitors it starts.</p>
7
7
  <p>
8
8
  <a href="https://github.com/Leechael/pi-famulus/blob/main/docs/tools.md">Tools</a> ·
9
9
  <a href="https://github.com/Leechael/pi-famulus/blob/main/docs/cli.md">CLI</a> ·
@@ -22,6 +22,8 @@
22
22
 
23
23
  ## Why
24
24
 
25
+ pi-famulus is less about subagents than about the main agent: keep its context clean, keep it on the goal, and keep talking to it while work runs. Plan with the main agent, hand the coding to a subagent on a cheaper model, and let tests and logs run in the background until they need attention.
26
+
25
27
  A famulus is the assistant who runs a scholar's errands, as Wagner does for Faust. pi's core leaves subagents, background bash and monitoring to extensions on purpose ([design, section 1](https://github.com/Leechael/pi-famulus/blob/main/docs/design.md#1-background-and-goals)). pi-famulus adds them. It is for people who run pi, or an agent built on pi, on real projects and want to hand off slow or parallel work, such as a test suite, a build, a dev server or a code review, without stalling the conversation.
26
28
 
27
29
  Three things go wrong when an agent has to wait:
@@ -34,6 +36,10 @@ One machine-wide daemon (`pi-famulus`, written in Rust) starts every shell comma
34
36
 
35
37
  pi-famulus is a pi extension, so it works wherever pi loads extensions: the pi TUI, RPC mode, and agents you build with the pi SDK. Outside the TUI there is no `/tasks` list and no fleet line, but results still wake the agent. Print mode (`pi -p`) exits when the first run ends, so results that arrive later are not seen.
36
38
 
39
+ ### Not a pane manager
40
+
41
+ If you use Herdr or tmux panes, keep them. Panes are for watching and taking over an agent. pi-famulus is for the main agent: it is woken when a subagent overruns its budget, asks for a decision or finishes, so it never has to watch a pane to know work is done. A subagent that stalls is retried on its own, and the parent hears the outcome.
42
+
37
43
  ## Quick Start
38
44
 
39
45
  > **Before you install**: pi-famulus registers the tools `bash` (replacing the built-in), `subagent`, `monitor`, `agent_message`, `task_list`, `task_output` and `task_stop`, and the commands `/tasks`, `/famulus` and `/reply`. In pi 1.0.0, when two extensions register the same tool name, the extension loaded first keeps it, and the other one's tool is hidden.
@@ -63,10 +69,11 @@ Run the full test suite and tell me what fails.
63
69
  - **Wake on completion**: A finished command, a monitor event or a subagent result is injected into the conversation. The agent continues from it without polling.
64
70
  - **Subagents**: A call takes up to 10 subagents, which run 4 at a time by default, or a `chain` that runs in order. If the wait passes 45 seconds, the call returns and the run continues in the background. Each subagent reports back when it finishes.
65
71
  - **Clean finish**: A subagent's `bash` runs in the foreground until the command exits or times out, and subagents cannot start subagents or monitors. So a finished subagent leaves nothing it started through pi-famulus running, unless a command detached itself with `setsid` or left a process running with `&`.
66
- - **One daemon for every process**: The daemon is the parent of every shell and monitor task across your pi sessions. If it crashes or is killed with `kill -9`, each task's process group is stopped. Commands that move into a new session with `setsid` escape this.
72
+ - **Hears about trouble early**: With the default settings, a subagent with no activity for 5 minutes is resumed once on the same session, and marked failed if it stalls again. The watchdog pauses while a tool runs or a `contact_supervisor` question waits for an answer. Past its 30-minute soft budget (`timeout_ms`) the child keeps running, and the parent gets an overrun wake every 10 minutes to extend, steer or interrupt it; the wake is held while the child waits for an answer. These values are set in the `subagent` section of the [configuration](https://github.com/Leechael/pi-famulus/blob/main/docs/configuration.md).
73
+ - **One daemon for every shell and monitor**: The daemon is the parent of every shell and monitor task across your pi sessions. If it crashes or is killed with `kill -9`, each task's process group is stopped. Commands that move into a new session with `setsid` escape this.
67
74
  - **Shared capacity**: By default, at most 8 subagents run at once across all pi sessions on the machine (`pi-famulus config set max-agents <n>` changes it). The rest wait in a queue. Subagents labelled `work_kind: "test-suite"` or `"test"` are capped at 2 at a time; without a label, a subagent counts as `other`.
68
75
  - **Parent and subagent talk**: A subagent asks its parent a question with `contact_supervisor` and waits for the answer. You can answer it yourself with `/reply`. `agent_message` sends messages between parent and subagents.
69
- - **Inspectable**: `pi-famulus top`, `ls`, `show`, `agent` and `events` show what ran, why it ended, and why a notification did or did not arrive.
76
+ - **Inspectable**: In a terminal, `pi-famulus top` is a live dashboard of sessions, subagents, shells and monitors. Open a subagent to read its conversation or a task to read its output, and stop a running shell or monitor from there. `ls`, `show`, `agent` and `events` show what ran, why it ended, and why a notification did or did not arrive.
70
77
  - **Tested against real models**: Tool descriptions are graded on real-model runs. The latest matrix (2026-10-08; 12 xAI and OpenAI configurations, 10 scenarios, k=10) recorded 1198 passes and 2 failures, with 6 invalid or errored episodes excluded ([eval/RESULTS.md](https://github.com/Leechael/pi-famulus/blob/main/eval/RESULTS.md)). It predates a later wording change to the `subagent` and wake prompts ([PR #62](https://github.com/Leechael/pi-famulus/pull/62)), which has not been through the full matrix yet.
71
78
 
72
79
  ## Usage Guide
@@ -74,7 +81,7 @@ Run the full test suite and tell me what fails.
74
81
  | Ask the agent | What happens |
75
82
  | :--- | :--- |
76
83
  | `Run the full test suite and tell me what fails.` | `bash` moves the run to the background after 20 seconds; the result arrives when it exits |
77
- | `Start the dev server and tell me if an error shows up in its output.` | `monitor` turns each output line into an event and wakes the agent when one arrives |
84
+ | `Start the dev server and tell me if an error shows up in its output.` | `monitor` batches new output into bounded events; repeated lines are deduplicated within a configurable window |
78
85
  | `Review these three modules in parallel with subagents.` | `subagent` runs the tasks in parallel; each subagent reports back when it finishes |
79
86
  | `Which models can subagents use?` | The agent calls `subagent({action: "models"})` and lists the choices |
80
87
 
@@ -121,7 +128,7 @@ pi-famulus starts and stops processes for you. This section states what it guara
121
128
  - Running work does not survive the last pi session closing. Five seconds after the last session disconnects, the daemon stops what is left and exits. Records, output and transcripts stay on disk for 24 hours.
122
129
  - A subagent still running when its 30-minute turn budget ends is not killed, unless you set `hardTimeoutMs`. The parent is told and chooses to extend, steer or interrupt it.
123
130
  - A subagent with no activity for 5 minutes is resumed once on the same session, with its transcript kept. The timer pauses while a tool runs or a `contact_supervisor` question waits for an answer. If the subagent stalls again, it is marked failed.
124
- - `monitor` batches output every 200 ms. It allows a burst of 10 events, then one event every 2 seconds. Batches over that limit are dropped. If at least half the batches are dropped over a 30-second window that holds at least 10 batches, the monitor stops and you are told.
131
+ - `monitor` batches output every 200 ms. Rust-framed repeated complete lines from the same monitor are deduplicated for 60 seconds by default after a representative is delivered; legacy/raw fallback previews are not content-deduplicated, and missing metadata, capacity eviction, and omitted representatives fail open. Exact matching is the default, with timestamp normalization opt-in. Previews are bounded; original output remains readable with `task_output`. Deduplication does not count as overload loss. Admitted batches allow a burst of 10 events, then one event every 2 seconds. Batches over that limit are dropped. If at least half the batches are dropped over a 30-second window that holds at least 10 batches, the monitor stops and you are told.
125
132
  - On Linux and macOS, the daemon checks its own executable every 2 seconds. When the file is replaced, the running daemon re-executes itself with the same pid. `pi-famulus upgrade` does the same on demand. Running commands keep going and clients reconnect. The measured gap was 30 to 46 ms.
126
133
 
127
134
  ## FAQ
@@ -135,7 +142,7 @@ pi-famulus starts and stops processes for you. This section states what it guara
135
142
  - **Which binary and versions am I running?** Type `/famulus`. It shows the `pi-famulus` binary path and its version, the running manager's version and pid, and the extension version.
136
143
  - **Why does my pi bash command hang on long builds?** With pi-famulus, `bash` stops waiting after 20 seconds and moves the command to the background, so the turn continues. To change the limit, set `foregroundBudgetMs` in `~/.pi/agent/pi-famulus/config.json` ([configuration](https://github.com/Leechael/pi-famulus/blob/main/docs/configuration.md)).
137
144
  - **How do I see or stop running work?** Type `/tasks` in the pi TUI and press `ctrl+x` on an item. From a terminal, `pi-famulus ls` lists what is running.
138
- - **Does it work on Windows?** Yes, on x64 and arm64. The in-place upgrade, where the daemon re-executes itself, works only on Linux and macOS. On Windows, replace the binary and restart the daemon manually.
145
+ - **Does it work on Windows?** Yes, on x64 and arm64. The in-place upgrade, where the daemon re-executes itself, works only on Linux and macOS. On Windows, replace the binary and restart the daemon manually. Unless `PI_FAMULUS_SHELL` names another shell, `bash` commands run in Git Bash when Git for Windows is installed, else in a `bash.exe` on `PATH` other than WSL's, else in `cmd.exe` ([configuration](https://github.com/Leechael/pi-famulus/blob/main/docs/configuration.md)). Git Bash's runtime can crash or hang when several copies start at the same moment ([git-for-windows/git#6368](https://github.com/git-for-windows/git/issues/6368)). With a posix shell (anything but `cmd.exe`), pi-famulus staggers shell start-ups to avoid it. A likely start-up crash ends as `shell-start-failed`. A task with no output and no process besides the shell after 20 seconds is stopped as `shell-start-hung`, so a silent command that runs only shell builtins for that long is stopped too. In both cases the agent is told to check for side effects before it retries.
139
146
  - **What if a notification does not arrive?** Run `pi-famulus events --id <id>` to see whether the wake was emitted, delivered and injected. Run `pi-famulus doctor` to check daemon health.
140
147
 
141
148
  ## Docs
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-famulus",
3
- "version": "0.1.7",
4
- "description": "Done means done: subagents and long-running background bash for Pi. One daemon owns every shell it starts and wakes the agent when work finishes. No polling.",
3
+ "version": "0.1.8",
4
+ "description": "Keep your main agent's context clean: subagents on cheaper models, long bash in the background, and monitors that wake the agent. One Rust daemon runs the shells.",
5
5
  "keywords": [
6
6
  "pi-package",
7
7
  "pi",
@@ -54,12 +54,12 @@
54
54
  "pi-famulus": "./bin/pi-famulus.js"
55
55
  },
56
56
  "optionalDependencies": {
57
- "pi-famulus-linux-x64": "0.1.7",
58
- "pi-famulus-linux-arm64": "0.1.7",
59
- "pi-famulus-darwin-x64": "0.1.7",
60
- "pi-famulus-darwin-arm64": "0.1.7",
61
- "pi-famulus-win-x64": "0.1.7",
62
- "pi-famulus-win-arm64": "0.1.7"
57
+ "pi-famulus-linux-x64": "0.1.8",
58
+ "pi-famulus-linux-arm64": "0.1.8",
59
+ "pi-famulus-darwin-x64": "0.1.8",
60
+ "pi-famulus-darwin-arm64": "0.1.8",
61
+ "pi-famulus-win-x64": "0.1.8",
62
+ "pi-famulus-win-arm64": "0.1.8"
63
63
  },
64
64
  "pi": {
65
65
  "extensions": [
@@ -13,10 +13,7 @@
13
13
  * ({ truncation?, fullOutputPath? }); extension fields are added alongside.
14
14
  */
15
15
  import { spawn } from "node:child_process";
16
- import { randomUUID } from "node:crypto";
17
- import { writeFileSync } from "node:fs";
18
- import { tmpdir } from "node:os";
19
- import { join } from "node:path";
16
+ import { BoundedOutput } from "./bounded-output";
20
17
  import { Type, type Static } from "typebox";
21
18
  import {
22
19
  getShellConfig,
@@ -29,6 +26,7 @@ import { taskOutputPath, type FamulusConfig } from "./config";
29
26
  import { realClock, type Clock, type ClockTimer } from "./clock";
30
27
  import { backgroundRowText, formatBackgroundNotice, truncateTail } from "./format";
31
28
  import { toolComponent } from "./tui/tool-component";
29
+ import { scrubTty } from "./tui/conversation-view";
32
30
  import type { WorkIndex } from "./work-index";
33
31
  import type { ManagerClient } from "./manager-client";
34
32
  import { fill, PROMPTS } from "./prompts.generated";
@@ -38,6 +36,7 @@ import {
38
36
  collectOutput,
39
37
  formatFinishedOutput,
40
38
  killedBy,
39
+ exitedStatus,
41
40
  killedStatus,
42
41
  timedOutStatus,
43
42
  SHELL_MAX_BYTES,
@@ -177,7 +176,13 @@ async function executeLocal(
177
176
  }
178
177
  const viaStdin = shell.commandTransport === "stdin";
179
178
 
180
- const output = await new Promise<{ text: string; exitCode: number | null; signal: string | null; timedOut: boolean; aborted: boolean }>(
179
+ const collector = new BoundedOutput({
180
+ maxBytes: MAX_BYTES,
181
+ maxLines: MAX_LINES,
182
+ // structuredOutput() keeps up to this much tail.
183
+ tailBytes: STRUCTURED_OUTPUT_MAX_BYTES,
184
+ });
185
+ const output = await new Promise<{ exitCode: number | null; signal: string | null; timedOut: boolean; aborted: boolean }>(
181
186
  (resolve, reject) => {
182
187
  const child = spawn(shell.shell, viaStdin ? shell.args : [...shell.args, params.command], {
183
188
  cwd: ctx.cwd,
@@ -190,7 +195,6 @@ async function executeLocal(
190
195
  child.stdin?.on("error", () => {});
191
196
  child.stdin?.end(params.command);
192
197
  }
193
- const chunks: Buffer[] = [];
194
198
  let settled = false;
195
199
  let timedOut = false;
196
200
  let aborted = false;
@@ -200,7 +204,7 @@ async function executeLocal(
200
204
  settled = true;
201
205
  if (timer !== null) clock.clearTimeout(timer);
202
206
  signal?.removeEventListener("abort", onAbort);
203
- resolve({ text: Buffer.concat(chunks).toString("utf8"), exitCode, signal: killSignal, timedOut, aborted });
207
+ resolve({ exitCode, signal: killSignal, timedOut, aborted });
204
208
  };
205
209
  const kill = () => {
206
210
  try {
@@ -222,8 +226,7 @@ async function executeLocal(
222
226
  : null;
223
227
  if (timer !== null) clock.unref?.(timer);
224
228
  signal?.addEventListener("abort", onAbort, { once: true });
225
- child.stdout?.on("data", (d: Buffer) => chunks.push(d));
226
- child.stderr?.on("data", (d: Buffer) => chunks.push(d));
229
+ collector.attach(child.stdout, child.stderr);
227
230
  child.on("error", (err) => {
228
231
  if (timer !== null) clock.clearTimeout(timer);
229
232
  signal?.removeEventListener("abort", onAbort);
@@ -236,21 +239,22 @@ async function executeLocal(
236
239
  },
237
240
  );
238
241
 
239
- const t = truncateTail(output.text, MAX_LINES, MAX_BYTES);
242
+ const collected = await collector.finish();
243
+ const t = truncateTail(collected.text, MAX_LINES, MAX_BYTES);
244
+ const totalLines = collected.totalLines;
240
245
  let text = t.text || "(no output)";
241
246
  let details: FamulusBashDetails | undefined;
242
- if (t.truncated) {
243
- const fullOutputPath = join(tmpdir(), `pi-famulus-bash-${randomUUID()}.log`);
244
- writeFileSync(fullOutputPath, output.text, "utf8");
247
+ if (t.truncated || collected.headDropped) {
248
+ const fullOutputPath = collected.fullOutputPath;
245
249
  const outputLines = t.text.length === 0 ? 0 : t.text.split("\n").length;
246
- const startLine = t.totalLines - outputLines + 1;
250
+ const startLine = totalLines - outputLines + 1;
247
251
  details = {
248
252
  truncation: {
249
253
  content: t.text,
250
254
  truncated: true,
251
- truncatedBy: t.totalLines > MAX_LINES ? "lines" : "bytes",
252
- totalLines: t.totalLines,
253
- totalBytes: t.totalBytes,
255
+ truncatedBy: totalLines > MAX_LINES ? "lines" : "bytes",
256
+ totalLines,
257
+ totalBytes: collected.totalBytes,
254
258
  outputLines,
255
259
  outputBytes: Buffer.byteLength(t.text, "utf8"),
256
260
  lastLinePartial: false,
@@ -260,7 +264,9 @@ async function executeLocal(
260
264
  },
261
265
  fullOutputPath,
262
266
  };
263
- text += `\n\n[Showing lines ${startLine}-${t.totalLines} of ${t.totalLines}. Full output: ${fullOutputPath}]`;
267
+ text += fullOutputPath
268
+ ? `\n\n[Showing lines ${startLine}-${totalLines} of ${totalLines}. Full output: ${fullOutputPath}]`
269
+ : `\n\n[Showing lines ${startLine}-${totalLines} of ${totalLines}. Full output unavailable]`;
264
270
  }
265
271
 
266
272
  if (output.aborted) throw new Error(appendStatus(text, "Command aborted"));
@@ -268,10 +274,10 @@ async function executeLocal(
268
274
  throw new Error(appendStatus(text, timedOutStatus(params.timeout)));
269
275
  }
270
276
  if (output.exitCode === null) throw new Error(appendStatus(text, killedBy(output.signal)));
271
- const { output: structuredText, truncated: structuredTruncated } = structuredOutput(output.text);
277
+ const { output: structuredText, truncated: structuredTruncated } = structuredOutput(collected.text);
272
278
  const structuredContent: BashToolOutput = {
273
279
  output: structuredText,
274
- truncated: structuredTruncated,
280
+ truncated: structuredTruncated || collected.headDropped,
275
281
  ...(details?.fullOutputPath ? { full_output_path: details.fullOutputPath } : {}),
276
282
  exit_code: output.exitCode,
277
283
  wall_time_seconds: wallSeconds(startedAtMs, clock.now()),
@@ -311,24 +317,26 @@ export function createBashOverride(
311
317
  const index = deps.getIndex?.() ?? null;
312
318
  const state = context.state as { unsubscribe?: () => void };
313
319
  const item = index?.get(taskId);
314
- if (index && !state.unsubscribe && isActiveStatus(item?.status)) {
315
- // Redraw this row when the task finishes, then stop listening.
320
+ if (index && !state.unsubscribe && (!item || isActiveStatus(item.status))) {
321
+ // Redraw this row as the task appears or finishes; stop once it has.
316
322
  state.unsubscribe = index.onChange(() => {
317
- if (!isActiveStatus(index.get(taskId)?.status)) {
323
+ const now = index.get(taskId);
324
+ if (now && !isActiveStatus(now.status)) {
318
325
  state.unsubscribe?.();
319
326
  state.unsubscribe = () => {};
320
327
  }
321
328
  context.invalidate();
322
329
  });
323
330
  }
324
- const row = backgroundRowText(taskId, item, (deps.clock ?? realClock).now());
331
+ const row = backgroundRowText(taskId, item, (deps.clock ?? realClock).now(), index !== null);
325
332
  return toolComponent([`${theme.fg(row.color as never, row.glyph)} ${theme.fg("muted", row.text)}`]) as never;
326
333
  }
327
334
  const text = result.content
328
335
  .filter((c): c is { type: "text"; text: string } => c.type === "text")
329
336
  .map((c) => c.text)
330
337
  .join("\n");
331
- const lines = text.split("\n");
338
+ // Progress \r, tabs, and cursor sequences would scramble the row.
339
+ const lines = scrubTty(text).split("\n");
332
340
  const shown = expanded ? lines : lines.slice(0, PREVIEW_LINES);
333
341
  const out = shown.map((l) => theme.fg("toolOutput", l));
334
342
  if (shown.length < lines.length) {
@@ -442,7 +450,7 @@ export function createBashOverride(
442
450
  // Mirror the built-in bash tool: non-zero exits resolve to an isError
443
451
  // result carrying structuredContent instead of rejecting.
444
452
  return {
445
- content: [{ type: "text", text: appendStatus(text, `Command exited with code ${exitCode}`) }],
453
+ content: [{ type: "text", text: appendStatus(text, await exitedStatus(client, start.task_id, exitCode)) }],
446
454
  details,
447
455
  structuredContent,
448
456
  isError: true,
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Bounded collector for a streaming command's output.
3
+ *
4
+ * Keeps only a raw byte tail in memory and, once the output outgrows the
5
+ * display limits, streams the full output to a temp file asynchronously.
6
+ * Memory stays bounded and no step materializes the whole output as one
7
+ * buffer or string on the event loop.
8
+ */
9
+ import { randomUUID } from "node:crypto";
10
+ import { createWriteStream, type WriteStream } from "node:fs";
11
+ import type { Readable } from "node:stream";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+
15
+ export interface BoundedOutputOptions {
16
+ /** Spill to a temp file once total bytes exceed this. */
17
+ maxBytes: number;
18
+ /** Spill to a temp file once total lines exceed this. */
19
+ maxLines: number;
20
+ /** Raw bytes of tail to keep in memory. */
21
+ tailBytes: number;
22
+ /** Temp file name prefix. */
23
+ tempFilePrefix?: string;
24
+ }
25
+
26
+ export interface BoundedOutputResult {
27
+ /** Decoded tail (the whole output when nothing was dropped). */
28
+ text: string;
29
+ /** True when `text` is missing a head that was dropped from memory. */
30
+ headDropped: boolean;
31
+ totalBytes: number;
32
+ /** Line count as `text.split("\n").length` would give for the full output. */
33
+ totalLines: number;
34
+ /** Full output, present once the output exceeded the limits. */
35
+ fullOutputPath?: string;
36
+ }
37
+
38
+ export class BoundedOutput {
39
+ private chunks: Buffer[] = [];
40
+ private bufferedBytes = 0;
41
+ private headDropped = false;
42
+ private totalBytes = 0;
43
+ private newlines = 0;
44
+ private stream: WriteStream | null = null;
45
+ private streamFailed = false;
46
+ private path: string | undefined;
47
+ private sources: Readable[] = [];
48
+ private waitingForDrain = false;
49
+
50
+ constructor(private readonly opts: BoundedOutputOptions) {}
51
+
52
+ /**
53
+ * Collect from these streams, pausing them while the spill file drains so
54
+ * output faster than the disk cannot pile up in the write stream's buffer.
55
+ */
56
+ attach(...streams: Array<Readable | null | undefined>): void {
57
+ for (const stream of streams) {
58
+ if (!stream) continue;
59
+ this.sources.push(stream);
60
+ stream.on("data", (d: Buffer) => this.append(d));
61
+ }
62
+ }
63
+
64
+ append(data: Buffer): void {
65
+ if (data.length === 0) return;
66
+ this.totalBytes += data.length;
67
+ for (let i = data.indexOf(10); i !== -1; i = data.indexOf(10, i + 1)) this.newlines++;
68
+ if (this.stream === null && !this.streamFailed && this.exceedsLimits()) this.openSpill();
69
+ if (this.stream !== null && !this.stream.write(data)) this.applyBackpressure(this.stream);
70
+ this.chunks.push(data);
71
+ this.bufferedBytes += data.length;
72
+ // Trim lazily (at 2x) so trimming cost is amortized over many chunks.
73
+ if (this.bufferedBytes > this.opts.tailBytes * 2) this.trim();
74
+ }
75
+
76
+ async finish(): Promise<BoundedOutputResult> {
77
+ // Sources have ended; drop any pause left from the last write.
78
+ if (this.waitingForDrain) {
79
+ this.waitingForDrain = false;
80
+ for (const source of this.sources) source.resume();
81
+ }
82
+ this.trim();
83
+ let raw = this.chunks.length === 1 ? this.chunks[0] : Buffer.concat(this.chunks);
84
+ if (this.headDropped) raw = skipPartialUtf8(raw);
85
+ const text = raw.toString("utf8");
86
+ let fullOutputPath: string | undefined;
87
+ if (this.stream !== null) {
88
+ const stream = this.stream;
89
+ this.stream = null;
90
+ const ok = await new Promise<boolean>((resolve) => {
91
+ stream.once("error", () => resolve(false));
92
+ stream.end(() => resolve(true));
93
+ });
94
+ if (ok && !this.streamFailed) fullOutputPath = this.path;
95
+ }
96
+ return {
97
+ text,
98
+ headDropped: this.headDropped,
99
+ totalBytes: this.totalBytes,
100
+ totalLines: this.newlines + 1,
101
+ fullOutputPath,
102
+ };
103
+ }
104
+
105
+ private applyBackpressure(stream: WriteStream): void {
106
+ if (this.waitingForDrain || this.sources.length === 0) return;
107
+ this.waitingForDrain = true;
108
+ for (const source of this.sources) source.pause();
109
+ const release = () => {
110
+ stream.off("drain", release);
111
+ stream.off("close", release);
112
+ this.waitingForDrain = false;
113
+ for (const source of this.sources) source.resume();
114
+ };
115
+ stream.on("drain", release);
116
+ stream.on("close", release);
117
+ }
118
+
119
+ private exceedsLimits(): boolean {
120
+ return this.totalBytes > this.opts.maxBytes || this.newlines + 1 > this.opts.maxLines;
121
+ }
122
+
123
+ private openSpill(): void {
124
+ this.path = join(tmpdir(), `${this.opts.tempFilePrefix ?? "pi-famulus-bash"}-${randomUUID()}.log`);
125
+ const stream = createWriteStream(this.path);
126
+ stream.on("error", () => {
127
+ this.streamFailed = true;
128
+ // Stop writing to a failed spill; keep collecting the in-memory tail.
129
+ // autoDestroy then emits "close", which releases paused sources.
130
+ if (this.stream === stream) this.stream = null;
131
+ });
132
+ // Everything seen so far is still in memory: the head is only dropped
133
+ // after the spill file exists.
134
+ this.stream = stream;
135
+ let drained = true;
136
+ for (const chunk of this.chunks) drained = stream.write(chunk);
137
+ if (!drained) this.applyBackpressure(stream);
138
+ }
139
+
140
+ private trim(): void {
141
+ const cap = this.opts.tailBytes;
142
+ if (this.bufferedBytes <= cap) return;
143
+ // Spill before dropping anything so the full output survives.
144
+ if (this.stream === null && !this.streamFailed) this.openSpill();
145
+ let excess = this.bufferedBytes - cap;
146
+ while (excess > 0 && this.chunks.length > 0) {
147
+ const first = this.chunks[0];
148
+ if (first.length <= excess) {
149
+ this.chunks.shift();
150
+ excess -= first.length;
151
+ this.bufferedBytes -= first.length;
152
+ } else {
153
+ this.chunks[0] = first.subarray(excess);
154
+ this.bufferedBytes -= excess;
155
+ excess = 0;
156
+ }
157
+ }
158
+ this.headDropped = true;
159
+ }
160
+ }
161
+
162
+ /** Drop leading UTF-8 continuation bytes left by a byte-offset cut. */
163
+ function skipPartialUtf8(buf: Buffer): Buffer {
164
+ let i = 0;
165
+ while (i < buf.length && i < 4 && (buf[i] & 0xc0) === 0x80) i++;
166
+ return i === 0 ? buf : buf.subarray(i);
167
+ }
@@ -38,7 +38,7 @@ export function registerReplyCommand(pi: ExtensionAPI, comms: Comms): void {
38
38
  handler: async (args, ctx: ExtensionContext) => {
39
39
  const resolved = resolveReplyCommand(args, comms.pendingRequests());
40
40
  if ("error" in resolved) {
41
- ctx.ui.notify(resolved.error, "info");
41
+ ctx.ui.notify(resolved.error, "warning");
42
42
  return;
43
43
  }
44
44
  try {
package/src/format.ts CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  formatFamulusWake,
9
9
  shellWakeTitle,
10
10
  type FamulusWake,
11
+ type MonitorOutputInfo,
11
12
  wakeDuration,
12
13
  type FormattedWake,
13
14
  type OverrunShell,
@@ -15,6 +16,21 @@ import {
15
16
  type WakeItem,
16
17
  } from "./wake";
17
18
  import { fill } from "./prompts.generated";
19
+ import { shellStartStatus } from "./shell-start";
20
+ import { statusGlyph } from "./tui/tool-component";
21
+
22
+ /** `850ms`, `12s`, `4m05s`, `2h03m`, `3d04h`: the same style as `pi-famulus top`. */
23
+ export function formatDuration(ms: number): string {
24
+ const total = Math.max(0, Math.floor(ms));
25
+ if (total < 1000) return `${total}ms`;
26
+ const secs = Math.floor(total / 1000);
27
+ if (secs < 60) return `${secs}s`;
28
+ const m = Math.floor(secs / 60);
29
+ if (m < 60) return `${m}m${String(secs % 60).padStart(2, "0")}s`;
30
+ const h = Math.floor(m / 60);
31
+ if (h < 24) return `${h}h${String(m % 60).padStart(2, "0")}m`;
32
+ return `${Math.floor(h / 24)}d${String(h % 24).padStart(2, "0")}h`;
33
+ }
18
34
 
19
35
  export { FAMULUS_WAKE_LEAD_IN } from "./wake";
20
36
  export type { FormattedWake, WakeItem } from "./wake";
@@ -84,6 +100,8 @@ export interface TaskExitInfo {
84
100
  preview: string;
85
101
  /** Signal name from the manager, when the exit was a signal. */
86
102
  signal?: string;
103
+ /** The manager's end_reason, when it sent one. */
104
+ endReason?: string;
87
105
  }
88
106
 
89
107
  /** Shorten a command for one-line display inside notifications. */
@@ -92,6 +110,8 @@ function displayCommand(command: string): string {
92
110
  }
93
111
 
94
112
  function statusPhrase(info: TaskExitInfo): string {
113
+ const startFailure = shellStartStatus(info.endReason);
114
+ if (startFailure) return startFailure;
95
115
  const code = info.exitCode === null ? null : `exit code ${info.exitCode}`;
96
116
  switch (info.status) {
97
117
  case "completed":
@@ -160,17 +180,22 @@ export function backgroundRowText(
160
180
  taskId: string,
161
181
  item: { status: string; exitCode?: number | null; startedAt: number; endedAt?: number } | undefined,
162
182
  now: number,
183
+ indexed = false,
163
184
  ): { glyph: string; color: string; text: string } {
185
+ // Missing from the index: pruned after it finished, or not yet re-listed
186
+ // after a reload. Either way the state is unknown, so claim neither.
187
+ if (!item && indexed) return { glyph: "·", color: "dim", text: `${taskId} · status in /tasks` };
164
188
  if (!item || item.status === "running" || item.status === "pending") {
165
- return { glyph: "⏵", color: "accent", text: `${taskId} running in background · /tasks` };
189
+ const { glyph, color } = statusGlyph(item?.status ?? "running");
190
+ return { glyph, color, text: `${taskId} running in background · /tasks` };
166
191
  }
167
- const secs = ((item.endedAt ?? now) - item.startedAt) / 1000;
168
- const dur = secs < 60 ? `${secs.toFixed(1)}s` : `${Math.floor(secs / 60)}m${Math.round(secs % 60)}s`;
192
+ const dur = formatDuration((item.endedAt ?? now) - item.startedAt);
169
193
  const exit = item.exitCode === null || item.exitCode === undefined ? "" : ` · exit ${item.exitCode}`;
170
194
  const ok = item.status === "completed" && (item.exitCode === 0 || item.exitCode === null || item.exitCode === undefined);
171
- return ok
172
- ? { glyph: "✓", color: "success", text: `${taskId} finished${exit} · ${dur}` }
173
- : { glyph: "✗", color: "error", text: `${taskId} ${item.status}${exit} · ${dur} · /tasks` };
195
+ if (ok) return { ...statusGlyph("completed"), text: `${taskId} finished${exit} · ${dur}` };
196
+ // A non-zero exit fails the row even when the task "completed".
197
+ const { glyph, color } = statusGlyph(item.status === "completed" ? "failed" : item.status);
198
+ return { glyph, color, text: `${taskId} ${item.status}${exit} · ${dur} · /tasks` };
174
199
  }
175
200
 
176
201
  /** Injected payload for a batch of monitor output lines (§4.4 / §4.5). */
@@ -179,7 +204,7 @@ export function formatMonitorEvent(
179
204
  taskId: string,
180
205
  batchText: string,
181
206
  status?: string,
182
- extras: { eventCount?: number; droppedLines?: number } = {},
207
+ extras: { eventCount?: number; droppedLines?: number; output?: MonitorOutputInfo } = {},
183
208
  ): FormattedWake {
184
209
  return formatFamulusWake({
185
210
  kind: "monitor",
@@ -189,6 +214,7 @@ export function formatMonitorEvent(
189
214
  event: batchText,
190
215
  ...(extras.eventCount !== undefined ? { eventCount: extras.eventCount } : {}),
191
216
  ...(extras.droppedLines !== undefined ? { droppedLines: extras.droppedLines } : {}),
217
+ ...(extras.output ? { output: extras.output } : {}),
192
218
  });
193
219
  }
194
220
 
package/src/index.ts CHANGED
@@ -302,6 +302,7 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
302
302
  status: toExitStatus(event),
303
303
  exitCode: event.exit_code ?? null,
304
304
  ...(typeof event.signal === "string" && event.signal ? { signal: event.signal } : {}),
305
+ ...(event.end_reason ? { endReason: event.end_reason } : {}),
305
306
  durationMs: event.duration_ms ?? 0,
306
307
  outputPath: event.output_path ?? "",
307
308
  preview: readPreview(event.output_path, 4000),
@@ -381,7 +382,7 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
381
382
  noteStopRequested: (id) => monitorRegistry?.noteStopRequested(id),
382
383
  }),
383
384
  );
384
- pi.registerTool(createMonitorTool(monitorRegistry));
385
+ pi.registerTool(createMonitorTool(monitorRegistry, () => workIndex));
385
386
  registerTasksCommand(pi, {
386
387
  getRegistry: () => subagentRegistry,
387
388
  getIndex: () => workIndex,
@@ -556,7 +557,7 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
556
557
  return;
557
558
  }
558
559
  if (event.event === "output" && event.task_id && typeof event.chunk === "string") {
559
- monitorRegistry?.handleOutput(event.task_id, event.chunk, event.next_cursor);
560
+ monitorRegistry?.handleOutput(event.task_id, event.chunk, event.next_cursor, event.monitor_records);
560
561
  return;
561
562
  }
562
563
  if (event.event === "task_exited" && event.task_id) {
@@ -18,7 +18,7 @@ import { famulusPaths } from "./config";
18
18
  import { realClock, type Clock, type ClockTimer } from "./clock";
19
19
 
20
20
  const MAX_FRAME_BYTES = 4 * 1024 * 1024; // 4 MiB (§3.3)
21
- const EXTENSION_VERSION = "0.1.7";
21
+ const EXTENSION_VERSION = "0.1.8";
22
22
  export { EXTENSION_VERSION };
23
23
  // 5: speaks per-kind agent admission and queued acquire.
24
24
  const PROTOCOL = 5;
@@ -52,6 +52,8 @@ export interface StartRequest {
52
52
  run_in_background?: boolean;
53
53
  timeout_ms?: number | null;
54
54
  origin?: TaskOrigin;
55
+ /** Human-readable name (monitor description). */
56
+ label?: string;
55
57
  }
56
58
 
57
59
  export interface StartResponse {
@@ -91,6 +93,19 @@ export interface TaskRecord {
91
93
  end_reason?: string;
92
94
  }
93
95
 
96
+ /** Rust-framed logical output. Fingerprints cover full content, not the preview. */
97
+ export interface MonitorOutputRecord {
98
+ version: 1;
99
+ kind: "line" | "progress";
100
+ observed_at: number;
101
+ cursor_start: number;
102
+ cursor_end: number;
103
+ preview: string;
104
+ truncated: boolean;
105
+ fingerprint?: string;
106
+ timestamp_fingerprint?: string;
107
+ }
108
+
94
109
  /** Server-pushed event (§3.3). Fields beyond `event` depend on the event kind. */
95
110
  export interface ManagerEvent {
96
111
  event: "task_started" | "output" | "task_exited" | "session_rebound" | string;
@@ -100,6 +115,8 @@ export interface ManagerEvent {
100
115
  pid?: number;
101
116
  chunk?: string;
102
117
  next_cursor?: number;
118
+ /** Absent on legacy managers/replay fallback; [] means Rust framing is active. */
119
+ monitor_records?: MonitorOutputRecord[];
103
120
  exit_code?: number | null;
104
121
  signal?: string | null;
105
122
  duration_ms?: number;