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 +14 -7
- package/package.json +8 -8
- package/src/bash-override.ts +34 -26
- package/src/bounded-output.ts +167 -0
- package/src/comms/reply-command.ts +1 -1
- package/src/format.ts +33 -7
- package/src/index.ts +3 -2
- package/src/manager-client.ts +18 -1
- package/src/monitor-batching.ts +84 -36
- package/src/monitor-output.ts +159 -0
- package/src/monitor.ts +148 -37
- package/src/notify.ts +100 -17
- package/src/prompts.generated.ts +14 -11
- package/src/shell-exec.ts +28 -2
- package/src/shell-start.ts +25 -0
- package/src/subagent/child-bash.ts +2 -1
- package/src/subagent/conversation.ts +57 -12
- package/src/subagent/fleet-widget.ts +8 -8
- package/src/subagent/runner.ts +2 -2
- package/src/subagent/tool.ts +2 -1
- package/src/task-tools.ts +32 -13
- package/src/tui/conversation-view.ts +402 -0
- package/src/tui/message-renderers.ts +16 -11
- package/src/tui/scroll-detail-view.ts +218 -99
- package/src/tui/task-detail-view.ts +417 -0
- package/src/tui/tasks-command.ts +117 -84
- package/src/tui/tool-component.ts +6 -2
- package/src/wake.ts +27 -1
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>
|
|
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
|
|
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
|
-
- **
|
|
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
|
|
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`
|
|
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.
|
|
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.
|
|
4
|
-
"description": "
|
|
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.
|
|
58
|
-
"pi-famulus-linux-arm64": "0.1.
|
|
59
|
-
"pi-famulus-darwin-x64": "0.1.
|
|
60
|
-
"pi-famulus-darwin-arm64": "0.1.
|
|
61
|
-
"pi-famulus-win-x64": "0.1.
|
|
62
|
-
"pi-famulus-win-arm64": "0.1.
|
|
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": [
|
package/src/bash-override.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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({
|
|
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
|
|
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
|
|
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 =
|
|
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 =
|
|
250
|
+
const startLine = totalLines - outputLines + 1;
|
|
247
251
|
details = {
|
|
248
252
|
truncation: {
|
|
249
253
|
content: t.text,
|
|
250
254
|
truncated: true,
|
|
251
|
-
truncatedBy:
|
|
252
|
-
totalLines
|
|
253
|
-
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 +=
|
|
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(
|
|
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
|
|
315
|
-
// Redraw this row
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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, "
|
|
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
|
-
|
|
189
|
+
const { glyph, color } = statusGlyph(item?.status ?? "running");
|
|
190
|
+
return { glyph, color, text: `${taskId} running in background · /tasks` };
|
|
166
191
|
}
|
|
167
|
-
const
|
|
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
|
|
172
|
-
|
|
173
|
-
|
|
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) {
|
package/src/manager-client.ts
CHANGED
|
@@ -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.
|
|
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;
|