pi-famulus 0.1.4 → 0.1.7
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 +148 -23
- package/bin/pi-famulus.js +1 -1
- package/package.json +34 -7
- package/src/bash-override.ts +23 -8
- package/src/config.ts +43 -7
- package/src/index.ts +93 -16
- package/src/manager-client.ts +23 -4
- package/src/native-manager.js +6 -3
- package/src/reload-handoff.ts +137 -0
- package/src/tui/famulus-command.ts +71 -0
- package/src/tui/tasks-command.ts +0 -4
package/README.md
CHANGED
|
@@ -1,38 +1,163 @@
|
|
|
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>Let your agent delegate, and keep working.</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 it never has to poll. One daemon owns every shell 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
|
+
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.
|
|
8
26
|
|
|
9
|
-
|
|
27
|
+
Three things go wrong when an agent has to wait:
|
|
28
|
+
|
|
29
|
+
- **A foreground watch blocks the chat.** With pi-famulus, `bash` moves a command to the background after 20 seconds, and the turn goes on.
|
|
30
|
+
- **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.
|
|
31
|
+
- **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 `&`.
|
|
32
|
+
|
|
33
|
+
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.
|
|
34
|
+
|
|
35
|
+
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
|
+
|
|
37
|
+
## Quick Start
|
|
38
|
+
|
|
39
|
+
> **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.
|
|
40
|
+
>
|
|
41
|
+
> - `pi-subagents` also registers `subagent`. Do not install it alongside pi-famulus.
|
|
42
|
+
> - `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.
|
|
43
|
+
>
|
|
44
|
+
> Details: [conflicts](https://github.com/Leechael/pi-famulus/blob/main/docs/development.md#conflicts-with-other-packages).
|
|
45
|
+
|
|
46
|
+
```bash
|
|
10
47
|
pi install npm:pi-famulus
|
|
11
48
|
```
|
|
12
49
|
|
|
13
|
-
|
|
50
|
+
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.
|
|
51
|
+
|
|
52
|
+
You do not write tool calls. After installing, ask the agent in plain language:
|
|
53
|
+
|
|
54
|
+
```text
|
|
55
|
+
Run the full test suite and tell me what fails.
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
`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.
|
|
59
|
+
|
|
60
|
+
## Features
|
|
61
|
+
|
|
62
|
+
- **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.
|
|
63
|
+
- **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
|
+
- **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
|
+
- **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.
|
|
67
|
+
- **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
|
+
- **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.
|
|
70
|
+
- **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
|
+
|
|
72
|
+
## Usage Guide
|
|
73
|
+
|
|
74
|
+
| Ask the agent | What happens |
|
|
75
|
+
| :--- | :--- |
|
|
76
|
+
| `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 |
|
|
78
|
+
| `Review these three modules in parallel with subagents.` | `subagent` runs the tasks in parallel; each subagent reports back when it finishes |
|
|
79
|
+
| `Which models can subagents use?` | The agent calls `subagent({action: "models"})` and lists the choices |
|
|
80
|
+
|
|
81
|
+
| In the pi TUI | Does |
|
|
82
|
+
| :--- | :--- |
|
|
83
|
+
| `/tasks` | Live list of shells, monitors and subagents; `ctrl+x` stops the selected item |
|
|
84
|
+
| `/reply <child> <text>` | Answers a subagent's question without going through the model |
|
|
85
|
+
| `/famulus` | Shows the `pi-famulus` binary path and the manager and extension versions |
|
|
86
|
+
| Fleet line (the status line below the editor) | Shows the shells, monitors and subagents that are running |
|
|
87
|
+
|
|
88
|
+
**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`:
|
|
89
|
+
|
|
90
|
+
```markdown
|
|
91
|
+
---
|
|
92
|
+
name: worker
|
|
93
|
+
description: Implements one concrete task end to end
|
|
94
|
+
model: haiku
|
|
95
|
+
---
|
|
96
|
+
Do the task you were given. Make the edits and run the tests that prove it works.
|
|
97
|
+
Stay within the task's scope. Report what you changed and how you verified it.
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Now ask:
|
|
101
|
+
|
|
102
|
+
```text
|
|
103
|
+
Plan the move from callbacks to async/await in src/api/. When the plan is settled,
|
|
104
|
+
have subagents implement it one module at a time, then review what they return.
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
The session model writes the plan. `subagent` runs `worker` by default, and `worker` uses the model that `haiku` matches.
|
|
108
|
+
|
|
109
|
+
- `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.
|
|
110
|
+
- Add a thinking level with a suffix, for example `model: haiku:high`.
|
|
111
|
+
- A `worker.md` in `<project>/.pi/agents/` overrides the one in your home directory. Without any `worker.md`, subagents use the parent's model.
|
|
112
|
+
- 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.
|
|
113
|
+
- `subagent({action: "steer" | "resume" | "extend" | "interrupt"})` redirects a running subagent, continues a finished one, gives it more time, or stops it.
|
|
114
|
+
|
|
115
|
+
## Reliability
|
|
116
|
+
|
|
117
|
+
pi-famulus starts and stops processes for you. This section states what it guarantees and where the guarantee ends.
|
|
118
|
+
|
|
119
|
+
- 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.
|
|
120
|
+
- 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.
|
|
121
|
+
- 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
|
+
- 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
|
+
- 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.
|
|
125
|
+
- 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
|
+
|
|
127
|
+
## FAQ
|
|
128
|
+
|
|
129
|
+
- **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.
|
|
130
|
+
- **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`.
|
|
131
|
+
- **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.
|
|
132
|
+
- **Can a subagent start its own subagents?** No. Nesting is limited to one level.
|
|
133
|
+
- **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>`.
|
|
134
|
+
- **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.
|
|
135
|
+
- **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
|
+
- **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
|
+
- **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.
|
|
139
|
+
- **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.
|
|
14
140
|
|
|
15
|
-
|
|
141
|
+
## Docs
|
|
16
142
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
143
|
+
- [Tool reference](https://github.com/Leechael/pi-famulus/blob/main/docs/tools.md): parameters, agent files, terminal UI
|
|
144
|
+
- [Configuration](https://github.com/Leechael/pi-famulus/blob/main/docs/configuration.md): `config.json`, timeouts, binary lookup, degraded mode
|
|
145
|
+
- [CLI manual](https://github.com/Leechael/pi-famulus/blob/main/docs/cli.md): `doctor`, `ls`, `show`, `top`, `agent`, `stats`, `events`
|
|
146
|
+
- [Architecture](https://github.com/Leechael/pi-famulus/blob/main/docs/design.md): daemon, lifeline, wakes, subagent runner
|
|
147
|
+
- [Machine-wide capacity](https://github.com/Leechael/pi-famulus/blob/main/docs/global-capacity.md): `max-agents` and work-kind limits
|
|
148
|
+
- [Eval results](https://github.com/Leechael/pi-famulus/blob/main/eval/RESULTS.md): real-model scenario matrix
|
|
149
|
+
- [Development](https://github.com/Leechael/pi-famulus/blob/main/docs/development.md): building from source, tests, package conflicts
|
|
150
|
+
- [Releasing](https://github.com/Leechael/pi-famulus/blob/main/docs/releasing.md)
|
|
23
151
|
|
|
24
|
-
|
|
152
|
+
## Background
|
|
25
153
|
|
|
26
|
-
|
|
154
|
+
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).
|
|
27
155
|
|
|
28
|
-
|
|
29
|
-
2. Executable `PI_FAMULUS_MANAGER_PATH` override.
|
|
30
|
-
3. The matching-version installed native npm package.
|
|
31
|
-
4. Executable `~/.pi/agent/pi-famulus/bin/pi-famulus` (or the equivalent beneath `PI_FAMULUS_HOME`).
|
|
32
|
-
5. Executable `pi-famulus` on `PATH`.
|
|
156
|
+
## Support
|
|
33
157
|
|
|
34
|
-
|
|
158
|
+
- If pi-famulus helped you, give it a star, or open an [issue](https://github.com/Leechael/pi-famulus/issues) or a pull request.
|
|
159
|
+
- When you report a problem, include the output of `pi-famulus doctor` and `pi-famulus events --id <id>`.
|
|
35
160
|
|
|
36
|
-
|
|
161
|
+
## License
|
|
37
162
|
|
|
38
|
-
|
|
163
|
+
Released under the [MIT License](https://github.com/Leechael/pi-famulus/blob/main/LICENSE).
|
package/bin/pi-famulus.js
CHANGED
|
@@ -7,7 +7,7 @@ if (!binary) {
|
|
|
7
7
|
const name = nativePackageName(process.platform, process.arch);
|
|
8
8
|
console.error(name
|
|
9
9
|
? `pi-famulus: missing usable ${name}. Reinstall pi-famulus with optional dependencies enabled (npm install --include=optional pi-famulus).`
|
|
10
|
-
: `pi-famulus: unsupported ${process.platform}/${process.arch}; only Linux/macOS x64/arm64 are supported.`);
|
|
10
|
+
: `pi-famulus: unsupported ${process.platform}/${process.arch}; only Linux/macOS/Windows x64/arm64 are supported.`);
|
|
11
11
|
process.exit(1);
|
|
12
12
|
}
|
|
13
13
|
const child = spawn(binary, process.argv.slice(2), { stdio: "inherit" });
|
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.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.",
|
|
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",
|
|
@@ -15,7 +38,8 @@
|
|
|
15
38
|
},
|
|
16
39
|
"os": [
|
|
17
40
|
"linux",
|
|
18
|
-
"darwin"
|
|
41
|
+
"darwin",
|
|
42
|
+
"win32"
|
|
19
43
|
],
|
|
20
44
|
"cpu": [
|
|
21
45
|
"x64",
|
|
@@ -30,10 +54,12 @@
|
|
|
30
54
|
"pi-famulus": "./bin/pi-famulus.js"
|
|
31
55
|
},
|
|
32
56
|
"optionalDependencies": {
|
|
33
|
-
"pi-famulus-linux-x64": "0.1.
|
|
34
|
-
"pi-famulus-linux-arm64": "0.1.
|
|
35
|
-
"pi-famulus-darwin-x64": "0.1.
|
|
36
|
-
"pi-famulus-darwin-arm64": "0.1.
|
|
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"
|
|
37
63
|
},
|
|
38
64
|
"pi": {
|
|
39
65
|
"extensions": [
|
|
@@ -44,6 +70,7 @@
|
|
|
44
70
|
"deps": "nub install --frozen-lockfile --node-linker hoisted",
|
|
45
71
|
"prompts": "node scripts/build-prompts.ts",
|
|
46
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",
|
|
47
74
|
"typecheck": "tsc --noEmit"
|
|
48
75
|
},
|
|
49
76
|
"devDependencies": {
|
package/src/bash-override.ts
CHANGED
|
@@ -18,11 +18,12 @@ import { writeFileSync } from "node:fs";
|
|
|
18
18
|
import { tmpdir } from "node:os";
|
|
19
19
|
import { join } from "node:path";
|
|
20
20
|
import { Type, type Static } from "typebox";
|
|
21
|
-
import
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
21
|
+
import {
|
|
22
|
+
getShellConfig,
|
|
23
|
+
type AgentToolResult,
|
|
24
|
+
type BashToolDetails,
|
|
25
|
+
type ExtensionContext,
|
|
26
|
+
type ToolDefinition,
|
|
26
27
|
} from "@earendil-works/pi-coding-agent";
|
|
27
28
|
import { taskOutputPath, type FamulusConfig } from "./config";
|
|
28
29
|
import { realClock, type Clock, type ClockTimer } from "./clock";
|
|
@@ -166,15 +167,29 @@ async function executeLocal(
|
|
|
166
167
|
): Promise<AgentToolResult<FamulusBashDetails | undefined>> {
|
|
167
168
|
const timeoutMs = resolveTimeoutMs(params.timeout);
|
|
168
169
|
const startedAtMs = clock.now();
|
|
169
|
-
|
|
170
|
+
// pi's own choice (Git Bash on Windows, /bin/bash on Unix): commands are
|
|
171
|
+
// written for pi's bash tool, not for whatever $SHELL is.
|
|
172
|
+
let shell: ReturnType<typeof getShellConfig>;
|
|
173
|
+
try {
|
|
174
|
+
shell = getShellConfig();
|
|
175
|
+
} catch (err) {
|
|
176
|
+
throw new Error(`Failed to start shell: ${(err as Error).message}`);
|
|
177
|
+
}
|
|
178
|
+
const viaStdin = shell.commandTransport === "stdin";
|
|
170
179
|
|
|
171
180
|
const output = await new Promise<{ text: string; exitCode: number | null; signal: string | null; timedOut: boolean; aborted: boolean }>(
|
|
172
181
|
(resolve, reject) => {
|
|
173
|
-
const child = spawn(shell, [
|
|
182
|
+
const child = spawn(shell.shell, viaStdin ? shell.args : [...shell.args, params.command], {
|
|
174
183
|
cwd: ctx.cwd,
|
|
175
184
|
env: process.env,
|
|
176
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
185
|
+
stdio: [viaStdin ? "pipe" : "ignore", "pipe", "pipe"],
|
|
186
|
+
windowsHide: true,
|
|
177
187
|
});
|
|
188
|
+
if (viaStdin) {
|
|
189
|
+
// Swallow EPIPE if the shell exits before consuming stdin (pi's runner does the same).
|
|
190
|
+
child.stdin?.on("error", () => {});
|
|
191
|
+
child.stdin?.end(params.command);
|
|
192
|
+
}
|
|
178
193
|
const chunks: Buffer[] = [];
|
|
179
194
|
let settled = false;
|
|
180
195
|
let timedOut = false;
|
package/src/config.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { accessSync, constants, readFileSync, statSync } from "node:fs";
|
|
8
8
|
import { homedir } from "node:os";
|
|
9
|
-
import { delimiter, join } from "node:path";
|
|
9
|
+
import { delimiter, join, win32 } from "node:path";
|
|
10
10
|
import { nativePackageName, resolveNativeManagerPath } from "./native-manager.js";
|
|
11
11
|
|
|
12
12
|
export interface FamulusConfig {
|
|
@@ -141,11 +141,43 @@ export function getFamulusHome(env: NodeJS.ProcessEnv = process.env): string {
|
|
|
141
141
|
return join(homedir(), ".pi", "agent", "pi-famulus");
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
+
/** FNV-1a 64-bit over UTF-8 bytes — must match manager `sys::fnv1a64`. */
|
|
145
|
+
export function fnv1a64(input: string): string {
|
|
146
|
+
let h = 0xcbf29ce484222325n;
|
|
147
|
+
for (const byte of Buffer.from(input, "utf8")) {
|
|
148
|
+
h ^= BigInt(byte);
|
|
149
|
+
h = (h * 0x0100000001b3n) & 0xffffffffffffffffn;
|
|
150
|
+
}
|
|
151
|
+
return h.toString(16);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Named-pipe identity of a home — must match manager `lifecycle::windows_pipe_ident`.
|
|
156
|
+
* The home is made absolute lexically (`path.win32.resolve` against `cwd`):
|
|
157
|
+
* `.` / `..` and repeated separators collapse, symlinks and junctions are
|
|
158
|
+
* not expanded. One spelling per directory (backslashes, no trailing
|
|
159
|
+
* separator except a drive root, lower case). The same relative home from
|
|
160
|
+
* two working directories must not share a pipe.
|
|
161
|
+
*/
|
|
162
|
+
export function windowsHomeKey(home: string, cwd: string = process.cwd()): string {
|
|
163
|
+
let s = win32.resolve(cwd, home);
|
|
164
|
+
while (s.length > 3 && s.endsWith("\\")) s = s.slice(0, -1);
|
|
165
|
+
return s.toLowerCase();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function windowsPipeName(home: string, cwd: string = process.cwd()): string {
|
|
169
|
+
return `\\\\.\\pipe\\pi-famulus-${fnv1a64(windowsHomeKey(home, cwd))}`;
|
|
170
|
+
}
|
|
171
|
+
|
|
144
172
|
/** Well-known paths inside the pi-famulus home directory (design doc §3.1). */
|
|
145
|
-
export function famulusPaths(home: string) {
|
|
173
|
+
export function famulusPaths(home: string, platform: NodeJS.Platform = process.platform) {
|
|
174
|
+
const socket =
|
|
175
|
+
platform === "win32"
|
|
176
|
+
? windowsPipeName(home)
|
|
177
|
+
: join(home, "manager.sock");
|
|
146
178
|
return {
|
|
147
179
|
home,
|
|
148
|
-
socket
|
|
180
|
+
socket,
|
|
149
181
|
pidFile: join(home, "manager.pid"),
|
|
150
182
|
spawnLock: join(home, "manager.spawn.lock"),
|
|
151
183
|
log: join(home, "manager.log"),
|
|
@@ -225,6 +257,9 @@ export function loadConfig(home: string = getFamulusHome()): FamulusConfig {
|
|
|
225
257
|
return config;
|
|
226
258
|
}
|
|
227
259
|
|
|
260
|
+
/** File name of the manager in <home>/bin and on PATH: Windows only starts `.exe` images. */
|
|
261
|
+
export const MANAGER_FILE_NAME = process.platform === "win32" ? "pi-famulus.exe" : "pi-famulus";
|
|
262
|
+
|
|
228
263
|
/** Return why a candidate is unusable, or null for an executable regular file. */
|
|
229
264
|
function managerCandidateProblem(path: string): string | null {
|
|
230
265
|
try {
|
|
@@ -260,14 +295,15 @@ export function describeManagerSearch(
|
|
|
260
295
|
tried.push(nativeName
|
|
261
296
|
? `npm ${nativeName}${native ? ` ${native}` : " (missing or unusable, ignored)"}`
|
|
262
297
|
: `npm native manager (unsupported ${process.platform}/${process.arch})`);
|
|
263
|
-
tried.push(describeCandidate(join(home, "bin",
|
|
298
|
+
tried.push(describeCandidate(join(home, "bin", MANAGER_FILE_NAME)));
|
|
264
299
|
tried.push("pi-famulus on PATH");
|
|
265
300
|
return tried.join("; ");
|
|
266
301
|
}
|
|
267
302
|
|
|
268
303
|
/**
|
|
269
304
|
* Resolve an executable regular pi-famulus binary, or null if none is usable.
|
|
270
|
-
* Priority: config.managerPath > PI_FAMULUS_MANAGER_PATH env > npm native package > <home>/bin/pi-famulus > PATH
|
|
305
|
+
* Priority: config.managerPath > PI_FAMULUS_MANAGER_PATH env > npm native package > <home>/bin/pi-famulus > PATH
|
|
306
|
+
* (`pi-famulus.exe` on Windows).
|
|
271
307
|
*/
|
|
272
308
|
export function resolveManagerPath(
|
|
273
309
|
config: FamulusConfig,
|
|
@@ -279,11 +315,11 @@ export function resolveManagerPath(
|
|
|
279
315
|
if (envPath && managerCandidateProblem(envPath) === null) return envPath;
|
|
280
316
|
const native = resolveNativeManagerPath();
|
|
281
317
|
if (native) return native;
|
|
282
|
-
const bundled = join(home, "bin",
|
|
318
|
+
const bundled = join(home, "bin", MANAGER_FILE_NAME);
|
|
283
319
|
if (managerCandidateProblem(bundled) === null) return bundled;
|
|
284
320
|
for (const dir of (env.PATH ?? "").split(delimiter)) {
|
|
285
321
|
if (!dir) continue;
|
|
286
|
-
const candidate = join(dir,
|
|
322
|
+
const candidate = join(dir, MANAGER_FILE_NAME);
|
|
287
323
|
if (managerCandidateProblem(candidate) === null) return candidate;
|
|
288
324
|
}
|
|
289
325
|
return null;
|
package/src/index.ts
CHANGED
|
@@ -25,7 +25,7 @@ import { createAgentMessageTool, createContactSupervisorTool } from "./comms/too
|
|
|
25
25
|
import { registerReplyCommand } from "./comms/reply-command";
|
|
26
26
|
import { describeManagerSearch, getFamulusHome, loadConfig, resolveManagerPath, resolveSubagentConfig } from "./config";
|
|
27
27
|
import type { TaskExitInfo } from "./format";
|
|
28
|
-
import { ManagerClient, type ManagerEvent, type TaskRecord } from "./manager-client";
|
|
28
|
+
import { EXTENSION_VERSION, ManagerClient, type ManagerEvent, type TaskRecord } from "./manager-client";
|
|
29
29
|
import { createMonitorTool, exitEventFromRecord, MonitorRegistry } from "./monitor";
|
|
30
30
|
import { admitAgentChild, reregisterAgentLeases } from "./subagent/admission";
|
|
31
31
|
import { NotifyCenter } from "./notify";
|
|
@@ -48,6 +48,8 @@ import { SubagentRegistry } from "./subagent/registry";
|
|
|
48
48
|
import { InProcessRunner } from "./subagent/runner";
|
|
49
49
|
import { createSubagentTool } from "./subagent/tool";
|
|
50
50
|
import { createTaskListTool, createTaskOutputTool, createTaskStopTool } from "./task-tools";
|
|
51
|
+
import { createPiBinding, parkRuntime, takeParkedRuntime } from "./reload-handoff";
|
|
52
|
+
import { registerFamulusCommand } from "./tui/famulus-command";
|
|
51
53
|
import { registerFamulusMessageRenderers } from "./tui/message-renderers";
|
|
52
54
|
import { registerTasksCommand } from "./tui/tasks-command";
|
|
53
55
|
import { stderrPathFor } from "./tui/task-output-paths";
|
|
@@ -69,8 +71,31 @@ function toExitStatus(event: ManagerEvent): TaskExitInfo["status"] {
|
|
|
69
71
|
return "failed";
|
|
70
72
|
}
|
|
71
73
|
|
|
74
|
+
interface FamulusRuntime {
|
|
75
|
+
attach(pi: ExtensionAPI): void;
|
|
76
|
+
}
|
|
77
|
+
|
|
72
78
|
export default function (pi: ExtensionAPI): void {
|
|
73
79
|
if (isFamulusChildSession(pi)) return;
|
|
80
|
+
// A /reload parked the previous instance's runtime: adopt it so its shells,
|
|
81
|
+
// monitors and subagents keep running (see reload-handoff.ts). Another
|
|
82
|
+
// version's runtime is torn down instead; its code may not match ours.
|
|
83
|
+
const parked = takeParkedRuntime<FamulusRuntime>();
|
|
84
|
+
if (parked?.version === EXTENSION_VERSION) {
|
|
85
|
+
parked.runtime.attach(pi);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const retiring = parked?.dispose().catch(() => {});
|
|
89
|
+
createFamulusRuntime(retiring).attach(pi);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Everything one pi session owns. It registers on a PiBinding rather than on
|
|
94
|
+
* pi directly, so a /reload can re-attach it to the next extension instance.
|
|
95
|
+
*/
|
|
96
|
+
function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
|
|
97
|
+
const binding = createPiBinding();
|
|
98
|
+
const pi = binding.api;
|
|
74
99
|
registerBehaviorGuidelines(pi);
|
|
75
100
|
const home = getFamulusHome();
|
|
76
101
|
const config = loadConfig(home);
|
|
@@ -366,6 +391,12 @@ export default function (pi: ExtensionAPI): void {
|
|
|
366
391
|
clock,
|
|
367
392
|
syncWithManager: deps.syncWithManager,
|
|
368
393
|
});
|
|
394
|
+
registerFamulusCommand(pi, {
|
|
395
|
+
extensionVersion: EXTENSION_VERSION,
|
|
396
|
+
managerPath,
|
|
397
|
+
describeSearch: () => describeManagerSearch(config, home),
|
|
398
|
+
getClient: () => client,
|
|
399
|
+
});
|
|
369
400
|
monitorRegistry.onChange(() => {
|
|
370
401
|
for (const mon of monitorRegistry.listActive()) {
|
|
371
402
|
const existing = workIndex.get(mon.taskId);
|
|
@@ -432,8 +463,37 @@ export default function (pi: ExtensionAPI): void {
|
|
|
432
463
|
// each child session via customTools, see below).
|
|
433
464
|
pi.registerTool(createAgentMessageTool(comms, { kind: "parent" }, commsHost, clock));
|
|
434
465
|
|
|
435
|
-
|
|
466
|
+
/** Session whose runtime a /reload parked; the reloaded instance's session_start resumes it. */
|
|
467
|
+
let parkedSessionId: string | null = null;
|
|
468
|
+
const startFleetWidget = (startCtx: ExtensionContext) => {
|
|
469
|
+
if (!startCtx.hasUI) return;
|
|
470
|
+
fleetWidget = new FleetWidget({
|
|
471
|
+
index: workIndex,
|
|
472
|
+
getUi: () => (ctx?.hasUI ? (ctx.ui as never) : null),
|
|
473
|
+
clock,
|
|
474
|
+
});
|
|
475
|
+
fleetWidget.start();
|
|
476
|
+
};
|
|
477
|
+
|
|
478
|
+
pi.on("session_start", async (event, startCtx) => {
|
|
479
|
+
if (parkedSessionId !== null) {
|
|
480
|
+
const resumed = event.reason === "reload" && startCtx.sessionManager.getSessionId() === parkedSessionId;
|
|
481
|
+
parkedSessionId = null;
|
|
482
|
+
if (resumed) {
|
|
483
|
+
// Same session after /reload: the client, registry and monitors kept
|
|
484
|
+
// running; only the context and the UI belong to the new instance.
|
|
485
|
+
ctx = startCtx;
|
|
486
|
+
binding.ready();
|
|
487
|
+
startFleetWidget(startCtx);
|
|
488
|
+
void syncWithManager();
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
binding.discardQueued();
|
|
492
|
+
await teardown();
|
|
493
|
+
}
|
|
494
|
+
await retiring;
|
|
436
495
|
ctx = startCtx;
|
|
496
|
+
binding.ready();
|
|
437
497
|
notifyCenter?.dispose();
|
|
438
498
|
notifyCenter = new NotifyCenter({
|
|
439
499
|
sendMessage: (msg, opts) => pi.sendMessage(msg, opts),
|
|
@@ -586,11 +646,15 @@ export default function (pi: ExtensionAPI): void {
|
|
|
586
646
|
return tools;
|
|
587
647
|
},
|
|
588
648
|
});
|
|
649
|
+
// The registry outlives startCtx across a /reload, which leaves startCtx
|
|
650
|
+
// stale; its closures keep plain values instead.
|
|
651
|
+
const sessionId = startCtx.sessionManager.getSessionId();
|
|
652
|
+
const sessionCwd = startCtx.cwd;
|
|
589
653
|
const transcripts = new TranscriptWriter(home, clock);
|
|
590
654
|
const syncTranscript = (childId: string): string | undefined => {
|
|
591
655
|
const handle = registry.handle(childId);
|
|
592
656
|
if (!handle) return undefined;
|
|
593
|
-
return transcripts.sync(
|
|
657
|
+
return transcripts.sync(sessionId, childId, handle.conversation());
|
|
594
658
|
};
|
|
595
659
|
const runner = new InProcessRunner({
|
|
596
660
|
createSession,
|
|
@@ -657,7 +721,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
657
721
|
registry.setRunner(runner);
|
|
658
722
|
subagentRegistry = registry;
|
|
659
723
|
// Persist child records so `pi-famulus ls` / task_list can see in-process agents.
|
|
660
|
-
const sessionIdForAgents = () =>
|
|
724
|
+
const sessionIdForAgents = () => sessionId;
|
|
661
725
|
const previousAgentStatus = new Map<string, string>();
|
|
662
726
|
registry.onTransition((run) => {
|
|
663
727
|
const sid = sessionIdForAgents();
|
|
@@ -743,7 +807,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
743
807
|
workKind: c.workKind ?? "other",
|
|
744
808
|
...(c.queueMs !== undefined ? { queueMs: c.queueMs } : {}),
|
|
745
809
|
...(c.model !== undefined ? { model: c.model } : {}),
|
|
746
|
-
cwd:
|
|
810
|
+
cwd: sessionCwd,
|
|
747
811
|
...(c.prompt !== undefined ? { prompt: c.prompt } : {}),
|
|
748
812
|
...(c.preamble !== undefined ? { preamble: c.preamble } : {}),
|
|
749
813
|
...(c.result?.text ? { text: c.result.text } : {}),
|
|
@@ -751,14 +815,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
751
815
|
});
|
|
752
816
|
}
|
|
753
817
|
});
|
|
754
|
-
|
|
755
|
-
fleetWidget = new FleetWidget({
|
|
756
|
-
index: workIndex,
|
|
757
|
-
getUi: () => (ctx?.hasUI ? (ctx.ui as never) : null),
|
|
758
|
-
clock,
|
|
759
|
-
});
|
|
760
|
-
fleetWidget.start();
|
|
761
|
-
}
|
|
818
|
+
startFleetWidget(startCtx);
|
|
762
819
|
|
|
763
820
|
// Connect in the background: a cold manager spawn must not stall the
|
|
764
821
|
// first prompt. Tools call ensureAvailable() lazily before use.
|
|
@@ -770,11 +827,13 @@ export default function (pi: ExtensionAPI): void {
|
|
|
770
827
|
if (c.protocolLevel() >= 4) void reconcileAgentLeases(c).catch(() => {});
|
|
771
828
|
return;
|
|
772
829
|
}
|
|
773
|
-
|
|
830
|
+
// A /reload may have replaced startCtx by now; report to the live one.
|
|
831
|
+
const current = ctx;
|
|
832
|
+
if (current?.hasUI) {
|
|
774
833
|
const detail = c.lastError();
|
|
775
834
|
const reason = detail ? ` (${detail})` : "";
|
|
776
835
|
const searched = managerPath ? `using ${managerPath}` : `looked in: ${describeManagerSearch(config, home)}`;
|
|
777
|
-
|
|
836
|
+
current.ui.notify(
|
|
778
837
|
`pi-famulus unavailable${reason}; ${searched}. ` +
|
|
779
838
|
"Bash runs locally without auto-backgrounding, task_*/monitor are disabled, " +
|
|
780
839
|
"and subagents cannot run bash. Install it or set PI_FAMULUS_MANAGER_PATH (see README Install).",
|
|
@@ -785,7 +844,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
785
844
|
.catch(() => {});
|
|
786
845
|
});
|
|
787
846
|
|
|
788
|
-
|
|
847
|
+
const teardown = async (): Promise<void> => {
|
|
789
848
|
exitWatchdog.dispose();
|
|
790
849
|
fleetWidget?.dispose();
|
|
791
850
|
fleetWidget = null;
|
|
@@ -807,6 +866,22 @@ export default function (pi: ExtensionAPI): void {
|
|
|
807
866
|
}
|
|
808
867
|
}
|
|
809
868
|
ctx = null;
|
|
869
|
+
};
|
|
870
|
+
const runtime: FamulusRuntime = { attach: (host) => binding.attach(host) };
|
|
871
|
+
|
|
872
|
+
pi.on("session_shutdown", async (event) => {
|
|
873
|
+
if (event.reason === "reload" && ctx) {
|
|
874
|
+
// Keep the session's shells, monitors and subagents running; the
|
|
875
|
+
// reloaded instance adopts this runtime.
|
|
876
|
+
parkedSessionId = ctx.sessionManager.getSessionId();
|
|
877
|
+
fleetWidget?.dispose();
|
|
878
|
+
fleetWidget = null;
|
|
879
|
+
ctx = null;
|
|
880
|
+
binding.detach();
|
|
881
|
+
parkRuntime({ version: EXTENSION_VERSION, runtime, dispose: teardown });
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
await teardown();
|
|
810
885
|
});
|
|
811
886
|
|
|
812
887
|
pi.on("agent_settled", async () => {
|
|
@@ -822,4 +897,6 @@ export default function (pi: ExtensionAPI): void {
|
|
|
822
897
|
});
|
|
823
898
|
return message ? { message } : undefined;
|
|
824
899
|
});
|
|
900
|
+
|
|
901
|
+
return runtime;
|
|
825
902
|
}
|
package/src/manager-client.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* lifetime-lock holder cleans up stale socket/pid files.
|
|
6
6
|
* - Request/response multiplexing over a single long-lived connection.
|
|
7
7
|
* - Server events dispatched to registered handlers.
|
|
8
|
-
* - On unexpected disconnect: immediate then exponential-backoff reconnect for
|
|
8
|
+
* - On unexpected disconnect: immediate then exponential-backoff reconnect for 27s,
|
|
9
9
|
* re-hello after reconnect. If all attempts fail the client is marked unavailable
|
|
10
10
|
* and callers are expected to degrade (bash falls back to local execution).
|
|
11
11
|
*/
|
|
@@ -18,7 +18,8 @@ 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.7";
|
|
22
|
+
export { EXTENSION_VERSION };
|
|
22
23
|
// 5: speaks per-kind agent admission and queued acquire.
|
|
23
24
|
const PROTOCOL = 5;
|
|
24
25
|
const HELLO_TIMEOUT_MS = 5000;
|
|
@@ -281,6 +282,7 @@ export class ManagerClient {
|
|
|
281
282
|
private eventHandlers = new Set<EventHandler>();
|
|
282
283
|
private reconnectHandlers = new Set<() => void>();
|
|
283
284
|
private protocolLevel_ = 0;
|
|
285
|
+
private daemonInfo: { version: string; pid: number } | null = null;
|
|
284
286
|
private state: ClientState = "disconnected";
|
|
285
287
|
private intentionalClose = false;
|
|
286
288
|
private rebound = false;
|
|
@@ -313,6 +315,11 @@ export class ManagerClient {
|
|
|
313
315
|
return this.protocolLevel_;
|
|
314
316
|
}
|
|
315
317
|
|
|
318
|
+
/** Version and pid the connected daemon reported on hello; null before the first hello. */
|
|
319
|
+
managerInfo(): { version: string; pid: number } | null {
|
|
320
|
+
return this.daemonInfo;
|
|
321
|
+
}
|
|
322
|
+
|
|
316
323
|
/** Last connect/reconnect failure reason (empty when never failed / currently connected). */
|
|
317
324
|
lastError(): string {
|
|
318
325
|
return this.lastFailureMessage;
|
|
@@ -631,7 +638,8 @@ export class ManagerClient {
|
|
|
631
638
|
const deadline = this.now() + timeoutMs;
|
|
632
639
|
for (;;) {
|
|
633
640
|
this.checkStartupOpen();
|
|
634
|
-
|
|
641
|
+
// A named pipe is not a file: existsSync does not see it, connecting does.
|
|
642
|
+
if (process.platform === "win32" || existsSync(socketPath)) {
|
|
635
643
|
const ok = await this.openSocket(socketPath, Math.min(SOCKET_READY_POLL_MS, Math.max(0, deadline - this.now())))
|
|
636
644
|
.then((probe) => { probe.destroy(); return true; }, () => false);
|
|
637
645
|
if (ok) return;
|
|
@@ -791,7 +799,10 @@ export class ManagerClient {
|
|
|
791
799
|
}
|
|
792
800
|
if (this.helloWaiter && typeof msg.ok === "boolean") {
|
|
793
801
|
// Unnamed response: correlate by order with the outstanding hello.
|
|
794
|
-
if (msg.ok === true)
|
|
802
|
+
if (msg.ok === true) {
|
|
803
|
+
this.recordDaemonInfo(msg);
|
|
804
|
+
this.helloWaiter.resolve();
|
|
805
|
+
}
|
|
795
806
|
else this.helloWaiter.reject(errorFromResponse(msg));
|
|
796
807
|
this.helloWaiter = null;
|
|
797
808
|
return;
|
|
@@ -799,6 +810,13 @@ export class ManagerClient {
|
|
|
799
810
|
this.log(`unmatched message: ${JSON.stringify(msg).slice(0, 200)}`);
|
|
800
811
|
}
|
|
801
812
|
|
|
813
|
+
private recordDaemonInfo(hello: Record<string, unknown>): void {
|
|
814
|
+
this.daemonInfo = {
|
|
815
|
+
version: typeof hello.version === "string" ? hello.version : "unknown",
|
|
816
|
+
pid: typeof hello.pid === "number" ? hello.pid : 0,
|
|
817
|
+
};
|
|
818
|
+
}
|
|
819
|
+
|
|
802
820
|
private settle(id: string, msg: Record<string, unknown>): void {
|
|
803
821
|
const entry = this.pending.get(id);
|
|
804
822
|
if (!entry) return;
|
|
@@ -810,6 +828,7 @@ export class ManagerClient {
|
|
|
810
828
|
if (entry.message.type === "hello") {
|
|
811
829
|
const serverMaximum = typeof msg.protocol === "number" ? msg.protocol : 0;
|
|
812
830
|
this.protocolLevel_ = Math.min(PROTOCOL, serverMaximum);
|
|
831
|
+
this.recordDaemonInfo(msg);
|
|
813
832
|
}
|
|
814
833
|
entry.resolve(msg);
|
|
815
834
|
} else {
|
package/src/native-manager.js
CHANGED
|
@@ -14,10 +14,13 @@ const resolutionErrorCodes = new Set([
|
|
|
14
14
|
const packageVersion = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
15
15
|
const resolveInstalled = createRequire(import.meta.url).resolve;
|
|
16
16
|
|
|
17
|
-
/** The
|
|
17
|
+
/** The native optional packages are selected by npm's os/cpu constraints. */
|
|
18
18
|
export function nativePackageName(platform, arch) {
|
|
19
|
-
if (!["linux", "darwin"].includes(platform) || !["x64", "arm64"].includes(arch)) return null;
|
|
20
|
-
|
|
19
|
+
if (!["linux", "darwin", "win32"].includes(platform) || !["x64", "arm64"].includes(arch)) return null;
|
|
20
|
+
// npm package names use `win` (not `win32`) after unscoped `*-win32-*` names
|
|
21
|
+
// were blocked by registry spam detection on first publish.
|
|
22
|
+
const packageOs = platform === "win32" ? "win" : platform;
|
|
23
|
+
return `pi-famulus-${packageOs}-${arch}`;
|
|
21
24
|
}
|
|
22
25
|
|
|
23
26
|
/** Resolve the exact-version optional package without loading native executable bytes. */
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /reload keeps background work alive.
|
|
3
|
+
*
|
|
4
|
+
* pi's /reload emits session_shutdown(reason "reload") on the old extension
|
|
5
|
+
* instance, re-imports every extension (fresh module scope), then emits
|
|
6
|
+
* session_start(reason "reload") on the new instances. Shells, monitors and
|
|
7
|
+
* subagents belong to the pi session, not to one module instance, so the old
|
|
8
|
+
* instance parks its runtime here instead of tearing it down, and the new
|
|
9
|
+
* instance adopts it.
|
|
10
|
+
*
|
|
11
|
+
* The runtime registers its tools, commands and handlers on a PiBinding: a
|
|
12
|
+
* stand-in ExtensionAPI that records registrations and replays them on every
|
|
13
|
+
* pi it is attached to. Other calls go to the attached pi; sendMessage is
|
|
14
|
+
* queued while no live pi can take it (between the old instance going stale
|
|
15
|
+
* and the new one's session_start).
|
|
16
|
+
*/
|
|
17
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
18
|
+
import { realClock, type Clock } from "./clock";
|
|
19
|
+
|
|
20
|
+
const HANDOFF_KEY = Symbol.for("pi-famulus.reload-handoff");
|
|
21
|
+
|
|
22
|
+
/** A parked runtime nobody adopts (pi-famulus removed from the config) is torn down after this. */
|
|
23
|
+
export const HANDOFF_EXPIRY_MS = 60_000;
|
|
24
|
+
|
|
25
|
+
const REGISTRATIONS = new Set<PropertyKey>([
|
|
26
|
+
"on",
|
|
27
|
+
"registerTool",
|
|
28
|
+
"registerCommand",
|
|
29
|
+
"registerShortcut",
|
|
30
|
+
"registerFlag",
|
|
31
|
+
"registerMessageRenderer",
|
|
32
|
+
"registerProvider",
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
export interface PiBinding {
|
|
36
|
+
/** Pass this to the runtime in place of the real pi. */
|
|
37
|
+
readonly api: ExtensionAPI;
|
|
38
|
+
/** Replay every registration on `pi` and route calls to it. */
|
|
39
|
+
attach(pi: ExtensionAPI): void;
|
|
40
|
+
/** Deliver queued messages; call once the attached pi's session has started. */
|
|
41
|
+
ready(): void;
|
|
42
|
+
/** Stop calling the attached pi (it is about to go stale). */
|
|
43
|
+
detach(): void;
|
|
44
|
+
/** Drop queued messages; they belong to a session that is not coming back. */
|
|
45
|
+
discardQueued(): void;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function createPiBinding(): PiBinding {
|
|
49
|
+
const registrations: Array<{ method: PropertyKey; args: unknown[] }> = [];
|
|
50
|
+
const queued: unknown[][] = [];
|
|
51
|
+
let current: ExtensionAPI | null = null;
|
|
52
|
+
let live = false;
|
|
53
|
+
|
|
54
|
+
const call = (pi: ExtensionAPI, method: PropertyKey, args: unknown[]): unknown =>
|
|
55
|
+
(Reflect.get(pi, method) as (...a: unknown[]) => unknown).apply(pi, args);
|
|
56
|
+
|
|
57
|
+
const api = new Proxy({} as ExtensionAPI, {
|
|
58
|
+
get(_target, key) {
|
|
59
|
+
if (REGISTRATIONS.has(key)) {
|
|
60
|
+
return (...args: unknown[]) => {
|
|
61
|
+
registrations.push({ method: key, args });
|
|
62
|
+
return current ? call(current, key, args) : undefined;
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
if (key === "sendMessage") {
|
|
66
|
+
return (...args: unknown[]) => {
|
|
67
|
+
if (current && live) return call(current, key, args);
|
|
68
|
+
queued.push(args);
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
if (!current) throw new Error(`pi-famulus: pi.${String(key)} used while detached (between reloads)`);
|
|
72
|
+
const value = Reflect.get(current, key) as unknown;
|
|
73
|
+
return typeof value === "function" ? (value as (...a: unknown[]) => unknown).bind(current) : value;
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
api,
|
|
79
|
+
attach(pi) {
|
|
80
|
+
current = pi;
|
|
81
|
+
live = false;
|
|
82
|
+
for (const { method, args } of registrations) call(pi, method, args);
|
|
83
|
+
},
|
|
84
|
+
ready() {
|
|
85
|
+
if (!current) return;
|
|
86
|
+
live = true;
|
|
87
|
+
for (const args of queued.splice(0)) call(current, "sendMessage", args);
|
|
88
|
+
},
|
|
89
|
+
detach() {
|
|
90
|
+
current = null;
|
|
91
|
+
live = false;
|
|
92
|
+
},
|
|
93
|
+
discardQueued() {
|
|
94
|
+
queued.length = 0;
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface ParkedRuntime<R> {
|
|
100
|
+
/** Extension version that parked it; another version's runtime is not adopted. */
|
|
101
|
+
version: string;
|
|
102
|
+
runtime: R;
|
|
103
|
+
/** Full teardown (what a non-reload session_shutdown does). */
|
|
104
|
+
dispose(): Promise<void>;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
interface Slot {
|
|
108
|
+
parked: ParkedRuntime<unknown>;
|
|
109
|
+
expiry: unknown;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Park a runtime for the next instance; tear it down if nobody takes it in time. */
|
|
113
|
+
export function parkRuntime<R>(parked: ParkedRuntime<R>, clock: Clock = realClock): void {
|
|
114
|
+
const store = globalThis as Record<symbol, Slot | undefined>;
|
|
115
|
+
const previous = store[HANDOFF_KEY];
|
|
116
|
+
if (previous) {
|
|
117
|
+
clock.clearTimeout(previous.expiry);
|
|
118
|
+
void previous.parked.dispose().catch(() => {});
|
|
119
|
+
}
|
|
120
|
+
const expiry = clock.setTimeout(() => {
|
|
121
|
+
if (store[HANDOFF_KEY]?.parked !== parked) return;
|
|
122
|
+
store[HANDOFF_KEY] = undefined;
|
|
123
|
+
void parked.dispose().catch(() => {});
|
|
124
|
+
}, HANDOFF_EXPIRY_MS);
|
|
125
|
+
clock.unref?.(expiry);
|
|
126
|
+
store[HANDOFF_KEY] = { parked: parked as ParkedRuntime<unknown>, expiry };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Take the parked runtime, if any. The caller adopts or disposes it. */
|
|
130
|
+
export function takeParkedRuntime<R>(clock: Clock = realClock): ParkedRuntime<R> | null {
|
|
131
|
+
const store = globalThis as Record<symbol, Slot | undefined>;
|
|
132
|
+
const slot = store[HANDOFF_KEY];
|
|
133
|
+
if (!slot) return null;
|
|
134
|
+
store[HANDOFF_KEY] = undefined;
|
|
135
|
+
clock.clearTimeout(slot.expiry);
|
|
136
|
+
return slot.parked as ParkedRuntime<R>;
|
|
137
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /famulus — which pi-famulus binary this session uses and which versions run.
|
|
3
|
+
*/
|
|
4
|
+
import { execFile } from "node:child_process";
|
|
5
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import type { ManagerClient } from "../manager-client";
|
|
7
|
+
|
|
8
|
+
export interface FamulusCommandDeps {
|
|
9
|
+
extensionVersion: string;
|
|
10
|
+
managerPath: string | null;
|
|
11
|
+
/** Where the binary was looked for, shown when none was found. */
|
|
12
|
+
describeSearch: () => string;
|
|
13
|
+
getClient: () => ManagerClient | null;
|
|
14
|
+
/** `<binary> --version`; null when it cannot be run. */
|
|
15
|
+
binaryVersion?: (path: string) => Promise<string | null>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface FamulusInfo {
|
|
19
|
+
extensionVersion: string;
|
|
20
|
+
managerPath: string | null;
|
|
21
|
+
searched: string;
|
|
22
|
+
binaryVersion: string | null;
|
|
23
|
+
daemon: { version: string; pid: number } | null;
|
|
24
|
+
daemonError: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function formatFamulusInfo(info: FamulusInfo): string {
|
|
28
|
+
const binary = info.managerPath
|
|
29
|
+
? `${info.managerPath}${info.binaryVersion ? ` (${info.binaryVersion})` : ""}`
|
|
30
|
+
: `not found; looked in: ${info.searched}`;
|
|
31
|
+
const manager = info.daemon
|
|
32
|
+
? `${info.daemon.version} (pid ${info.daemon.pid})`
|
|
33
|
+
: `not connected${info.daemonError ? ` (${info.daemonError})` : ""}`;
|
|
34
|
+
return [
|
|
35
|
+
`pi-famulus extension ${info.extensionVersion}`,
|
|
36
|
+
`CLI binary: ${binary}`,
|
|
37
|
+
`Manager: ${manager}`,
|
|
38
|
+
].join("\n");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function readBinaryVersion(path: string): Promise<string | null> {
|
|
42
|
+
return new Promise((resolve) => {
|
|
43
|
+
execFile(path, ["--version"], { timeout: 2000, windowsHide: true }, (err, stdout) => {
|
|
44
|
+
if (err) return resolve(null);
|
|
45
|
+
// "pi-famulus 0.1.6+<sha>"
|
|
46
|
+
resolve(stdout.trim().replace(/^pi-famulus\s+/, "") || null);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function collectFamulusInfo(deps: FamulusCommandDeps): Promise<FamulusInfo> {
|
|
52
|
+
const client = deps.getClient();
|
|
53
|
+
const connected = client?.isAvailable() ?? false;
|
|
54
|
+
return {
|
|
55
|
+
extensionVersion: deps.extensionVersion,
|
|
56
|
+
managerPath: deps.managerPath,
|
|
57
|
+
searched: deps.managerPath ? "" : deps.describeSearch(),
|
|
58
|
+
binaryVersion: deps.managerPath ? await (deps.binaryVersion ?? readBinaryVersion)(deps.managerPath) : null,
|
|
59
|
+
daemon: connected ? client?.managerInfo() ?? null : null,
|
|
60
|
+
daemonError: connected ? "" : client?.lastError() ?? "",
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function registerFamulusCommand(pi: ExtensionAPI, deps: FamulusCommandDeps): void {
|
|
65
|
+
pi.registerCommand("famulus", {
|
|
66
|
+
description: "Show the pi-famulus binary path and the manager and extension versions",
|
|
67
|
+
handler: async (_args, ctx) => {
|
|
68
|
+
ctx.ui.notify(formatFamulusInfo(await collectFamulusInfo(deps)), "info");
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
}
|
package/src/tui/tasks-command.ts
CHANGED
|
@@ -64,10 +64,6 @@ export function registerTasksCommand(pi: ExtensionAPI, deps: TasksCommandDeps):
|
|
|
64
64
|
description: "List and inspect background work",
|
|
65
65
|
handler: async (_args, ctx) => openTasksUi(ctx, deps),
|
|
66
66
|
});
|
|
67
|
-
pi.registerCommand("bashes", {
|
|
68
|
-
description: "Alias for /tasks",
|
|
69
|
-
handler: async (_args, ctx) => openTasksUi(ctx, deps),
|
|
70
|
-
});
|
|
71
67
|
}
|
|
72
68
|
|
|
73
69
|
type TaskListRow = { type: "run"; runId: string; count: number } | { type: "task"; item: WorkItem; indent: boolean };
|