pi-famulus 0.2.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-famulus",
3
- "version": "0.2.0",
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.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.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": {
@@ -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";
@@ -35,6 +36,8 @@ import { ChildShellTracker, createOverrunNotifier } from "./subagent/overrun";
35
36
  import {
36
37
  agentEndReason,
37
38
  headOf,
39
+ loadAgentChildRecords,
40
+ findAgentChildRecord,
38
41
  tailOf,
39
42
  updateAgentChildMetrics,
40
43
  writeAgentChildRecord,
@@ -42,8 +45,9 @@ import {
42
45
  } from "./subagent/agent-records";
43
46
  import { TranscriptWriter } from "./subagent/transcript";
44
47
  import { FleetWidget } from "./subagent/fleet-widget";
45
- import { WorkIndex, type WorkItem } from "./work-index";
48
+ import { printModeDropWarning, WORK_RETAIN_MS, WorkIndex, type WorkItem } from "./work-index";
46
49
  import { fileURLToPath } from "node:url";
50
+ import { AGENT_WORK_KINDS, type AgentWorkKind } from "./subagent/types";
47
51
  import { isFamulusChildSession, modelCandidates } from "./subagent/pi-runtime";
48
52
  import { createHostedSessionFn } from "./subagent/hosted-session";
49
53
  import { isHostedChildProcess, registerChildMode } from "./child-mode";
@@ -113,7 +117,8 @@ export function readPreview(outputPath: string | undefined, maxChars: number): s
113
117
  return tail.text.length <= maxChars ? tail.text : tail.text.slice(-maxChars);
114
118
  }
115
119
 
116
- 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"] {
117
122
  // The manager died without shutting down; its runner took the task down.
118
123
  if (event.end_reason === "manager-crash") return "orphaned";
119
124
  if (event.signal) return "killed";
@@ -219,6 +224,8 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
219
224
  * is mis-labeled as a parent "Background command" wake (§4.2 / §4.6).
220
225
  */
221
226
  const notifyOnExit = new Set<string>();
227
+ /** Sessions that already received the crash-recovery notice. */
228
+ const recoveredSessions = new Set<string>();
222
229
  const exitGate = new ExitNotifyGate<ManagerEvent>({ clock });
223
230
  const workIndex = new WorkIndex({ clock });
224
231
  const patchExited = (taskId: string, event: ManagerEvent): void => {
@@ -288,6 +295,136 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
288
295
  }
289
296
  return out;
290
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
+ };
291
428
  /**
292
429
  * Settle everything the manager reports as ended that we still show live:
293
430
  * monitors in the registry and shell/monitor rows in the index. Runs when
@@ -299,6 +436,7 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
299
436
  */
300
437
  const syncWithManager = async (): Promise<void> => {
301
438
  const c = client;
439
+ const center = notifyCenter;
302
440
  let tasks: TaskRecord[];
303
441
  if (c && c.isAvailable()) {
304
442
  try {
@@ -313,7 +451,9 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
313
451
  if (!disk) return;
314
452
  tasks = disk;
315
453
  }
454
+ if (client !== c || notifyCenter !== center) return;
316
455
  monitorRegistry?.reconcile(tasks);
456
+ adoptParentShells(tasks);
317
457
  for (const task of workIndex.staleLive(tasks)) {
318
458
  const event = exitEventFromRecord(task);
319
459
  // Route through the gate: if the real task_exited is processed after
@@ -533,6 +673,40 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
533
673
  if (!loader) return resolveAgentDef([], name); // builtins-only fallback
534
674
  return resolveAgentDef(loader.reload().definitions, name);
535
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
+ },
536
710
  listModels: () => {
537
711
  const current = ctx?.model;
538
712
  return modelCandidates({
@@ -618,7 +792,13 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
618
792
  // still carry <shell>.
619
793
  const childShells = new ChildShellTracker();
620
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;
621
800
  client.onEvent((event) => {
801
+ if (client !== sessionClient) return;
622
802
  const hosted = event as ManagerEvent & {
623
803
  child_id?: string;
624
804
  reason?: string;
@@ -628,6 +808,7 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
628
808
  residency?: string;
629
809
  driver?: string;
630
810
  last_text?: string;
811
+ request?: { type?: string; id?: string; method?: string; title?: string; message?: string };
631
812
  };
632
813
  if (hosted.event === "agent_comms" && hosted.child_id && hosted.request_id) {
633
814
  const reason = hosted.reason === "need_decision" ? "need_decision" : "progress_update";
@@ -636,8 +817,31 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
636
817
  }).catch(() => {});
637
818
  return;
638
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
+ }
639
843
  if (hosted.event === "agent_detach" && hosted.child_id) {
640
- notifyCenter?.notify({
844
+ sessionNotify?.notify({
641
845
  customType: "pi-famulus-wake",
642
846
  content: `System wake — not a new user message.\n\nThe user talked to ${hosted.name ?? hosted.child_id} directly.\n${hosted.last_text ?? ""}`,
643
847
  details: { kind: "supervisor-update", from: hosted.child_id, name: hosted.name ?? hosted.child_id, message: hosted.last_text ?? "" },
@@ -703,20 +907,39 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
703
907
  }
704
908
  });
705
909
  client.onReconnect(() => {
910
+ if (client !== sessionClient) return;
706
911
  // A daemon restart may drop permits; a same-session rebind may retain
707
912
  // them. Keep each kind for idempotent re-registration and mark all local
708
913
  // leases pending until the daemon confirms them.
709
914
  for (const childId of globalAgentLeases.keys()) pendingAgentLeases.add(childId);
710
- void monitorRegistry?.rewatchAll().then(() => syncWithManager());
915
+ void monitorRegistry?.rewatchAll().then(() => {
916
+ if (client === sessionClient) return syncWithManager();
917
+ });
711
918
  void (async () => {
712
- const manager = client;
713
- if (!manager || manager.protocolLevel() < 4) {
919
+ const manager = sessionClient;
920
+ if (manager.protocolLevel() < 4) {
714
921
  capacityNotice("daemon too old");
715
922
  return;
716
923
  }
717
924
  // The registry and its child ids survive socket reconnects in this
718
925
  // extension process; held ids include resumable interrupted children.
719
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
+ }
720
943
  })().catch(() => {});
721
944
  });
722
945
 
@@ -811,6 +1034,23 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
811
1034
  // One event per stall detection: an auto-resume follows unless the
812
1035
  // retry budget (stallRetries) is already spent.
813
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
+ });
814
1054
  },
815
1055
  // Soft deadline: the child keeps running; the parent decides.
816
1056
  onOverrun: createOverrunNotifier({
@@ -898,6 +1138,7 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
898
1138
  };
899
1139
  const endReason = agentEndReason(c.status, c.result);
900
1140
  if (endReason) rec.end_reason = endReason;
1141
+ if (c.result?.retryAfter !== undefined) rec.retry_after = c.result.retryAfter;
901
1142
  const transcript = syncTranscript(c.childId);
902
1143
  if (transcript) {
903
1144
  rec.transcript = transcript;
@@ -927,12 +1168,16 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
927
1168
  }
928
1169
  });
929
1170
  startFleetWidget(startCtx);
1171
+ if (client?.isAvailable()) {
1172
+ await recoverCrashedSession(startCtx.sessionManager.getSessionId());
1173
+ }
930
1174
 
931
1175
  // Connect in the background: a cold manager spawn must not stall the
932
1176
  // first prompt. The cache install, if one is running, finishes first so a
933
1177
  // missing optional package is not reported before it can be restored.
934
1178
  // Tools call ensureAvailable() lazily before use.
935
- const c = client;
1179
+ if (client !== sessionClient) return;
1180
+ const c = sessionClient;
936
1181
  void nativeRestore
937
1182
  .then((path) => {
938
1183
  if (client !== c || !c) return null;
@@ -946,6 +1191,8 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
946
1191
  const file = startCtx.sessionManager.getSessionFile?.();
947
1192
  const sid = startCtx.sessionManager.getSessionId();
948
1193
  void promptIfHostedSession(c, () => ctx, { sessionFile: file, sessionId: sid }, "start").catch(() => {});
1194
+ void syncWithManager();
1195
+ void recoverCrashedSession(sid);
949
1196
  return;
950
1197
  }
951
1198
  // A /reload may have replaced startCtx by now; report to the live one.
@@ -975,6 +1222,8 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
975
1222
  agentLoader = null;
976
1223
  monitorRegistry?.disposeAll();
977
1224
  notifyCenter?.dispose();
1225
+ recoveredSessions.clear();
1226
+ cleanupSessionSpillFiles();
978
1227
  const current = client;
979
1228
  client = null;
980
1229
  if (current) {
@@ -1010,6 +1259,14 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
1010
1259
  parkRuntime({ version: EXTENSION_VERSION, runtime, dispose: teardown });
1011
1260
  return;
1012
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
+ }
1013
1270
  await teardown();
1014
1271
  });
1015
1272
 
@@ -18,7 +18,7 @@ import { famulusPaths } from "./config";
18
18
  import { realClock, type Clock, type ClockTimer } from "./clock";
19
19
 
20
20
  const MAX_FRAME_BYTES = 4 * 1024 * 1024; // 4 MiB (§3.3)
21
- const EXTENSION_VERSION = "0.2.0";
21
+ const EXTENSION_VERSION = "0.2.1";
22
22
  export { EXTENSION_VERSION };
23
23
  // 5: speaks per-kind agent admission and queued acquire.
24
24
  const PROTOCOL = 6;
@@ -637,6 +637,8 @@ export class ManagerClient {
637
637
  async close(): Promise<void> {
638
638
  this.intentionalClose = true;
639
639
  this.state = "disconnected";
640
+ this.eventHandlers.clear();
641
+ this.reconnectHandlers.clear();
640
642
  this.failAllPending(new Error("manager client closed"));
641
643
  const socket = this.socket;
642
644
  this.socket = null;
package/src/notify.ts CHANGED
@@ -311,4 +311,5 @@ export class NotifyCenter {
311
311
  }
312
312
  onDelivered?.(sourcePreview ?? (details?.kind === "monitor" ? (message.details as { event?: string }).event : undefined));
313
313
  }
314
+
314
315
  }
@@ -49,7 +49,7 @@ export const PROMPTS = {
49
49
  "tools.subagent.rules": ["When a <pi-famulus-wake kind=\"subagent-handover\"> arrives, read <prompt> and <result> immediately and continue: subagent({action:\"resume\", run_id, child_id, message}) for that child, or agent_message to steer children that are still running. Do not wait for the rest of the run.","Subagent runs that exceed the foreground budget continue in the background; you are notified per finished child and again when the run completes — do not poll.","A failed subagent does not fail the whole run; inspect per-subagent sections in the result.","<pi-famulus-wake> is a system wake, not a user reply. kind=subagent-handover is one child; kind=subagent-done is the whole run."],
50
50
  "tools.subagent.param.tasks": "Start new subagents in parallel. Use only when action is omitted. For any action call, omit tasks entirely; do not supply placeholder tasks.",
51
51
  "tools.subagent.param.tasks.agent": "Agent definition name (default: worker)",
52
- "tools.subagent.param.tasks.prompt": "Task prompt for this subagent",
52
+ "tools.subagent.param.tasks.prompt": "Task prompt for this subagent. The child is a new process: it cannot see this conversation, so the prompt must be self-contained.",
53
53
  "tools.subagent.param.tasks.name": "Display name (default: agent name + ordinal)",
54
54
  "tools.subagent.param.work_kind": "Explicit planned workload class for machine-wide admission (default: other)",
55
55
  "tools.subagent.param.chain": "Start a new sequential chain (always awaited). Use only when action is omitted. For any action call, omit chain entirely.",
@@ -32,6 +32,8 @@ export interface AgentChildRecord {
32
32
  error?: string;
33
33
  /** Why the child ended (terminal statuses only). */
34
34
  end_reason?: AgentEndReason;
35
+ /** Seconds until a rate-limited provider may be retried, when known. */
36
+ retry_after?: number;
35
37
  /** Task prompt without the agent preamble, capped. */
36
38
  prompt_head?: string;
37
39
  /** Tail of the final result text, capped. */
@@ -58,6 +60,7 @@ export type AgentEndReason =
58
60
  | "completed"
59
61
  | "failed"
60
62
  | "model-error"
63
+ | "rate-limit"
61
64
  | "stalled"
62
65
  | "timeout"
63
66
  | "interrupted"
@@ -66,10 +69,11 @@ export type AgentEndReason =
66
69
  /** Map a terminal child status + result to the end_reason recorded on disk. */
67
70
  export function agentEndReason(
68
71
  status: AgentChildRecord["status"],
69
- result: { error?: string; endReason?: "model-error" } | undefined,
72
+ result: { error?: string; endReason?: "model-error" | "rate-limit" } | undefined,
70
73
  ): AgentEndReason | undefined {
71
74
  if (status === "completed") return "completed";
72
75
  if (status === "failed") {
76
+ if (result?.endReason === "rate-limit") return "rate-limit";
73
77
  if (result?.endReason === "model-error") return "model-error";
74
78
  return result?.error === "stalled" ? "stalled" : "failed";
75
79
  }
@@ -174,6 +178,42 @@ function parseRecord(raw: string): AgentChildRecord | null {
174
178
  }
175
179
 
176
180
  /** Load agent child records from disk (all sessions under home). */
181
+ /** Hosted children the daemon still has warm. They outlive a gone parent session. */
182
+ export function warmHostedChildIds(home: string): Set<string> {
183
+ const out = new Set<string>();
184
+ try {
185
+ const { pid } = JSON.parse(readFileSync(join(home, "manager.pid"), "utf8")) as { pid?: unknown };
186
+ if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0) return out;
187
+ try {
188
+ process.kill(pid, 0);
189
+ } catch (error) {
190
+ if ((error as NodeJS.ErrnoException).code !== "EPERM") return out;
191
+ }
192
+ } catch {
193
+ return out;
194
+ }
195
+ let files: string[];
196
+ try {
197
+ files = readdirSync(join(home, "agents")).filter((name) => name.endsWith(".json"));
198
+ } catch {
199
+ return out;
200
+ }
201
+ for (const file of files) {
202
+ try {
203
+ const value = JSON.parse(readFileSync(join(home, "agents", file), "utf8")) as {
204
+ residency?: string;
205
+ child_id?: string;
206
+ };
207
+ if (value.residency === "warm" && typeof value.child_id === "string") {
208
+ out.add(value.child_id);
209
+ }
210
+ } catch {
211
+ // skip bad hosted records
212
+ }
213
+ }
214
+ return out;
215
+ }
216
+
177
217
  export function loadAgentChildRecords(
178
218
  home: string,
179
219
  opts: { sessionId?: string; includeTerminal?: boolean; connectedSessionIds?: ReadonlySet<string> } = {},
@@ -189,6 +229,7 @@ export function loadAgentChildRecords(
189
229
  } catch {
190
230
  return [];
191
231
  }
232
+ const warm = opts.connectedSessionIds === undefined ? new Set<string>() : warmHostedChildIds(home);
192
233
  const out: AgentChildRecord[] = [];
193
234
  for (const sid of sessionIds) {
194
235
  const dir = agentRecordsDir(home, sid);
@@ -205,7 +246,9 @@ export function loadAgentChildRecords(
205
246
  const disconnected =
206
247
  opts.connectedSessionIds !== undefined && !opts.connectedSessionIds.has(rec.session_id);
207
248
  const shown: AgentChildRecord =
208
- disconnected && (rec.status === "pending" || rec.status === "running")
249
+ disconnected &&
250
+ (rec.status === "pending" || rec.status === "running") &&
251
+ !warm.has(rec.child_id)
209
252
  ? { ...rec, status: "interrupted" }
210
253
  : rec;
211
254
  const terminal =
@@ -220,6 +263,12 @@ export function loadAgentChildRecords(
220
263
  return out;
221
264
  }
222
265
 
266
+ /** One child by id or name, across every session under home. */
267
+ export function findAgentChildRecord(home: string, childIdOrName: string): AgentChildRecord | undefined {
268
+ const recs = loadAgentChildRecords(home, { includeTerminal: true });
269
+ return recs.find((rec) => rec.child_id === childIdOrName) ?? recs.find((rec) => rec.name === childIdOrName);
270
+ }
271
+
223
272
  export function isAgentStatusActive(status: AgentChildRecord["status"]): boolean {
224
273
  return status === "pending" || status === "running";
225
274
  }
@@ -66,10 +66,10 @@ export function createHostedSessionFn(deps: HostedSessionDeps): CreateSessionFn
66
66
  "--thinking", String(thinking),
67
67
  "--append-system-prompt", promptFile,
68
68
  ...(trustInherited ? [] : [deps.getProjectTrusted?.() ? "-a" : "-na"]),
69
- ...(req.agent.tools ? ["--tools", req.agent.tools.join(",")] : []),
69
+ ...(req.agent.tools ? ["--tools", hostedChildTools(req.agent.tools).join(",")] : []),
70
70
  ];
71
71
  const env = { ...process.env } as Record<string, string>;
72
- if (req.agent.tools) env.PI_FAMULUS_CHILD_TOOLS = req.agent.tools.join(",");
72
+ if (req.agent.tools) env.PI_FAMULUS_CHILD_TOOLS = hostedChildTools(req.agent.tools).join(",");
73
73
  env.PI_FAMULUS_CHILD_RUN = req.runId;
74
74
  env.PI_FAMULUS_CHILD_NAME = req.name;
75
75
  const spawned = await client.agentSpawn({
@@ -80,14 +80,7 @@ export function createHostedSessionFn(deps: HostedSessionDeps): CreateSessionFn
80
80
  cwd,
81
81
  env,
82
82
  });
83
- let warning = model.warning;
84
- try {
85
- await client.agentInput(req.childId, "set_steering_mode", "all", "parent");
86
- } catch (err) {
87
- const message = err instanceof Error ? err.message : String(err);
88
- const note = `Steering mode was not set (${message}); queued input may wait until the current turn ends.`;
89
- warning = warning ? `${warning} ${note}` : note;
90
- }
83
+ const warning = model.warning;
91
84
  await client.agentWatch(req.childId);
92
85
  const session = new HostedRpcSession(client, req.childId, {
93
86
  warning,
@@ -108,6 +101,7 @@ class HostedRpcSession implements ChildSessionAdapter {
108
101
  private readonly unsubscribe: () => void;
109
102
  private streaming = false;
110
103
  private lastText = "";
104
+ private lastFailure: { stopReason: "error" | "aborted"; errorMessage?: string } | undefined;
111
105
  private turns: { role: string; text: string }[] = [];
112
106
  private usage: ChildTokenUsage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
113
107
  private settle: (() => void) | null = null;
@@ -172,6 +166,10 @@ class HostedRpcSession implements ChildSessionAdapter {
172
166
  return this.lastText || undefined;
173
167
  }
174
168
 
169
+ getLastAssistantFailure(): { stopReason: "error" | "aborted"; errorMessage?: string } | undefined {
170
+ return this.lastFailure;
171
+ }
172
+
175
173
  getConversation() {
176
174
  return this.turns.map((turn) => ({ ...turn }));
177
175
  }
@@ -216,6 +214,9 @@ class HostedRpcSession implements ChildSessionAdapter {
216
214
  };
217
215
  if (event.activity === "running") this.streaming = true;
218
216
  if (event.activity === "idle" || event.activity === "failed") {
217
+ if (event.activity === "failed" && !this.lastFailure) {
218
+ this.lastFailure = { stopReason: "error", errorMessage: "hosted child process failed" };
219
+ }
219
220
  this.streaming = false;
220
221
  this.settle?.();
221
222
  this.settle = null;
@@ -223,7 +224,20 @@ class HostedRpcSession implements ChildSessionAdapter {
223
224
  return;
224
225
  }
225
226
  if (event.event !== "agent_rpc" || !event.record || typeof event.record !== "object") return;
226
- const record = event.record as { type?: string; message?: { role?: string; content?: { type?: string; text?: string }[]; usage?: Record<string, unknown> }; toolCallId?: string };
227
+ const record = event.record as {
228
+ type?: string;
229
+ message?: {
230
+ role?: string;
231
+ content?: { type?: string; text?: string }[];
232
+ usage?: Record<string, unknown>;
233
+ stopReason?: string;
234
+ errorMessage?: string;
235
+ };
236
+ toolCallId?: string;
237
+ toolName?: string;
238
+ args?: unknown;
239
+ isError?: boolean;
240
+ };
227
241
  const type = record.type ?? "";
228
242
  if (type === "agent_start") this.streaming = true;
229
243
  if (type === "agent_settled") {
@@ -237,6 +251,18 @@ class HostedRpcSession implements ChildSessionAdapter {
237
251
  this.lastText = text;
238
252
  this.turns.push({ role: "assistant", text });
239
253
  }
254
+ const stopReason = record.message.stopReason;
255
+ if (stopReason === "error" || stopReason === "aborted") {
256
+ this.lastFailure = {
257
+ stopReason,
258
+ ...(record.message.errorMessage ? { errorMessage: record.message.errorMessage } : {}),
259
+ };
260
+ if (record.message.errorMessage) {
261
+ this.turns.push({ role: "assistant error", text: record.message.errorMessage });
262
+ }
263
+ } else {
264
+ this.lastFailure = undefined;
265
+ }
240
266
  const usage = record.message.usage;
241
267
  if (usage) {
242
268
  this.usage.input += num(usage.input);
@@ -249,10 +275,21 @@ class HostedRpcSession implements ChildSessionAdapter {
249
275
  const text = (record.message.content ?? []).map((part) => part.text ?? "").join("");
250
276
  if (text) this.turns.push({ role: "user", text });
251
277
  }
278
+ if (type === "tool_execution_end") {
279
+ const name = record.toolName ?? "tool";
280
+ const text = record.args === undefined ? name : `${name} ${JSON.stringify(record.args)}`;
281
+ this.turns.push({
282
+ role: record.isError ? `tool ${name} error` : `tool ${name}`,
283
+ text,
284
+ });
285
+ }
252
286
  const adapted: ChildSessionEvent = {
253
287
  type,
254
288
  ...(record.message?.role ? { role: record.message.role } : {}),
255
289
  ...(record.toolCallId ? { toolCallId: record.toolCallId } : {}),
290
+ ...(record.toolName ? { toolName: record.toolName } : {}),
291
+ ...(record.args !== undefined ? { args: record.args } : {}),
292
+ ...(record.isError !== undefined ? { isError: record.isError } : {}),
256
293
  };
257
294
  for (const listener of this.listeners) listener(adapted);
258
295
  }
@@ -262,6 +299,15 @@ function num(value: unknown): number {
262
299
  return typeof value === "number" && Number.isFinite(value) ? value : 0;
263
300
  }
264
301
 
302
+ const SUPERVISOR_TOOLS = ["contact_supervisor", "agent_message"];
303
+
304
+ /** Agent allowlists still keep the two supervisor tools. */
305
+ export function hostedChildTools(tools: string[]): string[] {
306
+ const kept = new Set(tools);
307
+ for (const name of SUPERVISOR_TOOLS) kept.add(name);
308
+ return [...kept];
309
+ }
310
+
265
311
  /** Request id for a child→parent decision. */
266
312
  export function newCommsRequestId(): string {
267
313
  return `rq_${randomUUID().slice(0, 8)}`;
@@ -342,6 +342,84 @@ export class SubagentRegistry implements RunRegistry {
342
342
  // M3 extensions beyond Appendix B
343
343
  // -------------------------------------------------------------------------
344
344
 
345
+ /**
346
+ * Reconstruct a run/child the previous parent process wrote to disk
347
+ * so resume can reattach by child_id.
348
+ */
349
+ adoptExisting(info: {
350
+ runId: string;
351
+ childId: string;
352
+ name: string;
353
+ agent: string;
354
+ workKind?: string;
355
+ startedAt?: number;
356
+ prompt?: string;
357
+ }): void {
358
+ if (this.children.has(info.childId)) return;
359
+ let run = this.runs.get(info.runId);
360
+ if (!run) {
361
+ run = {
362
+ runId: info.runId,
363
+ kind: "tasks",
364
+ status: "interrupted",
365
+ createdAt: info.startedAt ?? this.now(),
366
+ children: [],
367
+ };
368
+ this.runs.set(info.runId, run);
369
+ }
370
+ const child: InternalChild = {
371
+ childId: info.childId,
372
+ runId: info.runId,
373
+ name: info.name,
374
+ agent: info.agent,
375
+ workKind: info.workKind ?? "other",
376
+ status: "interrupted",
377
+ startedAt: info.startedAt ?? this.now(),
378
+ endedAt: this.now(),
379
+ turn: 1,
380
+ queueMs: 0,
381
+ prompt: info.prompt,
382
+ };
383
+ run.children.push(child);
384
+ this.children.set(child.childId, child);
385
+ this.emit(run);
386
+ }
387
+
388
+ /** Bind a live hosted session to an adopted child without sending its first prompt. */
389
+ async attachChild(req: ChildRunRequest): Promise<ChildHandle> {
390
+ const child = this.children.get(req.childId);
391
+ if (!child) throw new Error(`unknown child ${req.childId} (adoptExisting first)`);
392
+ const runner = this.runner;
393
+ if (!runner?.attach) {
394
+ throw new Error(`subagent ${req.childId} has no live session to resume`);
395
+ }
396
+ child.prompt = req.taskPrompt ?? req.prompt;
397
+ child.preamble = req.agent.systemPrompt || undefined;
398
+ child.model = req.model ?? req.agent.model;
399
+ let handle: ChildHandle;
400
+ try {
401
+ handle = await runner.attach(req, (startingHandle) => {
402
+ child.handle = startingHandle;
403
+ });
404
+ } catch (error) {
405
+ const failed = child.handle as DisposableChildHandle | undefined;
406
+ child.handle = undefined;
407
+ await failed?.dispose?.();
408
+ throw error;
409
+ }
410
+ if (!this.children.has(child.childId)) return handle;
411
+ child.handle = handle;
412
+ handle.result.then((result) => this.settleChild(child.childId, result));
413
+ const resolved = handle.resolvedModel();
414
+ if (resolved) {
415
+ const thinking = handle.effectiveThinkingLevel?.();
416
+ child.model = `${resolved}${thinking ? `:${thinking}` : ""}`;
417
+ }
418
+ const run = this.runs.get(child.runId);
419
+ if (run) this.emit(run);
420
+ return handle;
421
+ }
422
+
345
423
  /** Allocate a pending child inside a run. Returns the child id. */
346
424
  addChild(runId: string, info: { name: string; agent: string; workKind?: string }): string {
347
425
  const run = this.runs.get(runId);
@@ -87,6 +87,8 @@ export interface InProcessRunnerOptions {
87
87
  stallRetryDelayMs?: number;
88
88
  /** Called on every stall detection (before any retry), with the 1-based attempt. */
89
89
  onStall?: (childId: string, attempt: number) => void;
90
+ /** Same tool name and arguments three times in a row. */
91
+ onToolLoop?: (childId: string, toolName: string, args: unknown) => void;
90
92
  /** Overrun reminder interval (ms). Default 10 minutes. */
91
93
  overrunRepeatMs?: number;
92
94
  /** Abort ceiling per user turn (ms). 0/absent = off: the soft deadline never aborts. */
@@ -116,6 +118,20 @@ function errorMessage(err: unknown): string {
116
118
  return err instanceof Error ? err.message : String(err);
117
119
  }
118
120
 
121
+ /** 429 / rate-limit text is recorded separately from other model errors. */
122
+ export function classifyModelFailure(message: string): {
123
+ endReason: "model-error" | "rate-limit";
124
+ retryAfter?: number;
125
+ } {
126
+ const rateLimited = /\b429\b|rate[\s-]?limit|too many requests/i.test(message);
127
+ if (!rateLimited) return { endReason: "model-error" };
128
+ const retry = /retry[\s-]?after[:\s]+(\d+)/i.exec(message);
129
+ return {
130
+ endReason: "rate-limit",
131
+ ...(retry ? { retryAfter: Number(retry[1]) } : {}),
132
+ };
133
+ }
134
+
119
135
  export class InProcessRunner implements ChildRunner {
120
136
  private readonly opts: InProcessRunnerOptions;
121
137
 
@@ -129,6 +145,13 @@ export class InProcessRunner implements ChildRunner {
129
145
  await handle.launch();
130
146
  return handle;
131
147
  }
148
+
149
+ async attach(req: ChildRunRequest, onHandle?: (handle: ChildHandle) => void): Promise<DisposableChildHandle> {
150
+ const handle = new InProcessChildHandle(req, this.opts);
151
+ onHandle?.(handle);
152
+ await handle.attachIdle();
153
+ return handle;
154
+ }
132
155
  }
133
156
 
134
157
  class InProcessChildHandle implements DisposableChildHandle {
@@ -138,6 +161,9 @@ class InProcessChildHandle implements DisposableChildHandle {
138
161
  private readonly stallRetries: number;
139
162
  private readonly stallRetryDelayMs: number;
140
163
  private readonly onStall?: (childId: string, attempt: number) => void;
164
+ private readonly onToolLoop?: (childId: string, toolName: string, args: unknown) => void;
165
+ private lastToolKey: string | null = null;
166
+ private lastToolCount = 0;
141
167
  private readonly overrunRepeatMs: number;
142
168
  private readonly hardTimeoutMs: number;
143
169
  private readonly onOverrun?: (tick: OverrunTick) => void;
@@ -233,6 +259,7 @@ class InProcessChildHandle implements DisposableChildHandle {
233
259
  ? Math.max(0, Math.floor(rawDelay))
234
260
  : DEFAULT_STALL_RETRY_DELAY_MS;
235
261
  this.onStall = opts.onStall;
262
+ this.onToolLoop = opts.onToolLoop;
236
263
  const rawRepeat = opts.overrunRepeatMs ?? DEFAULT_OVERRUN_REPEAT_MS;
237
264
  this.overrunRepeatMs =
238
265
  Number.isFinite(rawRepeat) && rawRepeat > 0
@@ -331,6 +358,27 @@ class InProcessChildHandle implements DisposableChildHandle {
331
358
  await this.beginGeneration(this.req.prompt, true);
332
359
  }
333
360
 
361
+ /**
362
+ * Bind an already-hosted session so resume() can send the next turn.
363
+ * The previous parent died; this generation is interrupted, not prompted.
364
+ */
365
+ async attachIdle(): Promise<void> {
366
+ this.sessionCreation = this.createSession(this.req);
367
+ const created =
368
+ await this.sessionCreation;
369
+ this.sessionCreation = null;
370
+ if (this.disposed) return;
371
+ this.session = created;
372
+ this.resolvedModel_ = created.resolvedModel;
373
+ this.unsubscribe = this.session.subscribe((event) => this.handleSessionEvent(event));
374
+ this.settle(this.generation, {
375
+ status: "interrupted",
376
+ text: this.session.getLastAssistantText() ?? "",
377
+ error: "parent session ended",
378
+ durationMs: 0,
379
+ });
380
+ }
381
+
334
382
  async steer(message: string): Promise<void> {
335
383
  if (this.status_ !== "running" || !this.session) {
336
384
  throw new Error(`subagent ${this.req.childId} is not running (status: ${this.status_})`);
@@ -480,11 +528,10 @@ class InProcessChildHandle implements DisposableChildHandle {
480
528
  text: this.status_ === "pending" ? "" : this.partialText(),
481
529
  durationMs: this.now() - this.startedAt,
482
530
  });
483
- try {
484
- await this.session?.abort();
485
- } catch {
486
- // abort is best-effort; the result is already settled
487
- }
531
+ // Resume may be accepted as soon as the interrupted result is visible.
532
+ // Drain this abort just like a timeout/stall abort before prompting again.
533
+ this.abortPromise = this.session ? this.session.abort().catch(() => {}) : null;
534
+ await this.abortPromise;
488
535
  }
489
536
 
490
537
  dispose(): void | Promise<void> {
@@ -690,11 +737,14 @@ class InProcessChildHandle implements DisposableChildHandle {
690
737
  if (!this.isCurrent(gen)) return;
691
738
  const failure = session?.getLastAssistantFailure?.();
692
739
  if (failure) {
740
+ const error = failure.errorMessage?.trim() || `Model stopped with ${failure.stopReason}`;
741
+ const classified = classifyModelFailure(error);
693
742
  this.settle(gen, {
694
743
  status: "failed",
695
744
  text: this.partialText(),
696
- error: failure.errorMessage?.trim() || `Model stopped with ${failure.stopReason}`,
697
- endReason: "model-error",
745
+ error,
746
+ endReason: classified.endReason,
747
+ ...(classified.retryAfter !== undefined ? { retryAfter: classified.retryAfter } : {}),
698
748
  durationMs: this.now() - this.startedAt,
699
749
  });
700
750
  return;
@@ -871,7 +921,7 @@ class InProcessChildHandle implements DisposableChildHandle {
871
921
  this.toolStartedAt = null;
872
922
  }
873
923
 
874
- private handleSessionEvent(event: { type: string; role?: string; toolCallId?: string; usage?: { input: number; output: number } }): void {
924
+ private handleSessionEvent(event: { type: string; role?: string; toolCallId?: string; toolName?: string; args?: unknown; usage?: { input: number; output: number } }): void {
875
925
  const now = this.now();
876
926
  const active = this.status_ === "running";
877
927
  let currentToolEvent = false;
@@ -915,6 +965,7 @@ class InProcessChildHandle implements DisposableChildHandle {
915
965
  if (currentToolEvent) {
916
966
  this.toolDepth++;
917
967
  this.clearStall();
968
+ this.noteToolCall(event.toolName, event.args);
918
969
  }
919
970
  return;
920
971
  }
@@ -931,6 +982,22 @@ class InProcessChildHandle implements DisposableChildHandle {
931
982
  if (this.toolDepth === 0 && !this.decisionPaused) this.armStall(this.generation);
932
983
  }
933
984
 
985
+ private noteToolCall(toolName: string | undefined, args: unknown): void {
986
+ const name = toolName ?? "";
987
+ const key = `${name}\0${JSON.stringify(args ?? null)}`;
988
+ if (this.lastToolKey === key) this.lastToolCount += 1;
989
+ else {
990
+ this.lastToolKey = key;
991
+ this.lastToolCount = 1;
992
+ }
993
+ if (this.lastToolCount !== 3 || !name) return;
994
+ try {
995
+ this.onToolLoop?.(this.req.childId, name, args);
996
+ } catch {
997
+ // loop observers must not break the child
998
+ }
999
+ }
1000
+
934
1001
  private release(terminal = true): void {
935
1002
  const release = this.releaseSlot;
936
1003
  if (terminal) this.releaseSlot = null;
@@ -142,6 +142,8 @@ export interface SubagentToolDeps {
142
142
  /** Selectable models for action:"models" (§4.6); absent → action errors. */
143
143
  /** Receives the watcher for turns started by agent_message send. */
144
144
  registerResumeWatch?: (watch: (childId: string) => void) => void;
145
+ /** Rebuild a child this session no longer holds so resume can use child_id. */
146
+ recoverChild?: (childId: string) => Promise<RunRecord | undefined>;
145
147
  listModels?: () => {
146
148
  provider: string;
147
149
  id: string;
@@ -228,6 +230,8 @@ function toNotificationInfo(record: RunRecord, now: number) {
228
230
  status: c.status,
229
231
  text: c.result?.text ?? "",
230
232
  error: c.result?.error,
233
+ ...(c.result?.endReason !== undefined ? { endReason: c.result.endReason } : {}),
234
+ ...(c.result?.retryAfter !== undefined ? { retryAfter: c.result.retryAfter } : {}),
231
235
  ...(c.result?.warning !== undefined ? { warning: c.result.warning } : {}),
232
236
  ...(c.prompt !== undefined ? { prompt: c.prompt } : {}),
233
237
  })),
@@ -563,11 +567,16 @@ export function createSubagentTool(
563
567
  };
564
568
  }
565
569
 
566
- const record = params.run_id
570
+ let record = params.run_id
567
571
  ? requireRun(registry, params.run_id)
568
572
  : params.child_id
569
573
  ? resolveRunByChild(registry, params.child_id)
570
574
  : undefined;
575
+ if (params.child_id && !params.run_id && action === "resume" && deps.recoverChild &&
576
+ (!record || !registry.findChild(record.runId, params.child_id))) {
577
+ record =
578
+ await deps.recoverChild(params.child_id);
579
+ }
571
580
  if (!record) {
572
581
  if (params.child_id && !params.run_id) {
573
582
  throw new Error(`unknown child "${params.child_id}" (no run in this session holds it)`);
@@ -46,7 +46,9 @@ export interface ChildResult {
46
46
  /** Non-fatal caveat, e.g. agent-definition model fell back to parent model. */
47
47
  warning?: string;
48
48
  /** Set when the model itself stopped with an error (provider failure). */
49
- endReason?: "model-error";
49
+ endReason?: "model-error" | "rate-limit";
50
+ /** Seconds until a rate-limited provider may be retried, when known. */
51
+ retryAfter?: number;
50
52
  /**
51
53
  * Total generations run (1 + resumes + stall retries). Omitted when 1.
52
54
  * Note: a user resume() also increments this; use `stalls` to count
@@ -102,6 +104,9 @@ export interface ChildSessionEvent {
102
104
  role?: string;
103
105
  /** Pi's stable tool-call id, used to pair execution start/end events. */
104
106
  toolCallId?: string;
107
+ toolName?: string;
108
+ args?: unknown;
109
+ isError?: boolean;
105
110
  usage?: ChildTokenUsage;
106
111
  }
107
112
 
@@ -178,6 +183,8 @@ export type CreateSessionFn = (req: ChildRunRequest) => Promise<ChildSessionAdap
178
183
 
179
184
  export interface ChildRunner {
180
185
  start(req: ChildRunRequest, onHandle?: (handle: ChildHandle) => void): Promise<ChildHandle>;
186
+ /** Reattach a child the daemon still hosts, without sending its first prompt. */
187
+ attach?(req: ChildRunRequest, onHandle?: (handle: ChildHandle) => void): Promise<ChildHandle>;
181
188
  }
182
189
 
183
190
  /**
package/src/work-index.ts CHANGED
@@ -11,6 +11,12 @@ export type WorkKind = "shell" | "monitor" | "agent";
11
11
  export const WORK_RETAIN_MS = 10 * 60 * 1000;
12
12
  export const WORK_FINISHED_CAP = 50;
13
13
 
14
+ /** stderr line when print mode drops live work and still exits 0. */
15
+ export function printModeDropWarning(ids: string[]): string | null {
16
+ if (ids.length === 0) return null;
17
+ return `pi-famulus: print mode is ending; stopping ${ids.length} background item(s) (${ids.join(", ")}). The process still exits 0.\n`;
18
+ }
19
+
14
20
  export interface WorkItem {
15
21
  id: string;
16
22
  kind: WorkKind;