pi-famulus 0.1.9 → 0.2.1
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 +4 -4
- package/package.json +8 -8
- package/src/bounded-output.ts +17 -1
- package/src/child-mode.ts +214 -0
- package/src/comms/comms.ts +18 -13
- package/src/comms/registry-host.ts +10 -0
- package/src/comms/tools.ts +9 -2
- package/src/comms/types.ts +9 -3
- package/src/config.ts +16 -0
- package/src/format.ts +4 -0
- package/src/index.ts +391 -36
- package/src/manager-client.ts +147 -5
- package/src/notify.ts +1 -0
- package/src/pi-command.ts +123 -0
- package/src/prompts.generated.ts +8 -9
- package/src/subagent/agent-records.ts +51 -2
- package/src/subagent/hosted-session.ts +314 -0
- package/src/subagent/overrun.ts +11 -0
- package/src/subagent/pi-runtime.ts +4 -0
- package/src/subagent/registry.ts +78 -0
- package/src/subagent/runner.ts +94 -17
- package/src/subagent/tool.ts +79 -9
- package/src/subagent/types.ts +10 -1
- package/src/tui/attach-command.ts +164 -0
- package/src/work-index.ts +6 -0
package/README.md
CHANGED
|
@@ -32,7 +32,7 @@ Three things go wrong when an agent has to wait:
|
|
|
32
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
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
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
|
|
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 are `pi --mode rpc` processes owned by the daemon.
|
|
36
36
|
|
|
37
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
38
|
|
|
@@ -125,7 +125,7 @@ pi-famulus starts and stops processes for you. This section states what it guara
|
|
|
125
125
|
|
|
126
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
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
|
-
-
|
|
128
|
+
- Shells and monitors do not survive the last pi session closing. Five seconds after the last session disconnects, and once no hosted subagent is still warm, the daemon stops what is left and exits. A warm subagent keeps the daemon up after its parent disconnects. It parks at its retention deadline, or earlier when the warm-agent cap needs the slot. Records, output and transcripts stay on disk for 24 hours.
|
|
129
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
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
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.
|
|
@@ -134,10 +134,10 @@ pi-famulus starts and stops processes for you. This section states what it guara
|
|
|
134
134
|
## FAQ
|
|
135
135
|
|
|
136
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?**
|
|
137
|
+
- **Can I attach to a subagent like a tmux pane?** Yes. Each subagent is a `pi --mode rpc` process with a pi session id. `/attach <id>` in the pi TUI, or `pi-famulus attach <child id>` from a terminal, talks to that process. Opening a warm hosted session in the pi TUI asks you to attach, take over, or cancel. Without a UI that open is refused. A parked child is not refused: interactive pi can open its session file.
|
|
138
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
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>`.
|
|
140
|
+
- **What happens to background tasks when pi exits?** Five seconds after the last pi session closes, and once no hosted subagent is still warm, the daemon stops whatever is still running and exits. Read the records afterwards with `pi-famulus show <id>`.
|
|
141
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
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
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)).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-famulus",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Keep your main agent's context clean: subagents on cheaper models, long bash in the background, and monitors that wake the agent. One Rust daemon runs the shells.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -54,12 +54,12 @@
|
|
|
54
54
|
"pi-famulus": "./bin/pi-famulus.js"
|
|
55
55
|
},
|
|
56
56
|
"optionalDependencies": {
|
|
57
|
-
"pi-famulus-linux-x64": "0.1
|
|
58
|
-
"pi-famulus-linux-arm64": "0.1
|
|
59
|
-
"pi-famulus-darwin-x64": "0.1
|
|
60
|
-
"pi-famulus-darwin-arm64": "0.1
|
|
61
|
-
"pi-famulus-win-x64": "0.1
|
|
62
|
-
"pi-famulus-win-arm64": "0.1
|
|
57
|
+
"pi-famulus-linux-x64": "0.2.1",
|
|
58
|
+
"pi-famulus-linux-arm64": "0.2.1",
|
|
59
|
+
"pi-famulus-darwin-x64": "0.2.1",
|
|
60
|
+
"pi-famulus-darwin-arm64": "0.2.1",
|
|
61
|
+
"pi-famulus-win-x64": "0.2.1",
|
|
62
|
+
"pi-famulus-win-arm64": "0.2.1"
|
|
63
63
|
},
|
|
64
64
|
"pi": {
|
|
65
65
|
"extensions": [
|
|
@@ -70,7 +70,7 @@
|
|
|
70
70
|
"deps": "nub install --frozen-lockfile --node-linker hoisted",
|
|
71
71
|
"prompts": "node scripts/build-prompts.ts",
|
|
72
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",
|
|
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/unit/impl-hosted-child-failure.test.ts tests/unit/impl-hosted-child-rate-limit.test.ts tests/unit/impl-hosted-child-stuck.test.ts tests/unit/impl-hosted-child-tool-allowlist.test.ts tests/unit/impl-hosted-child-tool-activity.test.ts tests/unit/impl-hosted-child-ui-dialog.test.ts tests/unit/impl-hosted-child-steering.test.ts tests/unit/impl-hosted-docs.test.ts tests/integration/behavior-guidelines.test.ts",
|
|
74
74
|
"typecheck": "tsc --noEmit"
|
|
75
75
|
},
|
|
76
76
|
"devDependencies": {
|
package/src/bounded-output.ts
CHANGED
|
@@ -7,11 +7,26 @@
|
|
|
7
7
|
* buffer or string on the event loop.
|
|
8
8
|
*/
|
|
9
9
|
import { randomUUID } from "node:crypto";
|
|
10
|
-
import { createWriteStream, type WriteStream } from "node:fs";
|
|
10
|
+
import { createWriteStream, unlinkSync, type WriteStream } from "node:fs";
|
|
11
11
|
import type { Readable } from "node:stream";
|
|
12
12
|
import { tmpdir } from "node:os";
|
|
13
13
|
import { join } from "node:path";
|
|
14
14
|
|
|
15
|
+
/** Spill files created this process; unlinked on session shutdown. */
|
|
16
|
+
const sessionSpillFiles = new Set<string>();
|
|
17
|
+
|
|
18
|
+
/** Forget and delete every overflow file this session wrote. */
|
|
19
|
+
export function cleanupSessionSpillFiles(): void {
|
|
20
|
+
for (const path of sessionSpillFiles) {
|
|
21
|
+
try {
|
|
22
|
+
unlinkSync(path);
|
|
23
|
+
} catch {
|
|
24
|
+
// already gone, or never created
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
sessionSpillFiles.clear();
|
|
28
|
+
}
|
|
29
|
+
|
|
15
30
|
export interface BoundedOutputOptions {
|
|
16
31
|
/** Spill to a temp file once total bytes exceed this. */
|
|
17
32
|
maxBytes: number;
|
|
@@ -122,6 +137,7 @@ export class BoundedOutput {
|
|
|
122
137
|
|
|
123
138
|
private openSpill(): void {
|
|
124
139
|
this.path = join(tmpdir(), `${this.opts.tempFilePrefix ?? "pi-famulus-bash"}-${randomUUID()}.log`);
|
|
140
|
+
sessionSpillFiles.add(this.path);
|
|
125
141
|
const stream = createWriteStream(this.path);
|
|
126
142
|
stream.on("error", () => {
|
|
127
143
|
this.streamFailed = true;
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-famulus inside a hosted subagent (`PI_FAMULUS_CHILD_ID`).
|
|
3
|
+
* Registers only the child tools and reports prompt-cache expiry. A second
|
|
4
|
+
* copy of the extension in the same process (installed package plus `-e`)
|
|
5
|
+
* returns immediately.
|
|
6
|
+
*/
|
|
7
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import { randomUUID } from "node:crypto";
|
|
9
|
+
import { CHILD_BEHAVIOR_GUIDELINES } from "./behavior-guidelines";
|
|
10
|
+
import { createChildBashTool } from "./subagent/child-bash";
|
|
11
|
+
import { ChildShellTracker } from "./subagent/overrun";
|
|
12
|
+
import { createContactSupervisorTool, createAgentMessageTool } from "./comms/tools";
|
|
13
|
+
import { createComms } from "./comms/comms";
|
|
14
|
+
import type { ChildHandle, ChildStatus, CommsHost } from "./comms/types";
|
|
15
|
+
import { getFamulusHome, loadConfig, resolveManagerPath } from "./config";
|
|
16
|
+
import { ManagerClient } from "./manager-client";
|
|
17
|
+
import { realClock } from "./clock";
|
|
18
|
+
|
|
19
|
+
const MARKER = Symbol.for("pi-famulus.child-mode");
|
|
20
|
+
|
|
21
|
+
export function isHostedChildProcess(): boolean {
|
|
22
|
+
return Boolean(process.env.PI_FAMULUS_CHILD_ID && process.env.PI_FAMULUS_CHILD_TOKEN);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function registerChildMode(pi: ExtensionAPI): void {
|
|
26
|
+
const globalState = globalThis as typeof globalThis & { [MARKER]?: boolean };
|
|
27
|
+
if (globalState[MARKER]) return;
|
|
28
|
+
globalState[MARKER] = true;
|
|
29
|
+
const childId = process.env.PI_FAMULUS_CHILD_ID ?? "";
|
|
30
|
+
const token = process.env.PI_FAMULUS_CHILD_TOKEN ?? "";
|
|
31
|
+
const runId = process.env.PI_FAMULUS_CHILD_RUN ?? "run_hosted";
|
|
32
|
+
const home = process.env.PI_FAMULUS_HOME || getFamulusHome();
|
|
33
|
+
const config = loadConfig(home);
|
|
34
|
+
const managerPath = resolveManagerPath(config, home);
|
|
35
|
+
let client: ManagerClient | null = null;
|
|
36
|
+
let ctx: ExtensionContext | null = null;
|
|
37
|
+
let stopWarming = false;
|
|
38
|
+
const shells = new ChildShellTracker();
|
|
39
|
+
const allow = process.env.PI_FAMULUS_CHILD_TOOLS?.split(",").filter(Boolean);
|
|
40
|
+
const bashAllowed = !allow || allow.includes("bash");
|
|
41
|
+
|
|
42
|
+
interface Peer {
|
|
43
|
+
childId: string;
|
|
44
|
+
name: string;
|
|
45
|
+
runId: string;
|
|
46
|
+
activity: string;
|
|
47
|
+
}
|
|
48
|
+
const peers: Peer[] = [];
|
|
49
|
+
|
|
50
|
+
const peerStatus = (peer: Peer): ChildStatus => {
|
|
51
|
+
if (peer.activity === "running" || peer.activity === "starting") return "running";
|
|
52
|
+
if (peer.activity === "failed") return "failed";
|
|
53
|
+
return "completed";
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const handleFor = (peer: Peer): ChildHandle => ({
|
|
57
|
+
childId: peer.childId,
|
|
58
|
+
result: Promise.resolve({ status: "completed", text: "", durationMs: 0 }),
|
|
59
|
+
steer: async (message: string) => { await client?.agentInput(peer.childId, "prompt_steer", message, childId); },
|
|
60
|
+
followUp: async (message: string) => { await client?.agentInput(peer.childId, "prompt_follow", message, childId); },
|
|
61
|
+
resume: async (message: string) => { await client?.agentInput(peer.childId, "prompt", message, childId); },
|
|
62
|
+
interrupt: async () => { await client?.agentInput(peer.childId, "abort", "", childId); },
|
|
63
|
+
status: () => peerStatus(peer),
|
|
64
|
+
lastEventAt: () => Date.now(),
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const host: CommsHost = {
|
|
68
|
+
getChild(id) {
|
|
69
|
+
const peer = peers.find((item) => item.childId === id);
|
|
70
|
+
if (!peer) return undefined;
|
|
71
|
+
return { handle: handleFor(peer), runId: peer.runId, name: peer.name, status: peerStatus(peer) };
|
|
72
|
+
},
|
|
73
|
+
listChildren() {
|
|
74
|
+
return peers.map((peer) => ({ childId: peer.childId, runId: peer.runId, name: peer.name, status: peerStatus(peer) }));
|
|
75
|
+
},
|
|
76
|
+
sameRun: () => true,
|
|
77
|
+
notifySupervisor: () => {},
|
|
78
|
+
resumeChild: async (id, message) => {
|
|
79
|
+
if (!client) throw new Error("pi-famulus daemon is not connected");
|
|
80
|
+
client.requireHostedProtocol();
|
|
81
|
+
// A live signal disables the 30s acquire timer, so a queued permit can wait.
|
|
82
|
+
const admissionSignal = new AbortController().signal;
|
|
83
|
+
let queuedBehind: number | null = null;
|
|
84
|
+
for (;;) {
|
|
85
|
+
let admission: { granted: boolean; queuedBehind?: number };
|
|
86
|
+
try {
|
|
87
|
+
admission = await client.acquireAgent(id, "other", admissionSignal);
|
|
88
|
+
} catch (err) {
|
|
89
|
+
const messageText = err instanceof Error ? err.message : String(err);
|
|
90
|
+
if (messageText.includes("timed out")) {
|
|
91
|
+
queuedBehind = queuedBehind ?? 0;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
throw err;
|
|
95
|
+
}
|
|
96
|
+
if (admission.granted) {
|
|
97
|
+
if (admission.queuedBehind != null) queuedBehind = admission.queuedBehind;
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
queuedBehind = queuedBehind ?? 0;
|
|
101
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
await client.agentInput(id, "prompt", message, childId);
|
|
105
|
+
} catch (err) {
|
|
106
|
+
await client.releaseAgent(id).catch(() => {});
|
|
107
|
+
throw err;
|
|
108
|
+
}
|
|
109
|
+
return { queuedBehind };
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const refreshPeers = async () => {
|
|
114
|
+
if (!client?.isAvailable()) return;
|
|
115
|
+
const listed = await client.agentPeers().catch(() => null);
|
|
116
|
+
if (!listed) return;
|
|
117
|
+
peers.splice(0, peers.length, ...listed.map((peer) => ({
|
|
118
|
+
childId: peer.childId,
|
|
119
|
+
name: peer.name,
|
|
120
|
+
runId: peer.runId,
|
|
121
|
+
activity: peer.activity,
|
|
122
|
+
})));
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const comms = createComms(host);
|
|
126
|
+
|
|
127
|
+
pi.registerTool({
|
|
128
|
+
...createContactSupervisorTool({
|
|
129
|
+
...comms,
|
|
130
|
+
contactSupervisor: async (_from: string, reason: "need_decision" | "progress_update", message: string) => {
|
|
131
|
+
if (!client) throw new Error("pi-famulus daemon is not connected");
|
|
132
|
+
return client.agentComms(randomUUID(), reason, message);
|
|
133
|
+
},
|
|
134
|
+
} as never, childId),
|
|
135
|
+
promptGuidelines: [CHILD_BEHAVIOR_GUIDELINES],
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const messageTool = createAgentMessageTool(comms, { kind: "child", childId, runId }, host, realClock);
|
|
139
|
+
pi.registerTool({
|
|
140
|
+
...messageTool,
|
|
141
|
+
execute: async (toolCallId, params, signal, onUpdate, toolCtx) => {
|
|
142
|
+
await refreshPeers();
|
|
143
|
+
return messageTool.execute(toolCallId, params, signal, onUpdate, toolCtx);
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
if (bashAllowed) {
|
|
148
|
+
pi.registerTool(createChildBashTool({
|
|
149
|
+
getClient: () => client,
|
|
150
|
+
home,
|
|
151
|
+
sessionId: () => ctx?.sessionManager.getSessionId() ?? childId,
|
|
152
|
+
sessionEnv: (): Record<string, string> => {
|
|
153
|
+
const current = ctx;
|
|
154
|
+
if (!current) return {};
|
|
155
|
+
return {
|
|
156
|
+
PI_SESSION_ID: current.sessionManager.getSessionId(),
|
|
157
|
+
PI_SESSION_FILE: current.sessionManager.getSessionFile?.() ?? "",
|
|
158
|
+
PI_PROVIDER: current.model?.provider ?? "",
|
|
159
|
+
PI_MODEL: current.model?.id ?? "",
|
|
160
|
+
PI_REASONING_LEVEL: current.thinkingLevel ?? "",
|
|
161
|
+
};
|
|
162
|
+
},
|
|
163
|
+
trackTask: () => {},
|
|
164
|
+
childId,
|
|
165
|
+
runId,
|
|
166
|
+
clock: realClock,
|
|
167
|
+
shells,
|
|
168
|
+
}));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
pi.on("session_start", async (_event, startCtx) => {
|
|
172
|
+
ctx = startCtx;
|
|
173
|
+
client = new ManagerClient({
|
|
174
|
+
home,
|
|
175
|
+
sessionId: startCtx.sessionManager.getSessionId(),
|
|
176
|
+
managerPath,
|
|
177
|
+
piPid: process.pid,
|
|
178
|
+
cwd: startCtx.cwd,
|
|
179
|
+
role: "child",
|
|
180
|
+
childId,
|
|
181
|
+
token,
|
|
182
|
+
sessionFile: startCtx.sessionManager.getSessionFile?.(),
|
|
183
|
+
});
|
|
184
|
+
await client.connect().catch(() => {});
|
|
185
|
+
client.onEvent((event) => {
|
|
186
|
+
const hosted = event as { event?: string; residency?: string };
|
|
187
|
+
if (hosted.event === "agent_state" && hosted.residency === "parked") stopWarming = true;
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
pi.on("message_end", async (event) => {
|
|
192
|
+
if (!client?.isAvailable()) return;
|
|
193
|
+
const message = (event as { message?: { role?: string; usage?: { cacheWrite1h?: number } } }).message;
|
|
194
|
+
if (message?.role !== "assistant") return;
|
|
195
|
+
const model = ctx?.model as { promptCache?: { short?: number; long?: number } } | undefined;
|
|
196
|
+
const long = process.env.PI_CACHE_RETENTION === "long" || (message.usage?.cacheWrite1h ?? 0) > 0;
|
|
197
|
+
const seconds = long ? model?.promptCache?.long : model?.promptCache?.short;
|
|
198
|
+
if (!seconds || seconds <= 0) {
|
|
199
|
+
await client.agentCache(null, null, null).catch(() => {});
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
await client.agentCache(Date.now() + seconds * 1000, long ? "long" : "short", seconds * 1000).catch(() => {});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
pi.on("cache_warming_decision", () => {
|
|
206
|
+
if (stopWarming) return { action: "stop" as const };
|
|
207
|
+
return undefined;
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
pi.on("session_shutdown", async () => {
|
|
211
|
+
await client?.close();
|
|
212
|
+
client = null;
|
|
213
|
+
});
|
|
214
|
+
}
|
package/src/comms/comms.ts
CHANGED
|
@@ -56,7 +56,7 @@ export interface CommsWithOrigin extends Comms {
|
|
|
56
56
|
message: string,
|
|
57
57
|
delivery: "steer" | "queue",
|
|
58
58
|
from?: string,
|
|
59
|
-
): Promise<
|
|
59
|
+
): Promise<"running" | "resumed" | "queued">;
|
|
60
60
|
/** Cancel pending decision waiters and timers (session shutdown). */
|
|
61
61
|
dispose(): void;
|
|
62
62
|
}
|
|
@@ -129,7 +129,7 @@ export function createComms(host: CommsHost, options: CommsOptions = {}): CommsW
|
|
|
129
129
|
options.logEvent?.("decision.reply", { child_id: toChildId });
|
|
130
130
|
},
|
|
131
131
|
|
|
132
|
-
async send(toChildId, message, delivery, from = "supervisor") {
|
|
132
|
+
async send(toChildId, message, delivery, from = "supervisor"): Promise<"running" | "resumed" | "queued"> {
|
|
133
133
|
const child = host.getChild(toChildId);
|
|
134
134
|
if (!child) {
|
|
135
135
|
const known =
|
|
@@ -142,28 +142,33 @@ export function createComms(host: CommsHost, options: CommsOptions = {}): CommsW
|
|
|
142
142
|
} else {
|
|
143
143
|
await child.handle.followUp(message);
|
|
144
144
|
}
|
|
145
|
+
mailbox.append(child.runId, { from, to: toChildId, kind: "send", message });
|
|
146
|
+
return "running";
|
|
145
147
|
} else if (
|
|
146
148
|
child.status === "completed" ||
|
|
147
149
|
child.status === "failed" ||
|
|
148
150
|
child.status === "interrupted"
|
|
149
151
|
) {
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
152
|
+
const resume = host.resumeChild;
|
|
153
|
+
if (!resume) {
|
|
154
|
+
throw new Error(
|
|
155
|
+
fill("tools.agent_message.error.finished-child", {
|
|
156
|
+
childId: toChildId,
|
|
157
|
+
name: child.name,
|
|
158
|
+
status: child.status,
|
|
159
|
+
runId: child.runId,
|
|
160
|
+
}),
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
const { queuedBehind } = await resume(child.handle.childId, message);
|
|
164
|
+
mailbox.append(child.runId, { from, to: toChildId, kind: "send", message });
|
|
165
|
+
return queuedBehind === null ? "resumed" : "queued";
|
|
160
166
|
} else {
|
|
161
167
|
throw new Error(
|
|
162
168
|
`Child ${toChildId} (${child.name}) is ${child.status}; ` +
|
|
163
169
|
"messages can only be delivered to running or finished children.",
|
|
164
170
|
);
|
|
165
171
|
}
|
|
166
|
-
mailbox.append(child.runId, { from, to: toChildId, kind: "send", message });
|
|
167
172
|
},
|
|
168
173
|
|
|
169
174
|
async broadcast(runId, message, fromChildId) {
|
|
@@ -16,6 +16,8 @@ export interface RegistryHostDeps {
|
|
|
16
16
|
getNotifyCenter: () => {
|
|
17
17
|
notify(msg: { customType: string; content: string; details?: unknown }): void;
|
|
18
18
|
} | null;
|
|
19
|
+
/** The subagent tool registers this so a send-to-idle turn wakes the parent. */
|
|
20
|
+
onResumeAccepted?: (childId: string) => void;
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
|
|
@@ -69,5 +71,13 @@ export function createRegistryCommsHost(deps: RegistryHostDeps): CommsHost {
|
|
|
69
71
|
details: wake.details,
|
|
70
72
|
});
|
|
71
73
|
},
|
|
74
|
+
|
|
75
|
+
async resumeChild(childId: string, message: string) {
|
|
76
|
+
const registry = deps.getRegistry();
|
|
77
|
+
if (!registry) throw new Error("subagent system is not initialized");
|
|
78
|
+
const accepted = await registry.resumeChild(childId, message);
|
|
79
|
+
deps.onResumeAccepted?.(childId);
|
|
80
|
+
return accepted;
|
|
81
|
+
},
|
|
72
82
|
};
|
|
73
83
|
}
|
package/src/comms/tools.ts
CHANGED
|
@@ -295,8 +295,9 @@ export function createAgentMessageTool(
|
|
|
295
295
|
|
|
296
296
|
// send
|
|
297
297
|
const delivery = params.delivery ?? "steer";
|
|
298
|
+
let delivered: "running" | "resumed" | "queued";
|
|
298
299
|
try {
|
|
299
|
-
await comms.send(target.childId, message, delivery, from);
|
|
300
|
+
delivered = await comms.send(target.childId, message, delivery, from);
|
|
300
301
|
} catch (err) {
|
|
301
302
|
return errorResult("send", err instanceof Error ? err.message : String(err), {
|
|
302
303
|
to: target.childId,
|
|
@@ -304,7 +305,13 @@ export function createAgentMessageTool(
|
|
|
304
305
|
});
|
|
305
306
|
}
|
|
306
307
|
const how =
|
|
307
|
-
|
|
308
|
+
delivered === "queued"
|
|
309
|
+
? "accepted a new turn on the idle child; it is waiting for a machine slot"
|
|
310
|
+
: delivered === "resumed"
|
|
311
|
+
? "started a new turn on the idle child"
|
|
312
|
+
: delivery === "steer"
|
|
313
|
+
? "steered into the running child"
|
|
314
|
+
: "queued for the running child";
|
|
308
315
|
return okResult("send", `Message to ${target.childId} (${target.name}) ${how}.`, {
|
|
309
316
|
to: target.childId,
|
|
310
317
|
delivery,
|
package/src/comms/types.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* comms types (design doc appendix B, M4).
|
|
3
3
|
*
|
|
4
|
-
* `ChildStatus` / `ChildResult` / `ChildHandle`
|
|
5
|
-
*
|
|
4
|
+
* `ChildStatus` / `ChildResult` / `ChildHandle` follow the appendix B shapes.
|
|
5
|
+
* `send` returns `"running" | "resumed" | "queued"`; appendix B originally said `Promise<void>`.
|
|
6
|
+
* These shapes are intentionally NOT imported from
|
|
6
7
|
* `../subagent/types` (M3 is developed in parallel and may not exist yet);
|
|
7
8
|
* any value satisfying these shapes is accepted.
|
|
8
9
|
*/
|
|
@@ -43,6 +44,10 @@ export interface CommsHost {
|
|
|
43
44
|
listChildren(): { childId: string; runId: string; name: string; status: ChildStatus }[];
|
|
44
45
|
sameRun(childIdA: string, childIdB: string): boolean;
|
|
45
46
|
notifySupervisor(wake: { content: string; details: FamulusWake }): void;
|
|
47
|
+
/** Continue an idle child. Absent on test hosts that still reject send-to-finished. `queuedBehind` is null when a slot was free. */
|
|
48
|
+
resumeChild?(childId: string, message: string): Promise<{ queuedBehind: number | null }>;
|
|
49
|
+
/** Fired after resumeChild accepts a turn, so the parent can be woken when it settles. */
|
|
50
|
+
onResumeAccepted?(childId: string): void;
|
|
46
51
|
}
|
|
47
52
|
|
|
48
53
|
export interface MailboxEntry {
|
|
@@ -61,7 +66,8 @@ export interface Comms {
|
|
|
61
66
|
message: string,
|
|
62
67
|
): Promise<string>; // need_decision → reply text; progress_update → immediate "ok"
|
|
63
68
|
reply(toChildId: string, message: string): void; // no pending waiter → throw
|
|
64
|
-
|
|
69
|
+
// "running" steered or queued into a live child; "resumed" an idle child started a turn; "queued" the turn is waiting for a machine slot.
|
|
70
|
+
send(toChildId: string, message: string, delivery: "steer" | "queue"): Promise<"running" | "resumed" | "queued">;
|
|
65
71
|
broadcast(runId: string, message: string, fromChildId?: string): Promise<string[]>; // delivered childIds
|
|
66
72
|
pendingRequests(): { childId: string; name: string; message: string; sinceMs: number }[];
|
|
67
73
|
log(runId: string, limit?: number): MailboxEntry[]; // default 20, ring 200/run
|
package/src/config.ts
CHANGED
|
@@ -48,6 +48,8 @@ export interface FamulusSubagentConfig {
|
|
|
48
48
|
maxConcurrentChildren?: number;
|
|
49
49
|
/** Max child sessions spawned per hour. */
|
|
50
50
|
spawnBudgetPerHour?: number;
|
|
51
|
+
/** pi command for hosted subagents: a path, or a JSON argv array. */
|
|
52
|
+
piCommand?: string;
|
|
51
53
|
}
|
|
52
54
|
|
|
53
55
|
/** Subagent settings with every field resolved (design doc §4.6 defaults). */
|
|
@@ -64,6 +66,7 @@ export interface ResolvedSubagentConfig {
|
|
|
64
66
|
concurrency: number;
|
|
65
67
|
maxConcurrentChildren: number;
|
|
66
68
|
spawnBudgetPerHour: number;
|
|
69
|
+
piCommand: string | null;
|
|
67
70
|
}
|
|
68
71
|
|
|
69
72
|
/**
|
|
@@ -88,6 +91,7 @@ export const DEFAULT_SUBAGENT_CONFIG: ResolvedSubagentConfig = {
|
|
|
88
91
|
concurrency: 4,
|
|
89
92
|
maxConcurrentChildren: 8,
|
|
90
93
|
spawnBudgetPerHour: 32,
|
|
94
|
+
piCommand: null,
|
|
91
95
|
};
|
|
92
96
|
|
|
93
97
|
/** Merge defaults <- top-level subagentBudgetMs <- subagent section. */
|
|
@@ -124,6 +128,9 @@ export function resolveSubagentConfig(config: FamulusConfig): ResolvedSubagentCo
|
|
|
124
128
|
if (typeof section.spawnBudgetPerHour === "number" && section.spawnBudgetPerHour >= 1) {
|
|
125
129
|
resolved.spawnBudgetPerHour = Math.floor(section.spawnBudgetPerHour);
|
|
126
130
|
}
|
|
131
|
+
if (typeof section.piCommand === "string" && section.piCommand.trim()) {
|
|
132
|
+
resolved.piCommand = section.piCommand.trim();
|
|
133
|
+
}
|
|
127
134
|
return resolved;
|
|
128
135
|
}
|
|
129
136
|
|
|
@@ -252,6 +259,15 @@ export function loadConfig(home: string = getFamulusHome()): FamulusConfig {
|
|
|
252
259
|
if (typeof section.spawnBudgetPerHour === "number" && section.spawnBudgetPerHour >= 1) {
|
|
253
260
|
subagent.spawnBudgetPerHour = section.spawnBudgetPerHour;
|
|
254
261
|
}
|
|
262
|
+
if (Array.isArray(section.piCommand) && section.piCommand.length > 0 && section.piCommand.every((part) => typeof part === "string" && part.length > 0)) {
|
|
263
|
+
subagent.piCommand = JSON.stringify(section.piCommand);
|
|
264
|
+
} else if (typeof section.piCommand === "string" && section.piCommand.trim()) {
|
|
265
|
+
subagent.piCommand = section.piCommand.trim();
|
|
266
|
+
} else if ("piCommand" in section) {
|
|
267
|
+
// Present but not a usable command. An empty argv makes spawn fail
|
|
268
|
+
// instead of quietly using whichever pi is running this session.
|
|
269
|
+
subagent.piCommand = "[]";
|
|
270
|
+
}
|
|
255
271
|
config.subagent = subagent;
|
|
256
272
|
}
|
|
257
273
|
return config;
|
package/src/format.ts
CHANGED
|
@@ -228,6 +228,8 @@ export interface SubagentChildInfo {
|
|
|
228
228
|
status: "pending" | "running" | "completed" | "failed" | "interrupted";
|
|
229
229
|
text: string;
|
|
230
230
|
error?: string;
|
|
231
|
+
endReason?: "model-error" | "rate-limit";
|
|
232
|
+
retryAfter?: number;
|
|
231
233
|
/** Non-fatal caveat from the child run (e.g. an adapted model spec). */
|
|
232
234
|
warning?: string;
|
|
233
235
|
/** Task prompt that was sent to this child. */
|
|
@@ -287,6 +289,8 @@ export function formatSubagentNotification(info: SubagentNotificationInfo): Form
|
|
|
287
289
|
prompt: capPrompt(child.prompt ?? ""),
|
|
288
290
|
result: capTail(child.text),
|
|
289
291
|
...(child.error ? { error: child.error } : {}),
|
|
292
|
+
...(child.endReason ? { endReason: child.endReason } : {}),
|
|
293
|
+
...(child.retryAfter !== undefined ? { retryAfter: child.retryAfter } : {}),
|
|
290
294
|
...(child.warning ? { warning: child.warning } : {}),
|
|
291
295
|
})),
|
|
292
296
|
});
|