pi-famulus 0.2.0 → 0.2.2-beta.0
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 +1 -0
- package/package.json +8 -8
- package/src/bounded-output.ts +17 -1
- package/src/format.ts +4 -0
- package/src/index.ts +271 -7
- package/src/intercom/intercom.ts +1266 -0
- package/src/intercom/mention.ts +83 -0
- package/src/intercom/overlay.ts +439 -0
- package/src/intercom/transport.ts +102 -0
- package/src/intercom/types.ts +39 -0
- package/src/intercom/widgets.ts +130 -0
- package/src/manager-client.ts +29 -4
- package/src/notify.ts +1 -0
- package/src/prompts.generated.ts +1 -1
- package/src/reload-handoff.ts +8 -2
- package/src/subagent/agent-records.ts +51 -2
- package/src/subagent/hosted-session.ts +57 -11
- package/src/subagent/registry.ts +78 -0
- package/src/subagent/runner.ts +75 -8
- package/src/subagent/tool.ts +10 -1
- package/src/subagent/types.ts +8 -1
- package/src/work-index.ts +6 -0
package/README.md
CHANGED
|
@@ -73,6 +73,7 @@ Run the full test suite and tell me what fails.
|
|
|
73
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
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
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
|
+
- **Cross-session messaging (agent-intercom)**: pi sessions join the local agent-intercom bridge over the same manager connection — no second socket. `SendMessage` / `ListSessions` tools, `/rename` and the `/intercom` session overlay, `@name` completion inserting `<RequestSendMessage … />` markers, a standing cross-session system-prompt section, `<cross-session-message>` deliver envelopes with read receipts, and authoritative busy/idle publication. Peer messages are input, not authority: they never grant consent. If the bridge is disabled (`PI_FAMULUS_NO_INTERCOM`) the feature stays silent; if it is down, pi-famulus says so once and retries every 3 seconds. Absorbed from the agent-intercom pi plugin (upstream baseline 98183fb); an installed legacy plugin is detected and you are asked to remove it.
|
|
76
77
|
- **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
78
|
- **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
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-famulus",
|
|
3
|
-
"version": "0.2.0",
|
|
3
|
+
"version": "0.2.2-beta.0",
|
|
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.2.0",
|
|
58
|
-
"pi-famulus-linux-arm64": "0.2.0",
|
|
59
|
-
"pi-famulus-darwin-x64": "0.2.0",
|
|
60
|
-
"pi-famulus-darwin-arm64": "0.2.0",
|
|
61
|
-
"pi-famulus-win-x64": "0.2.0",
|
|
62
|
-
"pi-famulus-win-arm64": "0.2.0"
|
|
57
|
+
"pi-famulus-linux-x64": "0.2.2-beta.0",
|
|
58
|
+
"pi-famulus-linux-arm64": "0.2.2-beta.0",
|
|
59
|
+
"pi-famulus-darwin-x64": "0.2.2-beta.0",
|
|
60
|
+
"pi-famulus-darwin-arm64": "0.2.2-beta.0",
|
|
61
|
+
"pi-famulus-win-x64": "0.2.2-beta.0",
|
|
62
|
+
"pi-famulus-win-arm64": "0.2.2-beta.0"
|
|
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;
|
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
|
});
|
package/src/index.ts
CHANGED
|
@@ -19,6 +19,7 @@ import type { ExtensionAPI, ExtensionContext, ToolDefinition } from "@earendil-w
|
|
|
19
19
|
import { resolveAgent as resolveAgentDef } from "./agents/definition";
|
|
20
20
|
import { createAgentLoader, type AgentLoader } from "./agents/loader";
|
|
21
21
|
import { createBashOverride } from "./bash-override";
|
|
22
|
+
import { cleanupSessionSpillFiles } from "./bounded-output";
|
|
22
23
|
import { createComms, type CommsWithOrigin } from "./comms/comms";
|
|
23
24
|
import { createRegistryCommsHost } from "./comms/registry-host";
|
|
24
25
|
import { createAgentMessageTool } from "./comms/tools";
|
|
@@ -27,6 +28,7 @@ import { describeManagerSearch, getFamulusHome, loadConfig, resolveManagerPath,
|
|
|
27
28
|
import { ensureNativeManagerInstalled } from "./native-manager.js";
|
|
28
29
|
import type { TaskExitInfo } from "./format";
|
|
29
30
|
import { EXTENSION_VERSION, ManagerClient, type ManagerEvent, type TaskRecord } from "./manager-client";
|
|
31
|
+
import { registerIntercom } from "./intercom/intercom";
|
|
30
32
|
import { createMonitorTool, exitEventFromRecord, MonitorRegistry } from "./monitor";
|
|
31
33
|
import { admitAgentChild, reregisterAgentLeases } from "./subagent/admission";
|
|
32
34
|
import { NotifyCenter } from "./notify";
|
|
@@ -35,6 +37,8 @@ import { ChildShellTracker, createOverrunNotifier } from "./subagent/overrun";
|
|
|
35
37
|
import {
|
|
36
38
|
agentEndReason,
|
|
37
39
|
headOf,
|
|
40
|
+
loadAgentChildRecords,
|
|
41
|
+
findAgentChildRecord,
|
|
38
42
|
tailOf,
|
|
39
43
|
updateAgentChildMetrics,
|
|
40
44
|
writeAgentChildRecord,
|
|
@@ -42,8 +46,9 @@ import {
|
|
|
42
46
|
} from "./subagent/agent-records";
|
|
43
47
|
import { TranscriptWriter } from "./subagent/transcript";
|
|
44
48
|
import { FleetWidget } from "./subagent/fleet-widget";
|
|
45
|
-
import { WorkIndex, type WorkItem } from "./work-index";
|
|
49
|
+
import { printModeDropWarning, WORK_RETAIN_MS, WorkIndex, type WorkItem } from "./work-index";
|
|
46
50
|
import { fileURLToPath } from "node:url";
|
|
51
|
+
import { AGENT_WORK_KINDS, type AgentWorkKind } from "./subagent/types";
|
|
47
52
|
import { isFamulusChildSession, modelCandidates } from "./subagent/pi-runtime";
|
|
48
53
|
import { createHostedSessionFn } from "./subagent/hosted-session";
|
|
49
54
|
import { isHostedChildProcess, registerChildMode } from "./child-mode";
|
|
@@ -113,7 +118,8 @@ export function readPreview(outputPath: string | undefined, maxChars: number): s
|
|
|
113
118
|
return tail.text.length <= maxChars ? tail.text : tail.text.slice(-maxChars);
|
|
114
119
|
}
|
|
115
120
|
|
|
116
|
-
|
|
121
|
+
/** Map a manager exit event to the background-wake status. Exported for tests. */
|
|
122
|
+
export function toExitStatus(event: ManagerEvent): TaskExitInfo["status"] {
|
|
117
123
|
// The manager died without shutting down; its runner took the task down.
|
|
118
124
|
if (event.end_reason === "manager-crash") return "orphaned";
|
|
119
125
|
if (event.signal) return "killed";
|
|
@@ -219,6 +225,8 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
|
|
|
219
225
|
* is mis-labeled as a parent "Background command" wake (§4.2 / §4.6).
|
|
220
226
|
*/
|
|
221
227
|
const notifyOnExit = new Set<string>();
|
|
228
|
+
/** Sessions that already received the crash-recovery notice. */
|
|
229
|
+
const recoveredSessions = new Set<string>();
|
|
222
230
|
const exitGate = new ExitNotifyGate<ManagerEvent>({ clock });
|
|
223
231
|
const workIndex = new WorkIndex({ clock });
|
|
224
232
|
const patchExited = (taskId: string, event: ManagerEvent): void => {
|
|
@@ -288,6 +296,136 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
|
|
|
288
296
|
}
|
|
289
297
|
return out;
|
|
290
298
|
};
|
|
299
|
+
/**
|
|
300
|
+
* Parent-owned shells this process did not start (the previous pi died
|
|
301
|
+
* while the daemon stayed up). Running ones join the index and get an
|
|
302
|
+
* exit wake; recent finished ones are delivered the same way.
|
|
303
|
+
* manager-shutdown / manager-crash leftovers stay for the crash notice.
|
|
304
|
+
*/
|
|
305
|
+
const adoptParentShells = (tasks: TaskRecord[]): void => {
|
|
306
|
+
const sid = ctx?.sessionManager.getSessionId() ?? "";
|
|
307
|
+
const now = clock.now();
|
|
308
|
+
for (const task of tasks) {
|
|
309
|
+
if (task.kind !== "shell") continue;
|
|
310
|
+
if (task.origin?.via === "child-bash") continue;
|
|
311
|
+
if (sid && task.session_id && task.session_id !== sid) continue;
|
|
312
|
+
if (workIndex.get(task.task_id) || taskMeta.has(task.task_id)) continue;
|
|
313
|
+
const startedAt = task.started_at ?? now;
|
|
314
|
+
taskMeta.set(task.task_id, {
|
|
315
|
+
kind: "shell",
|
|
316
|
+
command: task.command ?? "",
|
|
317
|
+
cwd: task.cwd,
|
|
318
|
+
startedAt,
|
|
319
|
+
});
|
|
320
|
+
if (task.status === "running") {
|
|
321
|
+
upsertBackgroundTask(task.task_id, startedAt);
|
|
322
|
+
markNotifyOnExit(task.task_id);
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
if (task.end_reason === "manager-shutdown" || task.end_reason === "manager-crash") continue;
|
|
326
|
+
if (task.ended_at != null && now - task.ended_at > WORK_RETAIN_MS) continue;
|
|
327
|
+
if (task.status !== "completed" && task.status !== "failed" && task.status !== "killed") continue;
|
|
328
|
+
upsertBackgroundTask(task.task_id, startedAt);
|
|
329
|
+
deliverExit(task.task_id, exitEventFromRecord(task));
|
|
330
|
+
}
|
|
331
|
+
};
|
|
332
|
+
/**
|
|
333
|
+
* A running disk record whose hosted process is gone: interrupt the live
|
|
334
|
+
* handle when this process still holds it (daemon crash), otherwise
|
|
335
|
+
* write the record. Warm children stay for resume.
|
|
336
|
+
*/
|
|
337
|
+
const settleDeadHostedChildren = async (
|
|
338
|
+
sessionId: string,
|
|
339
|
+
opts: { interruptLive?: boolean } = {},
|
|
340
|
+
): Promise<AgentChildRecord[]> => {
|
|
341
|
+
const manager = client;
|
|
342
|
+
const registry = subagentRegistry;
|
|
343
|
+
const center = notifyCenter;
|
|
344
|
+
const stale = loadAgentChildRecords(home, { sessionId, includeTerminal: false });
|
|
345
|
+
const lost: AgentChildRecord[] = [];
|
|
346
|
+
for (const rec of stale) {
|
|
347
|
+
if (rec.status !== "running" && rec.status !== "pending") continue;
|
|
348
|
+
const handle = registry?.handle(rec.child_id);
|
|
349
|
+
// A local admission queue has no hosted process to look up yet.
|
|
350
|
+
if (handle?.status() === "pending") continue;
|
|
351
|
+
let live = false;
|
|
352
|
+
if (manager?.isAvailable() && manager.protocolLevel() >= 6) {
|
|
353
|
+
const found =
|
|
354
|
+
await manager.hostedLookup({ sessionId: rec.child_id }).catch(() => null);
|
|
355
|
+
if (client !== manager || subagentRegistry !== registry || notifyCenter !== center) return [];
|
|
356
|
+
live = Boolean(found && (found.residency === "warm" || found.activity === "running" || found.activity === "starting"));
|
|
357
|
+
}
|
|
358
|
+
if (live) continue;
|
|
359
|
+
if (handle && (handle.status() === "running" || handle.status() === "pending")) {
|
|
360
|
+
if (!opts.interruptLive) continue;
|
|
361
|
+
await handle.interrupt().catch(() => {});
|
|
362
|
+
if (client !== manager || subagentRegistry !== registry || notifyCenter !== center) return [];
|
|
363
|
+
} else {
|
|
364
|
+
writeAgentChildRecord(home, {
|
|
365
|
+
...rec,
|
|
366
|
+
status: "interrupted",
|
|
367
|
+
ended_at: rec.ended_at ?? clock.now(),
|
|
368
|
+
end_reason: "interrupted",
|
|
369
|
+
error: rec.error ?? "parent session ended",
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
lost.push(rec);
|
|
373
|
+
}
|
|
374
|
+
return lost;
|
|
375
|
+
};
|
|
376
|
+
/**
|
|
377
|
+
* The previous process died. Settle stale running agent records (the child
|
|
378
|
+
* is gone) and tell the model about that work and about commands the
|
|
379
|
+
* daemon already ended as manager-shutdown / manager-crash.
|
|
380
|
+
* A hosted child that is still warm is left for resume.
|
|
381
|
+
*/
|
|
382
|
+
const recoverCrashedSession = async (sessionId: string): Promise<void> => {
|
|
383
|
+
if (!sessionId || recoveredSessions.has(sessionId)) return;
|
|
384
|
+
const manager = client;
|
|
385
|
+
const center = notifyCenter;
|
|
386
|
+
recoveredSessions.add(sessionId);
|
|
387
|
+
const interrupted =
|
|
388
|
+
await settleDeadHostedChildren(sessionId);
|
|
389
|
+
if (client !== manager || notifyCenter !== center) return;
|
|
390
|
+
let killed: TaskRecord[] = [];
|
|
391
|
+
if (manager?.isAvailable()) {
|
|
392
|
+
try {
|
|
393
|
+
const tasks = await manager.list(true);
|
|
394
|
+
if (client !== manager || notifyCenter !== center) return;
|
|
395
|
+
killed = tasks.filter((task) => {
|
|
396
|
+
if (task.session_id && task.session_id !== sessionId) return false;
|
|
397
|
+
if (task.kind !== "shell") return false;
|
|
398
|
+
return task.end_reason === "manager-shutdown" || task.end_reason === "manager-crash";
|
|
399
|
+
});
|
|
400
|
+
} catch {
|
|
401
|
+
killed = [];
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
if (interrupted.length === 0 && killed.length === 0) {
|
|
405
|
+
recoveredSessions.delete(sessionId);
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
const lines = [
|
|
409
|
+
"System wake — not a new user message.",
|
|
410
|
+
"",
|
|
411
|
+
"This session was reopened after the previous process ended.",
|
|
412
|
+
];
|
|
413
|
+
for (const rec of interrupted) {
|
|
414
|
+
lines.push(`Interrupted subagent ${rec.child_id} (${rec.name}).`);
|
|
415
|
+
}
|
|
416
|
+
for (const task of killed) {
|
|
417
|
+
lines.push(`Command ${task.task_id} ended while the session was gone (${task.end_reason ?? task.status}).`);
|
|
418
|
+
}
|
|
419
|
+
center?.notify({
|
|
420
|
+
customType: "pi-famulus-wake",
|
|
421
|
+
content: lines.join("\n"),
|
|
422
|
+
details: {
|
|
423
|
+
kind: "session-recovery",
|
|
424
|
+
children: interrupted.map((rec) => rec.child_id),
|
|
425
|
+
tasks: killed.map((task) => task.task_id),
|
|
426
|
+
},
|
|
427
|
+
});
|
|
428
|
+
};
|
|
291
429
|
/**
|
|
292
430
|
* Settle everything the manager reports as ended that we still show live:
|
|
293
431
|
* monitors in the registry and shell/monitor rows in the index. Runs when
|
|
@@ -299,6 +437,7 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
|
|
|
299
437
|
*/
|
|
300
438
|
const syncWithManager = async (): Promise<void> => {
|
|
301
439
|
const c = client;
|
|
440
|
+
const center = notifyCenter;
|
|
302
441
|
let tasks: TaskRecord[];
|
|
303
442
|
if (c && c.isAvailable()) {
|
|
304
443
|
try {
|
|
@@ -313,7 +452,9 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
|
|
|
313
452
|
if (!disk) return;
|
|
314
453
|
tasks = disk;
|
|
315
454
|
}
|
|
455
|
+
if (client !== c || notifyCenter !== center) return;
|
|
316
456
|
monitorRegistry?.reconcile(tasks);
|
|
457
|
+
adoptParentShells(tasks);
|
|
317
458
|
for (const task of workIndex.staleLive(tasks)) {
|
|
318
459
|
const event = exitEventFromRecord(task);
|
|
319
460
|
// Route through the gate: if the real task_exited is processed after
|
|
@@ -533,6 +674,40 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
|
|
|
533
674
|
if (!loader) return resolveAgentDef([], name); // builtins-only fallback
|
|
534
675
|
return resolveAgentDef(loader.reload().definitions, name);
|
|
535
676
|
},
|
|
677
|
+
recoverChild: async (childId) => {
|
|
678
|
+
const registry = subagentRegistry;
|
|
679
|
+
if (!registry) return undefined;
|
|
680
|
+
const rec = findAgentChildRecord(home, childId);
|
|
681
|
+
if (!rec) return undefined;
|
|
682
|
+
if (registry.handle(rec.child_id)) {
|
|
683
|
+
return registry.get(rec.run_id);
|
|
684
|
+
}
|
|
685
|
+
const workKind = AGENT_WORK_KINDS.includes(rec.work_kind as AgentWorkKind)
|
|
686
|
+
? rec.work_kind as AgentWorkKind
|
|
687
|
+
: undefined;
|
|
688
|
+
const loader = agentLoader;
|
|
689
|
+
const agent = resolveAgentDef(loader ? loader.reload().definitions : [], rec.agent);
|
|
690
|
+
registry.adoptExisting({
|
|
691
|
+
runId: rec.run_id,
|
|
692
|
+
childId: rec.child_id,
|
|
693
|
+
name: rec.name,
|
|
694
|
+
agent: rec.agent,
|
|
695
|
+
workKind,
|
|
696
|
+
startedAt: rec.started_at,
|
|
697
|
+
prompt: rec.prompt_head,
|
|
698
|
+
});
|
|
699
|
+
await registry.attachChild({
|
|
700
|
+
childId: rec.child_id,
|
|
701
|
+
runId: rec.run_id,
|
|
702
|
+
name: rec.name,
|
|
703
|
+
prompt: rec.prompt_head ?? "",
|
|
704
|
+
agent,
|
|
705
|
+
timeoutMs: subagentConfig.timeoutMs,
|
|
706
|
+
depth: 1,
|
|
707
|
+
...(workKind ? { workKind } : {}),
|
|
708
|
+
});
|
|
709
|
+
return registry.get(rec.run_id);
|
|
710
|
+
},
|
|
536
711
|
listModels: () => {
|
|
537
712
|
const current = ctx?.model;
|
|
538
713
|
return modelCandidates({
|
|
@@ -618,7 +793,13 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
|
|
|
618
793
|
// still carry <shell>.
|
|
619
794
|
const childShells = new ChildShellTracker();
|
|
620
795
|
const parentSessionId = startCtx.sessionManager.getSessionId();
|
|
796
|
+
// Bind this session's notify centre and client. After /new /resume /fork
|
|
797
|
+
// the outer `notifyCenter` is replaced; late events on the old client
|
|
798
|
+
// must not follow that rebinding.
|
|
799
|
+
const sessionNotify = notifyCenter;
|
|
800
|
+
const sessionClient = client;
|
|
621
801
|
client.onEvent((event) => {
|
|
802
|
+
if (client !== sessionClient) return;
|
|
622
803
|
const hosted = event as ManagerEvent & {
|
|
623
804
|
child_id?: string;
|
|
624
805
|
reason?: string;
|
|
@@ -628,6 +809,7 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
|
|
|
628
809
|
residency?: string;
|
|
629
810
|
driver?: string;
|
|
630
811
|
last_text?: string;
|
|
812
|
+
request?: { type?: string; id?: string; method?: string; title?: string; message?: string };
|
|
631
813
|
};
|
|
632
814
|
if (hosted.event === "agent_comms" && hosted.child_id && hosted.request_id) {
|
|
633
815
|
const reason = hosted.reason === "need_decision" ? "need_decision" : "progress_update";
|
|
@@ -636,8 +818,31 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
|
|
|
636
818
|
}).catch(() => {});
|
|
637
819
|
return;
|
|
638
820
|
}
|
|
821
|
+
if (hosted.event === "agent_ui" && hosted.child_id) {
|
|
822
|
+
const request = hosted.request ?? {};
|
|
823
|
+
const method = request.method;
|
|
824
|
+
if (method !== "select" && method !== "confirm" && method !== "input" && method !== "editor") return;
|
|
825
|
+
const title = typeof request.title === "string" ? request.title : "";
|
|
826
|
+
const body = typeof request.message === "string" ? request.message : "";
|
|
827
|
+
sessionNotify?.notify({
|
|
828
|
+
customType: "pi-famulus-wake",
|
|
829
|
+
content:
|
|
830
|
+
`System wake — not a new user message.\n\n` +
|
|
831
|
+
`Subagent ${hosted.child_id} is waiting for a ${method}` +
|
|
832
|
+
(title ? `: ${title}` : ".") +
|
|
833
|
+
(body ? `\n${body}` : ""),
|
|
834
|
+
details: {
|
|
835
|
+
kind: "supervisor-request",
|
|
836
|
+
from: hosted.child_id,
|
|
837
|
+
method,
|
|
838
|
+
...(title ? { title } : {}),
|
|
839
|
+
...(body ? { message: body } : {}),
|
|
840
|
+
},
|
|
841
|
+
});
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
639
844
|
if (hosted.event === "agent_detach" && hosted.child_id) {
|
|
640
|
-
|
|
845
|
+
sessionNotify?.notify({
|
|
641
846
|
customType: "pi-famulus-wake",
|
|
642
847
|
content: `System wake — not a new user message.\n\nThe user talked to ${hosted.name ?? hosted.child_id} directly.\n${hosted.last_text ?? ""}`,
|
|
643
848
|
details: { kind: "supervisor-update", from: hosted.child_id, name: hosted.name ?? hosted.child_id, message: hosted.last_text ?? "" },
|
|
@@ -703,20 +908,39 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
|
|
|
703
908
|
}
|
|
704
909
|
});
|
|
705
910
|
client.onReconnect(() => {
|
|
911
|
+
if (client !== sessionClient) return;
|
|
706
912
|
// A daemon restart may drop permits; a same-session rebind may retain
|
|
707
913
|
// them. Keep each kind for idempotent re-registration and mark all local
|
|
708
914
|
// leases pending until the daemon confirms them.
|
|
709
915
|
for (const childId of globalAgentLeases.keys()) pendingAgentLeases.add(childId);
|
|
710
|
-
void monitorRegistry?.rewatchAll().then(() =>
|
|
916
|
+
void monitorRegistry?.rewatchAll().then(() => {
|
|
917
|
+
if (client === sessionClient) return syncWithManager();
|
|
918
|
+
});
|
|
711
919
|
void (async () => {
|
|
712
|
-
const manager =
|
|
713
|
-
if (
|
|
920
|
+
const manager = sessionClient;
|
|
921
|
+
if (manager.protocolLevel() < 4) {
|
|
714
922
|
capacityNotice("daemon too old");
|
|
715
923
|
return;
|
|
716
924
|
}
|
|
717
925
|
// The registry and its child ids survive socket reconnects in this
|
|
718
926
|
// extension process; held ids include resumable interrupted children.
|
|
719
927
|
await reconcileAgentLeases(manager);
|
|
928
|
+
if (client !== manager) return;
|
|
929
|
+
const sid = parentSessionId;
|
|
930
|
+
const lost =
|
|
931
|
+
await settleDeadHostedChildren(sid, { interruptLive: true });
|
|
932
|
+
if (lost.length > 0) {
|
|
933
|
+
sessionNotify?.notify({
|
|
934
|
+
customType: "pi-famulus-wake",
|
|
935
|
+
content: [
|
|
936
|
+
"System wake — not a new user message.",
|
|
937
|
+
"",
|
|
938
|
+
"The daemon restarted. Hosted children that were running are gone.",
|
|
939
|
+
...lost.map((rec) => `Interrupted subagent ${rec.child_id} (${rec.name}).`),
|
|
940
|
+
].join("\n"),
|
|
941
|
+
details: { kind: "session-recovery", children: lost.map((rec) => rec.child_id) },
|
|
942
|
+
});
|
|
943
|
+
}
|
|
720
944
|
})().catch(() => {});
|
|
721
945
|
});
|
|
722
946
|
|
|
@@ -811,6 +1035,23 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
|
|
|
811
1035
|
// One event per stall detection: an auto-resume follows unless the
|
|
812
1036
|
// retry budget (stallRetries) is already spent.
|
|
813
1037
|
logEvent("agent.stall", { child_id: childId, attempt });
|
|
1038
|
+
notifyCenter?.notify({
|
|
1039
|
+
customType: "pi-famulus-wake",
|
|
1040
|
+
content:
|
|
1041
|
+
`System wake — not a new user message.\n\n` +
|
|
1042
|
+
`Subagent ${childId} stalled with no activity (attempt ${attempt}); retrying.`,
|
|
1043
|
+
details: { kind: "subagent-stall", child_id: childId, attempt },
|
|
1044
|
+
});
|
|
1045
|
+
},
|
|
1046
|
+
onToolLoop: (childId, toolName, args) => {
|
|
1047
|
+
const argText = args && typeof args === "object" ? JSON.stringify(args) : String(args ?? "");
|
|
1048
|
+
notifyCenter?.notify({
|
|
1049
|
+
customType: "pi-famulus-wake",
|
|
1050
|
+
content:
|
|
1051
|
+
`System wake — not a new user message.\n\n` +
|
|
1052
|
+
`Subagent ${childId} called ${toolName} three times in a row with the same arguments${argText ? `: ${argText}` : "."}`,
|
|
1053
|
+
details: { kind: "subagent-loop", child_id: childId, tool: toolName, args },
|
|
1054
|
+
});
|
|
814
1055
|
},
|
|
815
1056
|
// Soft deadline: the child keeps running; the parent decides.
|
|
816
1057
|
onOverrun: createOverrunNotifier({
|
|
@@ -898,6 +1139,7 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
|
|
|
898
1139
|
};
|
|
899
1140
|
const endReason = agentEndReason(c.status, c.result);
|
|
900
1141
|
if (endReason) rec.end_reason = endReason;
|
|
1142
|
+
if (c.result?.retryAfter !== undefined) rec.retry_after = c.result.retryAfter;
|
|
901
1143
|
const transcript = syncTranscript(c.childId);
|
|
902
1144
|
if (transcript) {
|
|
903
1145
|
rec.transcript = transcript;
|
|
@@ -927,12 +1169,16 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
|
|
|
927
1169
|
}
|
|
928
1170
|
});
|
|
929
1171
|
startFleetWidget(startCtx);
|
|
1172
|
+
if (client?.isAvailable()) {
|
|
1173
|
+
await recoverCrashedSession(startCtx.sessionManager.getSessionId());
|
|
1174
|
+
}
|
|
930
1175
|
|
|
931
1176
|
// Connect in the background: a cold manager spawn must not stall the
|
|
932
1177
|
// first prompt. The cache install, if one is running, finishes first so a
|
|
933
1178
|
// missing optional package is not reported before it can be restored.
|
|
934
1179
|
// Tools call ensureAvailable() lazily before use.
|
|
935
|
-
|
|
1180
|
+
if (client !== sessionClient) return;
|
|
1181
|
+
const c = sessionClient;
|
|
936
1182
|
void nativeRestore
|
|
937
1183
|
.then((path) => {
|
|
938
1184
|
if (client !== c || !c) return null;
|
|
@@ -946,6 +1192,8 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
|
|
|
946
1192
|
const file = startCtx.sessionManager.getSessionFile?.();
|
|
947
1193
|
const sid = startCtx.sessionManager.getSessionId();
|
|
948
1194
|
void promptIfHostedSession(c, () => ctx, { sessionFile: file, sessionId: sid }, "start").catch(() => {});
|
|
1195
|
+
void syncWithManager();
|
|
1196
|
+
void recoverCrashedSession(sid);
|
|
949
1197
|
return;
|
|
950
1198
|
}
|
|
951
1199
|
// A /reload may have replaced startCtx by now; report to the live one.
|
|
@@ -975,6 +1223,8 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
|
|
|
975
1223
|
agentLoader = null;
|
|
976
1224
|
monitorRegistry?.disposeAll();
|
|
977
1225
|
notifyCenter?.dispose();
|
|
1226
|
+
recoveredSessions.clear();
|
|
1227
|
+
cleanupSessionSpillFiles();
|
|
978
1228
|
const current = client;
|
|
979
1229
|
client = null;
|
|
980
1230
|
if (current) {
|
|
@@ -1010,6 +1260,14 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
|
|
|
1010
1260
|
parkRuntime({ version: EXTENSION_VERSION, runtime, dispose: teardown });
|
|
1011
1261
|
return;
|
|
1012
1262
|
}
|
|
1263
|
+
if (String((ctx as { mode?: string } | null)?.mode) === "print") {
|
|
1264
|
+
const ids = [
|
|
1265
|
+
...workIndex.list().filter((item) => item.status === "running" || item.status === "pending").map((item) => item.id),
|
|
1266
|
+
...(subagentRegistry?.activeChildren() ?? []).map((child) => child.childId),
|
|
1267
|
+
];
|
|
1268
|
+
const warning = printModeDropWarning([...new Set(ids)]);
|
|
1269
|
+
if (warning) process.stderr.write(warning);
|
|
1270
|
+
}
|
|
1013
1271
|
await teardown();
|
|
1014
1272
|
});
|
|
1015
1273
|
|
|
@@ -1027,5 +1285,11 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
|
|
|
1027
1285
|
return message ? { message } : undefined;
|
|
1028
1286
|
});
|
|
1029
1287
|
|
|
1288
|
+
// The intercom port (cross-session messaging, absorbed from the
|
|
1289
|
+
// agent-intercom pi plugin) rides the same manager connection. Registered
|
|
1290
|
+
// after the session hooks above so its session_start sees the fresh
|
|
1291
|
+
// client. Hosted children never reach here (child-mode returns early).
|
|
1292
|
+
registerIntercom(pi, { getClient: () => client });
|
|
1293
|
+
|
|
1030
1294
|
return runtime;
|
|
1031
1295
|
}
|