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 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": {
@@ -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
- function toExitStatus(event: ManagerEvent): TaskExitInfo["status"] {
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
- notifyCenter?.notify({
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(() => syncWithManager());
916
+ void monitorRegistry?.rewatchAll().then(() => {
917
+ if (client === sessionClient) return syncWithManager();
918
+ });
711
919
  void (async () => {
712
- const manager = client;
713
- if (!manager || manager.protocolLevel() < 4) {
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
- const c = client;
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
  }