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/src/index.ts CHANGED
@@ -19,11 +19,12 @@ 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
- import { createAgentMessageTool, createContactSupervisorTool } from "./comms/tools";
25
+ import { createAgentMessageTool } from "./comms/tools";
25
26
  import { registerReplyCommand } from "./comms/reply-command";
26
- import { describeManagerSearch, getFamulusHome, loadConfig, resolveManagerPath, resolveSubagentConfig } from "./config";
27
+ import { describeManagerSearch, getFamulusHome, loadConfig, resolveManagerPath, resolveSubagentConfig, taskOutputPath } from "./config";
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,11 +32,12 @@ import { createMonitorTool, exitEventFromRecord, MonitorRegistry } from "./monit
31
32
  import { admitAgentChild, reregisterAgentLeases } from "./subagent/admission";
32
33
  import { NotifyCenter } from "./notify";
33
34
  import { onWakeMessageEnd, registryStatusLookup } from "./wake-delivery";
34
- import { createChildBashTool } from "./subagent/child-bash";
35
35
  import { ChildShellTracker, createOverrunNotifier } from "./subagent/overrun";
36
36
  import {
37
37
  agentEndReason,
38
38
  headOf,
39
+ loadAgentChildRecords,
40
+ findAgentChildRecord,
39
41
  tailOf,
40
42
  updateAgentChildMetrics,
41
43
  writeAgentChildRecord,
@@ -43,8 +45,13 @@ import {
43
45
  } from "./subagent/agent-records";
44
46
  import { TranscriptWriter } from "./subagent/transcript";
45
47
  import { FleetWidget } from "./subagent/fleet-widget";
46
- import { WorkIndex, type WorkItem } from "./work-index";
47
- import { createPiSessionFn, isFamulusChildSession, modelCandidates } from "./subagent/pi-runtime";
48
+ import { printModeDropWarning, WORK_RETAIN_MS, WorkIndex, type WorkItem } from "./work-index";
49
+ import { fileURLToPath } from "node:url";
50
+ import { AGENT_WORK_KINDS, type AgentWorkKind } from "./subagent/types";
51
+ import { isFamulusChildSession, modelCandidates } from "./subagent/pi-runtime";
52
+ import { createHostedSessionFn } from "./subagent/hosted-session";
53
+ import { isHostedChildProcess, registerChildMode } from "./child-mode";
54
+ import { openAttachView, registerAttachCommand } from "./tui/attach-command";
48
55
  import { SubagentRegistry } from "./subagent/registry";
49
56
  import { InProcessRunner } from "./subagent/runner";
50
57
  import { createSubagentTool } from "./subagent/tool";
@@ -56,6 +63,52 @@ import { registerTasksCommand } from "./tui/tasks-command";
56
63
  import { stderrPathFor } from "./tui/task-output-paths";
57
64
  import { shellWakeTitle } from "./wake";
58
65
 
66
+ async function promptIfHostedSession(
67
+ c: ManagerClient,
68
+ live: () => ExtensionContext | null,
69
+ query: { sessionFile?: string | null; sessionId?: string },
70
+ where: "start" | "switch",
71
+ ): Promise<"takeover" | "stay" | "none"> {
72
+ if (!c.isAvailable() || c.protocolLevel() < 6) return "none";
73
+ const found = await c.hostedLookup({
74
+ ...(query.sessionId ? { sessionId: query.sessionId } : {}),
75
+ ...(query.sessionFile ? { sessionFile: query.sessionFile } : {}),
76
+ }).catch(() => null);
77
+ if (!found || found.residency !== "warm" || typeof found.child_id !== "string") return "none";
78
+ const uiCtx = live();
79
+ if (!uiCtx) return "none";
80
+ if (where === "start" && query.sessionId && uiCtx.sessionManager.getSessionId() !== query.sessionId) return "none";
81
+ if (!uiCtx.hasUI) {
82
+ uiCtx.ui.notify(
83
+ "This session file belongs to a warm hosted subagent. Use /attach from the parent, or open it in the TUI to take over.",
84
+ "warning",
85
+ );
86
+ return "stay";
87
+ }
88
+ const choice = await uiCtx.ui.select("This session is hosted by pi-famulus", [
89
+ "Attach to the hosted process",
90
+ "Take over",
91
+ "Cancel",
92
+ ]);
93
+ if (choice === "Take over") {
94
+ try {
95
+ await c.agentTakeover(found.child_id);
96
+ return "takeover";
97
+ } catch (err) {
98
+ uiCtx.ui.notify(err instanceof Error ? err.message : String(err), "error");
99
+ return "stay";
100
+ }
101
+ }
102
+ if (choice === "Attach to the hosted process") {
103
+ try {
104
+ await openAttachView(uiCtx, c, found.child_id);
105
+ } catch (err) {
106
+ uiCtx.ui.notify(err instanceof Error ? err.message : String(err), "error");
107
+ }
108
+ }
109
+ return "stay";
110
+ }
111
+
59
112
  /** Read only the tail of a task output file for the notification preview (≤ maxChars). */
60
113
  export function readPreview(outputPath: string | undefined, maxChars: number): string {
61
114
  if (!outputPath) return "";
@@ -64,7 +117,8 @@ export function readPreview(outputPath: string | undefined, maxChars: number): s
64
117
  return tail.text.length <= maxChars ? tail.text : tail.text.slice(-maxChars);
65
118
  }
66
119
 
67
- function toExitStatus(event: ManagerEvent): TaskExitInfo["status"] {
120
+ /** Map a manager exit event to the background-wake status. Exported for tests. */
121
+ export function toExitStatus(event: ManagerEvent): TaskExitInfo["status"] {
68
122
  // The manager died without shutting down; its runner took the task down.
69
123
  if (event.end_reason === "manager-crash") return "orphaned";
70
124
  if (event.signal) return "killed";
@@ -77,6 +131,10 @@ interface FamulusRuntime {
77
131
  }
78
132
 
79
133
  export default function (pi: ExtensionAPI): void {
134
+ if (isHostedChildProcess()) {
135
+ registerChildMode(pi);
136
+ return;
137
+ }
80
138
  if (isFamulusChildSession(pi)) return;
81
139
  // A /reload parked the previous instance's runtime: adopt it so its shells,
82
140
  // monitors and subagents keep running (see reload-handoff.ts). Another
@@ -166,6 +224,8 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
166
224
  * is mis-labeled as a parent "Background command" wake (§4.2 / §4.6).
167
225
  */
168
226
  const notifyOnExit = new Set<string>();
227
+ /** Sessions that already received the crash-recovery notice. */
228
+ const recoveredSessions = new Set<string>();
169
229
  const exitGate = new ExitNotifyGate<ManagerEvent>({ clock });
170
230
  const workIndex = new WorkIndex({ clock });
171
231
  const patchExited = (taskId: string, event: ManagerEvent): void => {
@@ -235,6 +295,136 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
235
295
  }
236
296
  return out;
237
297
  };
298
+ /**
299
+ * Parent-owned shells this process did not start (the previous pi died
300
+ * while the daemon stayed up). Running ones join the index and get an
301
+ * exit wake; recent finished ones are delivered the same way.
302
+ * manager-shutdown / manager-crash leftovers stay for the crash notice.
303
+ */
304
+ const adoptParentShells = (tasks: TaskRecord[]): void => {
305
+ const sid = ctx?.sessionManager.getSessionId() ?? "";
306
+ const now = clock.now();
307
+ for (const task of tasks) {
308
+ if (task.kind !== "shell") continue;
309
+ if (task.origin?.via === "child-bash") continue;
310
+ if (sid && task.session_id && task.session_id !== sid) continue;
311
+ if (workIndex.get(task.task_id) || taskMeta.has(task.task_id)) continue;
312
+ const startedAt = task.started_at ?? now;
313
+ taskMeta.set(task.task_id, {
314
+ kind: "shell",
315
+ command: task.command ?? "",
316
+ cwd: task.cwd,
317
+ startedAt,
318
+ });
319
+ if (task.status === "running") {
320
+ upsertBackgroundTask(task.task_id, startedAt);
321
+ markNotifyOnExit(task.task_id);
322
+ continue;
323
+ }
324
+ if (task.end_reason === "manager-shutdown" || task.end_reason === "manager-crash") continue;
325
+ if (task.ended_at != null && now - task.ended_at > WORK_RETAIN_MS) continue;
326
+ if (task.status !== "completed" && task.status !== "failed" && task.status !== "killed") continue;
327
+ upsertBackgroundTask(task.task_id, startedAt);
328
+ deliverExit(task.task_id, exitEventFromRecord(task));
329
+ }
330
+ };
331
+ /**
332
+ * A running disk record whose hosted process is gone: interrupt the live
333
+ * handle when this process still holds it (daemon crash), otherwise
334
+ * write the record. Warm children stay for resume.
335
+ */
336
+ const settleDeadHostedChildren = async (
337
+ sessionId: string,
338
+ opts: { interruptLive?: boolean } = {},
339
+ ): Promise<AgentChildRecord[]> => {
340
+ const manager = client;
341
+ const registry = subagentRegistry;
342
+ const center = notifyCenter;
343
+ const stale = loadAgentChildRecords(home, { sessionId, includeTerminal: false });
344
+ const lost: AgentChildRecord[] = [];
345
+ for (const rec of stale) {
346
+ if (rec.status !== "running" && rec.status !== "pending") continue;
347
+ const handle = registry?.handle(rec.child_id);
348
+ // A local admission queue has no hosted process to look up yet.
349
+ if (handle?.status() === "pending") continue;
350
+ let live = false;
351
+ if (manager?.isAvailable() && manager.protocolLevel() >= 6) {
352
+ const found =
353
+ await manager.hostedLookup({ sessionId: rec.child_id }).catch(() => null);
354
+ if (client !== manager || subagentRegistry !== registry || notifyCenter !== center) return [];
355
+ live = Boolean(found && (found.residency === "warm" || found.activity === "running" || found.activity === "starting"));
356
+ }
357
+ if (live) continue;
358
+ if (handle && (handle.status() === "running" || handle.status() === "pending")) {
359
+ if (!opts.interruptLive) continue;
360
+ await handle.interrupt().catch(() => {});
361
+ if (client !== manager || subagentRegistry !== registry || notifyCenter !== center) return [];
362
+ } else {
363
+ writeAgentChildRecord(home, {
364
+ ...rec,
365
+ status: "interrupted",
366
+ ended_at: rec.ended_at ?? clock.now(),
367
+ end_reason: "interrupted",
368
+ error: rec.error ?? "parent session ended",
369
+ });
370
+ }
371
+ lost.push(rec);
372
+ }
373
+ return lost;
374
+ };
375
+ /**
376
+ * The previous process died. Settle stale running agent records (the child
377
+ * is gone) and tell the model about that work and about commands the
378
+ * daemon already ended as manager-shutdown / manager-crash.
379
+ * A hosted child that is still warm is left for resume.
380
+ */
381
+ const recoverCrashedSession = async (sessionId: string): Promise<void> => {
382
+ if (!sessionId || recoveredSessions.has(sessionId)) return;
383
+ const manager = client;
384
+ const center = notifyCenter;
385
+ recoveredSessions.add(sessionId);
386
+ const interrupted =
387
+ await settleDeadHostedChildren(sessionId);
388
+ if (client !== manager || notifyCenter !== center) return;
389
+ let killed: TaskRecord[] = [];
390
+ if (manager?.isAvailable()) {
391
+ try {
392
+ const tasks = await manager.list(true);
393
+ if (client !== manager || notifyCenter !== center) return;
394
+ killed = tasks.filter((task) => {
395
+ if (task.session_id && task.session_id !== sessionId) return false;
396
+ if (task.kind !== "shell") return false;
397
+ return task.end_reason === "manager-shutdown" || task.end_reason === "manager-crash";
398
+ });
399
+ } catch {
400
+ killed = [];
401
+ }
402
+ }
403
+ if (interrupted.length === 0 && killed.length === 0) {
404
+ recoveredSessions.delete(sessionId);
405
+ return;
406
+ }
407
+ const lines = [
408
+ "System wake — not a new user message.",
409
+ "",
410
+ "This session was reopened after the previous process ended.",
411
+ ];
412
+ for (const rec of interrupted) {
413
+ lines.push(`Interrupted subagent ${rec.child_id} (${rec.name}).`);
414
+ }
415
+ for (const task of killed) {
416
+ lines.push(`Command ${task.task_id} ended while the session was gone (${task.end_reason ?? task.status}).`);
417
+ }
418
+ center?.notify({
419
+ customType: "pi-famulus-wake",
420
+ content: lines.join("\n"),
421
+ details: {
422
+ kind: "session-recovery",
423
+ children: interrupted.map((rec) => rec.child_id),
424
+ tasks: killed.map((task) => task.task_id),
425
+ },
426
+ });
427
+ };
238
428
  /**
239
429
  * Settle everything the manager reports as ended that we still show live:
240
430
  * monitors in the registry and shell/monitor rows in the index. Runs when
@@ -246,6 +436,7 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
246
436
  */
247
437
  const syncWithManager = async (): Promise<void> => {
248
438
  const c = client;
439
+ const center = notifyCenter;
249
440
  let tasks: TaskRecord[];
250
441
  if (c && c.isAvailable()) {
251
442
  try {
@@ -260,7 +451,9 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
260
451
  if (!disk) return;
261
452
  tasks = disk;
262
453
  }
454
+ if (client !== c || notifyCenter !== center) return;
263
455
  monitorRegistry?.reconcile(tasks);
456
+ adoptParentShells(tasks);
264
457
  for (const task of workIndex.staleLive(tasks)) {
265
458
  const event = exitEventFromRecord(task);
266
459
  // Route through the gate: if the real task_exited is processed after
@@ -414,6 +607,10 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
414
607
  clock,
415
608
  syncWithManager: deps.syncWithManager,
416
609
  });
610
+ registerAttachCommand(pi, {
611
+ getClient: () => client,
612
+ getRegistry: () => subagentRegistry,
613
+ });
417
614
  famulusInfo = {
418
615
  extensionVersion: EXTENSION_VERSION,
419
616
  managerPath,
@@ -447,9 +644,11 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
447
644
  // M4: comms host bridges the registry and the NotifyCenter. A single Comms
448
645
  // instance lives at extension scope (mailbox is per-run namespaced; pending
449
646
  // waiters are resolved via dispose() on session_shutdown).
647
+ let watchResume: (childId: string) => void = () => {};
450
648
  const commsHost = createRegistryCommsHost({
451
649
  getRegistry: () => subagentRegistry,
452
650
  getNotifyCenter: () => notifyCenter,
651
+ onResumeAccepted: (childId) => watchResume(childId),
453
652
  });
454
653
  const comms: CommsWithOrigin = createComms(commsHost, {
455
654
  decisionTimeoutMs: subagentConfig.decisionTimeoutMs,
@@ -466,11 +665,48 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
466
665
  defaultTimeoutMs: subagentConfig.timeoutMs,
467
666
  defaultConcurrency: subagentConfig.concurrency,
468
667
  clock,
668
+ registerResumeWatch: (watch) => {
669
+ watchResume = watch;
670
+ },
469
671
  resolveAgent: (name) => {
470
672
  const loader = agentLoader;
471
673
  if (!loader) return resolveAgentDef([], name); // builtins-only fallback
472
674
  return resolveAgentDef(loader.reload().definitions, name);
473
675
  },
676
+ recoverChild: async (childId) => {
677
+ const registry = subagentRegistry;
678
+ if (!registry) return undefined;
679
+ const rec = findAgentChildRecord(home, childId);
680
+ if (!rec) return undefined;
681
+ if (registry.handle(rec.child_id)) {
682
+ return registry.get(rec.run_id);
683
+ }
684
+ const workKind = AGENT_WORK_KINDS.includes(rec.work_kind as AgentWorkKind)
685
+ ? rec.work_kind as AgentWorkKind
686
+ : undefined;
687
+ const loader = agentLoader;
688
+ const agent = resolveAgentDef(loader ? loader.reload().definitions : [], rec.agent);
689
+ registry.adoptExisting({
690
+ runId: rec.run_id,
691
+ childId: rec.child_id,
692
+ name: rec.name,
693
+ agent: rec.agent,
694
+ workKind,
695
+ startedAt: rec.started_at,
696
+ prompt: rec.prompt_head,
697
+ });
698
+ await registry.attachChild({
699
+ childId: rec.child_id,
700
+ runId: rec.run_id,
701
+ name: rec.name,
702
+ prompt: rec.prompt_head ?? "",
703
+ agent,
704
+ timeoutMs: subagentConfig.timeoutMs,
705
+ depth: 1,
706
+ ...(workKind ? { workKind } : {}),
707
+ });
708
+ return registry.get(rec.run_id);
709
+ },
474
710
  listModels: () => {
475
711
  const current = ctx?.model;
476
712
  return modelCandidates({
@@ -546,11 +782,78 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
546
782
  sessionId: startCtx.sessionManager.getSessionId(),
547
783
  managerPath,
548
784
  cwd: startCtx.cwd,
785
+ sessionFile: startCtx.sessionManager.getSessionFile?.(),
549
786
  clock,
550
787
  log: () => {}, // keep quiet; degradation is surfaced via tools
551
788
  });
552
789
 
790
+ // Hosted children run bash in their own process. The parent learns the
791
+ // foreground shell from task_started (origin child-bash) so overrun wakes
792
+ // still carry <shell>.
793
+ const childShells = new ChildShellTracker();
794
+ const parentSessionId = startCtx.sessionManager.getSessionId();
795
+ // Bind this session's notify centre and client. After /new /resume /fork
796
+ // the outer `notifyCenter` is replaced; late events on the old client
797
+ // must not follow that rebinding.
798
+ const sessionNotify = notifyCenter;
799
+ const sessionClient = client;
553
800
  client.onEvent((event) => {
801
+ if (client !== sessionClient) return;
802
+ const hosted = event as ManagerEvent & {
803
+ child_id?: string;
804
+ reason?: string;
805
+ request_id?: string;
806
+ message?: string;
807
+ name?: string;
808
+ residency?: string;
809
+ driver?: string;
810
+ last_text?: string;
811
+ request?: { type?: string; id?: string; method?: string; title?: string; message?: string };
812
+ };
813
+ if (hosted.event === "agent_comms" && hosted.child_id && hosted.request_id) {
814
+ const reason = hosted.reason === "need_decision" ? "need_decision" : "progress_update";
815
+ void comms.contactSupervisor(hosted.child_id, reason, hosted.message ?? "").then((reply) => {
816
+ if (reason === "need_decision") void client?.agentCommsReply(hosted.request_id!, hosted.child_id!, reply).catch(() => {});
817
+ }).catch(() => {});
818
+ return;
819
+ }
820
+ if (hosted.event === "agent_ui" && hosted.child_id) {
821
+ const request = hosted.request ?? {};
822
+ const method = request.method;
823
+ if (method !== "select" && method !== "confirm" && method !== "input" && method !== "editor") return;
824
+ const title = typeof request.title === "string" ? request.title : "";
825
+ const body = typeof request.message === "string" ? request.message : "";
826
+ sessionNotify?.notify({
827
+ customType: "pi-famulus-wake",
828
+ content:
829
+ `System wake — not a new user message.\n\n` +
830
+ `Subagent ${hosted.child_id} is waiting for a ${method}` +
831
+ (title ? `: ${title}` : ".") +
832
+ (body ? `\n${body}` : ""),
833
+ details: {
834
+ kind: "supervisor-request",
835
+ from: hosted.child_id,
836
+ method,
837
+ ...(title ? { title } : {}),
838
+ ...(body ? { message: body } : {}),
839
+ },
840
+ });
841
+ return;
842
+ }
843
+ if (hosted.event === "agent_detach" && hosted.child_id) {
844
+ sessionNotify?.notify({
845
+ customType: "pi-famulus-wake",
846
+ content: `System wake — not a new user message.\n\nThe user talked to ${hosted.name ?? hosted.child_id} directly.\n${hosted.last_text ?? ""}`,
847
+ details: { kind: "supervisor-update", from: hosted.child_id, name: hosted.name ?? hosted.child_id, message: hosted.last_text ?? "" },
848
+ });
849
+ return;
850
+ }
851
+ if (hosted.event === "agent_state" && hosted.child_id) {
852
+ const handle = subagentRegistry?.handle(hosted.child_id) as { pauseStall?: () => void; resumeStall?: () => void } | undefined;
853
+ if (hosted.driver === "user") handle?.pauseStall?.();
854
+ else handle?.resumeStall?.();
855
+ return;
856
+ }
554
857
  if (event.event === "task_started" && event.task_id) {
555
858
  const prior = taskMeta.get(event.task_id);
556
859
  taskMeta.set(event.task_id, {
@@ -562,6 +865,14 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
562
865
  ? { startedAt: prior.startedAt }
563
866
  : {}),
564
867
  });
868
+ if (event.origin?.via === "child-bash" && event.origin.child_id) {
869
+ childShells.start(event.origin.child_id, {
870
+ taskId: event.task_id,
871
+ command: event.command ?? "",
872
+ startedAt: typeof event.ts === "number" ? event.ts : clock.now(),
873
+ outputPath: taskOutputPath(home, parentSessionId, event.task_id),
874
+ });
875
+ }
565
876
  if (notifyOnExit.has(event.task_id) && typeof event.ts === "number") {
566
877
  upsertBackgroundTask(event.task_id, event.ts);
567
878
  }
@@ -584,6 +895,7 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
584
895
  return;
585
896
  }
586
897
  if (event.event === "task_exited" && event.task_id) {
898
+ childShells.endByTask(event.task_id);
587
899
  // A known monitor closes (and patches the index via onExited); an
588
900
  // unknown id is buffered in case a monitor start is about to claim it.
589
901
  if (monitorRegistry?.handleExit(event.task_id, event)) return;
@@ -595,20 +907,39 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
595
907
  }
596
908
  });
597
909
  client.onReconnect(() => {
910
+ if (client !== sessionClient) return;
598
911
  // A daemon restart may drop permits; a same-session rebind may retain
599
912
  // them. Keep each kind for idempotent re-registration and mark all local
600
913
  // leases pending until the daemon confirms them.
601
914
  for (const childId of globalAgentLeases.keys()) pendingAgentLeases.add(childId);
602
- void monitorRegistry?.rewatchAll().then(() => syncWithManager());
915
+ void monitorRegistry?.rewatchAll().then(() => {
916
+ if (client === sessionClient) return syncWithManager();
917
+ });
603
918
  void (async () => {
604
- const manager = client;
605
- if (!manager || manager.protocolLevel() < 4) {
919
+ const manager = sessionClient;
920
+ if (manager.protocolLevel() < 4) {
606
921
  capacityNotice("daemon too old");
607
922
  return;
608
923
  }
609
924
  // The registry and its child ids survive socket reconnects in this
610
925
  // extension process; held ids include resumable interrupted children.
611
926
  await reconcileAgentLeases(manager);
927
+ if (client !== manager) return;
928
+ const sid = parentSessionId;
929
+ const lost =
930
+ await settleDeadHostedChildren(sid, { interruptLive: true });
931
+ if (lost.length > 0) {
932
+ sessionNotify?.notify({
933
+ customType: "pi-famulus-wake",
934
+ content: [
935
+ "System wake — not a new user message.",
936
+ "",
937
+ "The daemon restarted. Hosted children that were running are gone.",
938
+ ...lost.map((rec) => `Interrupted subagent ${rec.child_id} (${rec.name}).`),
939
+ ].join("\n"),
940
+ details: { kind: "session-recovery", children: lost.map((rec) => rec.child_id) },
941
+ });
942
+ }
612
943
  })().catch(() => {});
613
944
  });
614
945
 
@@ -633,9 +964,7 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
633
964
  "warning",
634
965
  );
635
966
  }
636
- // The foreground shell each child is blocked on, for its overrun wake.
637
- const childShells = new ChildShellTracker();
638
- const createSession = createPiSessionFn({
967
+ const createSession = createHostedSessionFn({
639
968
  getModelRegistry: () => ctx?.modelRegistry ?? null,
640
969
  getModelRuntime: () => {
641
970
  const registry = ctx?.modelRegistry as { runtime?: unknown } | null | undefined;
@@ -646,29 +975,10 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
646
975
  getScopedModels: () => ctx?.scopedModels ?? [],
647
976
  getCwd: () => ctx?.cwd ?? process.cwd(),
648
977
  getProjectTrusted: () => ctx?.isProjectTrusted() ?? false,
649
- customTools: (req) => {
650
- const tools: Array<ToolDefinition<any, any, any>> = [
651
- // M4: every child can reach the supervisor and its siblings.
652
- createContactSupervisorTool(comms, req.childId),
653
- createAgentMessageTool(comms, { kind: "child", childId: req.childId, runId: req.runId }, commsHost, clock),
654
- ];
655
- // The no-background bash variant replaces the built-in bash inside
656
- // child sessions (custom tools override builtins by name).
657
- tools.push(
658
- createChildBashTool({
659
- getClient: () => client,
660
- home,
661
- sessionId: () => ctx?.sessionManager.getSessionId() ?? "",
662
- sessionEnv: () => (ctx ? sessionEnv(ctx) : {}),
663
- trackTask,
664
- childId: req.childId,
665
- runId: req.runId,
666
- clock,
667
- shells: childShells,
668
- }),
669
- );
670
- return tools;
671
- },
978
+ getClient: () => client,
979
+ getHome: () => home,
980
+ extensionEntry: fileURLToPath(import.meta.url),
981
+ piCommand: subagentConfig.piCommand,
672
982
  });
673
983
  // The registry outlives startCtx across a /reload, which leaves startCtx
674
984
  // stale; its closures keep plain values instead.
@@ -724,6 +1034,23 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
724
1034
  // One event per stall detection: an auto-resume follows unless the
725
1035
  // retry budget (stallRetries) is already spent.
726
1036
  logEvent("agent.stall", { child_id: childId, attempt });
1037
+ notifyCenter?.notify({
1038
+ customType: "pi-famulus-wake",
1039
+ content:
1040
+ `System wake — not a new user message.\n\n` +
1041
+ `Subagent ${childId} stalled with no activity (attempt ${attempt}); retrying.`,
1042
+ details: { kind: "subagent-stall", child_id: childId, attempt },
1043
+ });
1044
+ },
1045
+ onToolLoop: (childId, toolName, args) => {
1046
+ const argText = args && typeof args === "object" ? JSON.stringify(args) : String(args ?? "");
1047
+ notifyCenter?.notify({
1048
+ customType: "pi-famulus-wake",
1049
+ content:
1050
+ `System wake — not a new user message.\n\n` +
1051
+ `Subagent ${childId} called ${toolName} three times in a row with the same arguments${argText ? `: ${argText}` : "."}`,
1052
+ details: { kind: "subagent-loop", child_id: childId, tool: toolName, args },
1053
+ });
727
1054
  },
728
1055
  // Soft deadline: the child keeps running; the parent decides.
729
1056
  onOverrun: createOverrunNotifier({
@@ -811,6 +1138,7 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
811
1138
  };
812
1139
  const endReason = agentEndReason(c.status, c.result);
813
1140
  if (endReason) rec.end_reason = endReason;
1141
+ if (c.result?.retryAfter !== undefined) rec.retry_after = c.result.retryAfter;
814
1142
  const transcript = syncTranscript(c.childId);
815
1143
  if (transcript) {
816
1144
  rec.transcript = transcript;
@@ -840,12 +1168,16 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
840
1168
  }
841
1169
  });
842
1170
  startFleetWidget(startCtx);
1171
+ if (client?.isAvailable()) {
1172
+ await recoverCrashedSession(startCtx.sessionManager.getSessionId());
1173
+ }
843
1174
 
844
1175
  // Connect in the background: a cold manager spawn must not stall the
845
1176
  // first prompt. The cache install, if one is running, finishes first so a
846
1177
  // missing optional package is not reported before it can be restored.
847
1178
  // Tools call ensureAvailable() lazily before use.
848
- const c = client;
1179
+ if (client !== sessionClient) return;
1180
+ const c = sessionClient;
849
1181
  void nativeRestore
850
1182
  .then((path) => {
851
1183
  if (client !== c || !c) return null;
@@ -856,6 +1188,11 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
856
1188
  if (ok === null || client !== c || !c) return;
857
1189
  if (ok) {
858
1190
  if (c.protocolLevel() >= 4) void reconcileAgentLeases(c).catch(() => {});
1191
+ const file = startCtx.sessionManager.getSessionFile?.();
1192
+ const sid = startCtx.sessionManager.getSessionId();
1193
+ void promptIfHostedSession(c, () => ctx, { sessionFile: file, sessionId: sid }, "start").catch(() => {});
1194
+ void syncWithManager();
1195
+ void recoverCrashedSession(sid);
859
1196
  return;
860
1197
  }
861
1198
  // A /reload may have replaced startCtx by now; report to the live one.
@@ -885,6 +1222,8 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
885
1222
  agentLoader = null;
886
1223
  monitorRegistry?.disposeAll();
887
1224
  notifyCenter?.dispose();
1225
+ recoveredSessions.clear();
1226
+ cleanupSessionSpillFiles();
888
1227
  const current = client;
889
1228
  client = null;
890
1229
  if (current) {
@@ -900,6 +1239,14 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
900
1239
  };
901
1240
  const runtime: FamulusRuntime = { attach: (host) => binding.attach(host) };
902
1241
 
1242
+ pi.on("session_before_switch", async (event, switchCtx) => {
1243
+ const file = event.targetSessionFile;
1244
+ if (!file || !client?.isAvailable()) return;
1245
+ const outcome = await promptIfHostedSession(client, () => switchCtx, { sessionFile: file }, "switch");
1246
+ if (outcome === "none" || outcome === "takeover") return;
1247
+ return { cancel: true };
1248
+ });
1249
+
903
1250
  pi.on("session_shutdown", async (event) => {
904
1251
  if (event.reason === "reload" && ctx) {
905
1252
  // Keep the session's shells, monitors and subagents running; the
@@ -912,6 +1259,14 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
912
1259
  parkRuntime({ version: EXTENSION_VERSION, runtime, dispose: teardown });
913
1260
  return;
914
1261
  }
1262
+ if (String((ctx as { mode?: string } | null)?.mode) === "print") {
1263
+ const ids = [
1264
+ ...workIndex.list().filter((item) => item.status === "running" || item.status === "pending").map((item) => item.id),
1265
+ ...(subagentRegistry?.activeChildren() ?? []).map((child) => child.childId),
1266
+ ];
1267
+ const warning = printModeDropWarning([...new Set(ids)]);
1268
+ if (warning) process.stderr.write(warning);
1269
+ }
915
1270
  await teardown();
916
1271
  });
917
1272