pi-famulus 0.1.6 → 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 +154 -25
- package/package.json +32 -7
- 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 +96 -18
- package/src/manager-client.ts +38 -3
- package/src/monitor-batching.ts +84 -36
- package/src/monitor-output.ts +159 -0
- package/src/monitor.ts +148 -37
- package/src/native-manager.js +0 -2
- package/src/notify.ts +100 -17
- package/src/prompts.generated.ts +14 -11
- package/src/reload-handoff.ts +137 -0
- 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/famulus-command.ts +71 -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 -88
- package/src/tui/tool-component.ts +6 -2
- package/src/wake.ts +27 -1
package/README.md
CHANGED
|
@@ -1,41 +1,170 @@
|
|
|
1
|
-
|
|
1
|
+
<div align="center">
|
|
2
|
+
<img src="https://raw.githubusercontent.com/Leechael/pi-famulus/main/assets/logo.png" alt="pi-famulus" width="120" />
|
|
3
|
+
<h1>pi-famulus</h1>
|
|
4
|
+
<p><b>Keep your main agent focused. Hand off the rest.</b></p>
|
|
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 the conversation keeps going. One Rust daemon runs the shells and monitors it starts.</p>
|
|
7
|
+
<p>
|
|
8
|
+
<a href="https://github.com/Leechael/pi-famulus/blob/main/docs/tools.md">Tools</a> ·
|
|
9
|
+
<a href="https://github.com/Leechael/pi-famulus/blob/main/docs/cli.md">CLI</a> ·
|
|
10
|
+
<a href="https://github.com/Leechael/pi-famulus/blob/main/docs/design.md">Architecture</a> ·
|
|
11
|
+
<a href="https://github.com/Leechael/pi-famulus/blob/main/eval/RESULTS.md">Eval results</a>
|
|
12
|
+
</p>
|
|
13
|
+
<a href="https://www.npmjs.com/package/pi-famulus"><img src="https://img.shields.io/npm/v/pi-famulus?style=flat-square" alt="npm"></a>
|
|
14
|
+
<a href="https://github.com/Leechael/pi-famulus/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/Leechael/pi-famulus/ci.yml?branch=main&style=flat-square&label=ci" alt="CI"></a>
|
|
15
|
+
<a href="https://github.com/Leechael/pi-famulus/stargazers"><img src="https://img.shields.io/github/stars/Leechael/pi-famulus?style=flat-square" alt="Stars"></a>
|
|
16
|
+
<a href="https://github.com/Leechael/pi-famulus/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square" alt="License"></a>
|
|
17
|
+
<a href="https://github.com/Leechael/pi-famulus/commits"><img src="https://img.shields.io/github/commit-activity/m/Leechael/pi-famulus?style=flat-square" alt="Commits"></a>
|
|
18
|
+
<a href="https://x.com/leechael"><img src="https://img.shields.io/badge/follow-leechael-red?style=flat-square&logo=x" alt="X"></a>
|
|
19
|
+
</div>
|
|
2
20
|
|
|
3
|
-
|
|
21
|
+
<!-- TODO(visual): a full-width screenshot or GIF goes here. None exists yet. Candidate: a recording of `/tasks` and the fleet line while a test suite runs in the background and a subagent reports back. -->
|
|
4
22
|
|
|
5
|
-
|
|
23
|
+
## Why
|
|
6
24
|
|
|
7
|
-
|
|
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.
|
|
8
26
|
|
|
9
|
-
|
|
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.
|
|
28
|
+
|
|
29
|
+
Three things go wrong when an agent has to wait:
|
|
30
|
+
|
|
31
|
+
- **A foreground watch blocks the chat.** With pi-famulus, `bash` moves a command to the background after 20 seconds, and the turn goes on.
|
|
32
|
+
- **A backgrounded command finishes silently.** With pi-famulus, the result is injected into the conversation when the command exits, so the model reacts instead of polling.
|
|
33
|
+
- **A subagent reports "done" while its work still runs.** With pi-famulus, a subagent's `bash` cannot move to the background, and a subagent cannot start subagents or monitors. When it reports done, nothing it started through pi-famulus is still running, unless a command detached itself with `setsid` or left a process running with `&`.
|
|
34
|
+
|
|
35
|
+
One machine-wide daemon (`pi-famulus`, written in Rust) starts every shell command and monitor, including the ones subagents run, and is their parent. If the daemon crashes or is killed, the process groups it started are stopped, except commands that moved into a new session with `setsid`. Subagents themselves run inside pi.
|
|
36
|
+
|
|
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.
|
|
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
|
+
|
|
43
|
+
## Quick Start
|
|
44
|
+
|
|
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.
|
|
46
|
+
>
|
|
47
|
+
> - `pi-subagents` also registers `subagent`. Do not install it alongside pi-famulus.
|
|
48
|
+
> - `pi-background-tasks` also registers `/tasks`, so pi lists both as `/tasks:1` and `/tasks:2`. It overlaps with pi-famulus in purpose. Choose one of the two.
|
|
49
|
+
>
|
|
50
|
+
> Details: [conflicts](https://github.com/Leechael/pi-famulus/blob/main/docs/development.md#conflicts-with-other-packages).
|
|
51
|
+
|
|
52
|
+
```bash
|
|
10
53
|
pi install npm:pi-famulus
|
|
11
54
|
```
|
|
12
55
|
|
|
13
|
-
|
|
56
|
+
Requires pi 1.0.0 or newer. Native daemon builds ship for Linux x64 and arm64, macOS x64 and arm64 (macOS 13+), and Windows x64 and arm64. The daemon ships as an exact-version optional dependency of the npm package, so keep optional dependencies enabled. No Rust compiler or extra download is needed.
|
|
57
|
+
|
|
58
|
+
You do not write tool calls. After installing, ask the agent in plain language:
|
|
59
|
+
|
|
60
|
+
```text
|
|
61
|
+
Run the full test suite and tell me what fails.
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
`bash` waits 20 seconds. If the command is still running, it moves to the background and the turn continues. The result arrives in the conversation when the command exits.
|
|
65
|
+
|
|
66
|
+
## Features
|
|
67
|
+
|
|
68
|
+
- **Auto-backgrounding bash**: A command that runs longer than `foregroundBudgetMs` (20 seconds by default) moves to the background. Set `run_in_background` to start it there at once. Bare `sleep` is rejected.
|
|
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.
|
|
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.
|
|
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 `&`.
|
|
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.
|
|
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`.
|
|
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.
|
|
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.
|
|
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.
|
|
78
|
+
|
|
79
|
+
## Usage Guide
|
|
80
|
+
|
|
81
|
+
| Ask the agent | What happens |
|
|
82
|
+
| :--- | :--- |
|
|
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 |
|
|
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 |
|
|
85
|
+
| `Review these three modules in parallel with subagents.` | `subagent` runs the tasks in parallel; each subagent reports back when it finishes |
|
|
86
|
+
| `Which models can subagents use?` | The agent calls `subagent({action: "models"})` and lists the choices |
|
|
87
|
+
|
|
88
|
+
| In the pi TUI | Does |
|
|
89
|
+
| :--- | :--- |
|
|
90
|
+
| `/tasks` | Live list of shells, monitors and subagents; `ctrl+x` stops the selected item |
|
|
91
|
+
| `/reply <child> <text>` | Answers a subagent's question without going through the model |
|
|
92
|
+
| `/famulus` | Shows the `pi-famulus` binary path and the manager and extension versions |
|
|
93
|
+
| Fleet line (the status line below the editor) | Shows the shells, monitors and subagents that are running |
|
|
94
|
+
|
|
95
|
+
**Plan with your strongest model, implement with a cheap one.** Select your strongest model in pi, then save this as `~/.pi/agent/agents/worker.md`:
|
|
96
|
+
|
|
97
|
+
```markdown
|
|
98
|
+
---
|
|
99
|
+
name: worker
|
|
100
|
+
description: Implements one concrete task end to end
|
|
101
|
+
model: haiku
|
|
102
|
+
---
|
|
103
|
+
Do the task you were given. Make the edits and run the tests that prove it works.
|
|
104
|
+
Stay within the task's scope. Report what you changed and how you verified it.
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Now ask:
|
|
108
|
+
|
|
109
|
+
```text
|
|
110
|
+
Plan the move from callbacks to async/await in src/api/. When the plan is settled,
|
|
111
|
+
have subagents implement it one module at a time, then review what they return.
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
The session model writes the plan. `subagent` runs `worker` by default, and `worker` uses the model that `haiku` matches.
|
|
115
|
+
|
|
116
|
+
- `model` is matched against the model ids and names that pi lets you use: your `enabledModels` or `--models` scope, or every available model when no scope is set. It must match exactly one. In an agent file, no match or several matches fall back to the parent model with a warning. On a single `subagent` call, they are an error, and the model is told the candidates.
|
|
117
|
+
- Add a thinking level with a suffix, for example `model: haiku:high`.
|
|
118
|
+
- A `worker.md` in `<project>/.pi/agents/` overrides the one in your home directory. Without any `worker.md`, subagents use the parent's model.
|
|
119
|
+
- Built-in agents are `explorer` (read, grep, find, ls, bash) and `worker` (inherits your tools). Add your own as markdown files in the same folders.
|
|
120
|
+
- `subagent({action: "steer" | "resume" | "extend" | "interrupt"})` redirects a running subagent, continues a finished one, gives it more time, or stops it.
|
|
121
|
+
|
|
122
|
+
## Reliability
|
|
123
|
+
|
|
124
|
+
pi-famulus starts and stops processes for you. This section states what it guarantees and where the guarantee ends.
|
|
125
|
+
|
|
126
|
+
- On Unix, the daemon starts every shell and monitor task in a process group, and is its parent. When the daemon ends (`shutdown`, `kill -9`, panic), each group gets SIGTERM, then SIGKILL 2 seconds later. Windows tasks run in Job Objects and are terminated as a unit when stopped or when the daemon exits.
|
|
127
|
+
- A command that moves a child into a new session (`setsid`) leaves the group and escapes this. A process left running with `&` keeps running until it exits or the daemon stops it.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
133
|
+
|
|
134
|
+
## FAQ
|
|
14
135
|
|
|
15
|
-
|
|
136
|
+
- **How is pi-famulus different from `pi-subagents` or `pi-background-tasks`?** They are alternatives, so choose one. Choose [`pi-subagents`](https://github.com/nicobailon/pi-subagents) for ready-made roles, saved workflows, `/council`, or background subagents in a detached process. Choose [`pi-background-tasks`](https://github.com/ismailsaleekh/pi-background-tasks) for named background shell jobs with output files, a read-only delegated agent, or its Fusion workflows. Choose pi-famulus for one daemon that owns every shell and monitor process across your pi sessions, a shared cap on concurrent subagents, and a CLI for inspecting past runs.
|
|
137
|
+
- **Can I attach to a subagent like a tmux pane?** No. Subagents run inside the agent's process, so you cannot attach to one or take over its session. Steer, resume or interrupt it through the parent, answer its questions with `/reply`, and read what it did with `pi-famulus agent <id> -f`, `show <id>` and `top`.
|
|
138
|
+
- **Does it work outside the pi TUI?** Yes. It loads wherever pi loads extensions, including RPC mode (`pi --mode rpc`) and agents built with the pi SDK. Print mode (`pi -p`) loads it too, but exits when the first run ends, so later results are not seen. In an SDK agent, load it through `DefaultResourceLoader` (for example with `additionalExtensionPaths`) and call `session.bindExtensions()`. The real-model eval drives pi in RPC mode.
|
|
139
|
+
- **Can a subagent start its own subagents?** No. Nesting is limited to one level.
|
|
140
|
+
- **What happens to background tasks when pi exits?** Five seconds after the last pi session closes, the daemon stops whatever is still running and exits. Read the records afterwards with `pi-famulus show <id>`.
|
|
141
|
+
- **Does `/reload` stop running work?** No. Shells, monitors and subagents keep running, and their results still arrive. If the reload loads a different pi-famulus version, the old version's work is stopped first.
|
|
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.
|
|
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)).
|
|
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.
|
|
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.
|
|
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.
|
|
16
147
|
|
|
17
|
-
|
|
18
|
-
|---|---|---|
|
|
19
|
-
| Linux | x64 | `pi-famulus-linux-x64` |
|
|
20
|
-
| Linux | arm64 | `pi-famulus-linux-arm64` |
|
|
21
|
-
| macOS | Intel x64 | `pi-famulus-darwin-x64` |
|
|
22
|
-
| macOS | Apple Silicon arm64 | `pi-famulus-darwin-arm64` |
|
|
23
|
-
| Windows | x64 | `pi-famulus-win-x64` |
|
|
148
|
+
## Docs
|
|
24
149
|
|
|
25
|
-
|
|
150
|
+
- [Tool reference](https://github.com/Leechael/pi-famulus/blob/main/docs/tools.md): parameters, agent files, terminal UI
|
|
151
|
+
- [Configuration](https://github.com/Leechael/pi-famulus/blob/main/docs/configuration.md): `config.json`, timeouts, binary lookup, degraded mode
|
|
152
|
+
- [CLI manual](https://github.com/Leechael/pi-famulus/blob/main/docs/cli.md): `doctor`, `ls`, `show`, `top`, `agent`, `stats`, `events`
|
|
153
|
+
- [Architecture](https://github.com/Leechael/pi-famulus/blob/main/docs/design.md): daemon, lifeline, wakes, subagent runner
|
|
154
|
+
- [Machine-wide capacity](https://github.com/Leechael/pi-famulus/blob/main/docs/global-capacity.md): `max-agents` and work-kind limits
|
|
155
|
+
- [Eval results](https://github.com/Leechael/pi-famulus/blob/main/eval/RESULTS.md): real-model scenario matrix
|
|
156
|
+
- [Development](https://github.com/Leechael/pi-famulus/blob/main/docs/development.md): building from source, tests, package conflicts
|
|
157
|
+
- [Releasing](https://github.com/Leechael/pi-famulus/blob/main/docs/releasing.md)
|
|
26
158
|
|
|
27
|
-
|
|
159
|
+
## Background
|
|
28
160
|
|
|
29
|
-
|
|
30
|
-
2. Executable `PI_FAMULUS_MANAGER_PATH` override.
|
|
31
|
-
3. The matching-version installed native npm package.
|
|
32
|
-
4. Executable `~/.pi/agent/pi-famulus/bin/pi-famulus` (or the equivalent beneath `PI_FAMULUS_HOME`).
|
|
33
|
-
5. Executable `pi-famulus` on `PATH`.
|
|
161
|
+
pi-famulus borrows several behaviors from other agents. From Claude Code it takes background-on-timeout instead of kill, completion injected into the conversation, a batched `monitor`, and bare-`sleep` interception. From grok-build it takes a 45-second wait budget for subagents and an admission queue that waits instead of rejecting. From lain it takes foreground-only bash for child agents. It also takes lessons from `pi-subagents` and `pi-intercom` about what to leave out. The research notes are in [design, section 1](https://github.com/Leechael/pi-famulus/blob/main/docs/design.md#1-background-and-goals).
|
|
34
162
|
|
|
35
|
-
|
|
163
|
+
## Support
|
|
36
164
|
|
|
37
|
-
|
|
165
|
+
- If pi-famulus helped you, give it a star, or open an [issue](https://github.com/Leechael/pi-famulus/issues) or a pull request.
|
|
166
|
+
- When you report a problem, include the output of `pi-famulus doctor` and `pi-famulus events --id <id>`.
|
|
38
167
|
|
|
39
|
-
|
|
168
|
+
## License
|
|
40
169
|
|
|
41
|
-
|
|
170
|
+
Released under the [MIT License](https://github.com/Leechael/pi-famulus/blob/main/LICENSE).
|
package/package.json
CHANGED
|
@@ -1,9 +1,32 @@
|
|
|
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
|
+
"keywords": [
|
|
6
|
+
"pi-package",
|
|
7
|
+
"pi",
|
|
8
|
+
"pi-extension",
|
|
9
|
+
"pi-coding-agent",
|
|
10
|
+
"subagent",
|
|
11
|
+
"subagents",
|
|
12
|
+
"background",
|
|
13
|
+
"background-tasks",
|
|
14
|
+
"bash",
|
|
15
|
+
"daemon",
|
|
16
|
+
"multi-agent",
|
|
17
|
+
"monitor",
|
|
18
|
+
"agent-orchestration"
|
|
19
|
+
],
|
|
20
|
+
"author": "Leechael Yim",
|
|
21
|
+
"homepage": "https://github.com/Leechael/pi-famulus",
|
|
22
|
+
"bugs": {
|
|
23
|
+
"url": "https://github.com/Leechael/pi-famulus/issues"
|
|
24
|
+
},
|
|
5
25
|
"type": "module",
|
|
6
26
|
"license": "MIT",
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=22.19.0"
|
|
29
|
+
},
|
|
7
30
|
"repository": {
|
|
8
31
|
"type": "git",
|
|
9
32
|
"url": "https://github.com/Leechael/pi-famulus",
|
|
@@ -31,11 +54,12 @@
|
|
|
31
54
|
"pi-famulus": "./bin/pi-famulus.js"
|
|
32
55
|
},
|
|
33
56
|
"optionalDependencies": {
|
|
34
|
-
"pi-famulus-linux-x64": "0.1.
|
|
35
|
-
"pi-famulus-linux-arm64": "0.1.
|
|
36
|
-
"pi-famulus-darwin-x64": "0.1.
|
|
37
|
-
"pi-famulus-darwin-arm64": "0.1.
|
|
38
|
-
"pi-famulus-win-x64": "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"
|
|
39
63
|
},
|
|
40
64
|
"pi": {
|
|
41
65
|
"extensions": [
|
|
@@ -46,6 +70,7 @@
|
|
|
46
70
|
"deps": "nub install --frozen-lockfile --node-linker hoisted",
|
|
47
71
|
"prompts": "node scripts/build-prompts.ts",
|
|
48
72
|
"test": "vitest run",
|
|
73
|
+
"test:compat": "vitest run tests/unit/impl-child-resource-isolation.test.ts tests/unit/impl-child-tools.test.ts tests/unit/impl-child-guidelines.test.ts tests/unit/impl-subagent-lifecycle.test.ts tests/unit/impl-steering-drain.test.ts tests/unit/impl-model-spec.test.ts tests/unit/impl-eval-fixes.test.ts tests/integration/behavior-guidelines.test.ts",
|
|
49
74
|
"typecheck": "tsc --noEmit"
|
|
50
75
|
},
|
|
51
76
|
"devDependencies": {
|
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 {
|