pi-famulus 0.1.1 → 0.1.3

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.
@@ -15,6 +15,7 @@
15
15
  */
16
16
  import { Type } from "typebox";
17
17
  import type { AgentToolResult, ToolDefinition } from "@earendil-works/pi-coding-agent";
18
+ import { VALID_THINKING_LEVELS } from "../thinking-levels";
18
19
  import { formatSubagentHandover, formatSubagentNotification, truncateTail } from "../format";
19
20
  import { realClock, type Clock, type ClockTimer } from "../clock";
20
21
 
@@ -73,13 +74,18 @@ const subagentParameters = Type.Object({
73
74
  Type.String({
74
75
  description:
75
76
  'Model override for all subagents: fuzzy ("haiku"), qualified ("provider/id"), ' +
76
- 'optionally with ":<thinking>" suffix. Default: current model. ' +
77
+ `optionally with ":<thinking>" suffix (${VALID_THINKING_LEVELS.join(", ")}; ` +
78
+ 'an unknown suffix is dropped with a warning when the base resolves; ' +
79
+ 'an unknown base still errors). Default: current model. ' +
77
80
  'Use action:"models" to list selectable values.',
78
81
  }),
79
82
  ),
80
83
  timeout_ms: Type.Optional(
81
84
  Type.Number({
82
- description: `Hard timeout per subagent in ms (default 1800000, max ${MAX_TIMEOUT_MS})`,
85
+ description:
86
+ `Time budget per subagent turn in ms (default 1800000, max ${MAX_TIMEOUT_MS}). ` +
87
+ 'Passing it does not stop the subagent: you get <pi-famulus-wake kind="subagent-overrun"> ' +
88
+ "and choose extend, steer, or interrupt. With resume: the resumed turn's budget. With extend: the new budget from now.",
83
89
  maximum: MAX_TIMEOUT_MS,
84
90
  }),
85
91
  ),
@@ -92,13 +98,16 @@ const subagentParameters = Type.Object({
92
98
  Type.Literal("interrupt"),
93
99
  Type.Literal("resume"),
94
100
  Type.Literal("steer"),
101
+ Type.Literal("extend"),
95
102
  Type.Literal("models"),
96
103
  ],
97
104
  { description: "Manage an existing run (or list selectable models) instead of starting a new one" },
98
105
  ),
99
106
  ),
100
107
  run_id: Type.Optional(Type.String({ description: "Target run for action" })),
101
- child_id: Type.Optional(Type.String({ description: "Target child (id or name) for steer/interrupt/resume" })),
108
+ child_id: Type.Optional(
109
+ Type.String({ description: "Target child (id or name) for steer/interrupt/resume/extend" }),
110
+ ),
102
111
  message: Type.Optional(Type.String({ description: "Message content for steer/resume" })),
103
112
  });
104
113
 
@@ -110,7 +119,7 @@ type SubagentParams = {
110
119
  fail_fast?: boolean;
111
120
  model?: string;
112
121
  timeout_ms?: number;
113
- action?: "list" | "get" | "status" | "interrupt" | "resume" | "steer" | "models";
122
+ action?: "list" | "get" | "status" | "interrupt" | "resume" | "steer" | "extend" | "models";
114
123
  run_id?: string;
115
124
  child_id?: string;
116
125
  message?: string;
@@ -222,6 +231,7 @@ function toNotificationInfo(record: RunRecord, now: number) {
222
231
  status: c.status,
223
232
  text: c.result?.text ?? "",
224
233
  error: c.result?.error,
234
+ ...(c.result?.warning !== undefined ? { warning: c.result.warning } : {}),
225
235
  ...(c.prompt !== undefined ? { prompt: c.prompt } : {}),
226
236
  })),
227
237
  };
@@ -285,9 +295,23 @@ export function createSubagentTool(
285
295
  return registry;
286
296
  };
287
297
 
298
+ /** Last terminal state a subagent-done was sent for, per run. */
299
+ const runDoneSent = new Map<string, string>();
288
300
  const notifyRunCompleted = (registry: SubagentRegistry, runId: string): void => {
289
301
  const record = registry.get(runId);
290
- if (!record) return;
302
+ if (!record) {
303
+ runDoneSent.delete(runId);
304
+ return;
305
+ }
306
+ // A resumed child can outlive the run's first-launch completion. The
307
+ // run is not done while it runs; its own settle sends this wake.
308
+ if (record.children.some((c) => c.status === "pending" || c.status === "running")) return;
309
+ // Several observers (first-launch completion, each resumed turn's settle)
310
+ // can see the same all-terminal state. One subagent-done per terminal
311
+ // state, identified by every child's turn number.
312
+ const terminalKey = record.children.map((c) => `${c.childId}:${c.turn ?? 1}`).join("|");
313
+ if (runDoneSent.get(runId) === terminalKey) return;
314
+ runDoneSent.set(runId, terminalKey);
291
315
  deps.getNotifyCenter()?.notify(formatSubagentNotification(toNotificationInfo(record, clock.now())));
292
316
  };
293
317
 
@@ -320,6 +344,7 @@ export function createSubagentTool(
320
344
  prompt: child.prompt ?? "",
321
345
  text: child.result?.text ?? "",
322
346
  ...(child.result?.error !== undefined ? { error: child.result.error } : {}),
347
+ ...(child.result?.warning !== undefined ? { warning: child.result.warning } : {}),
323
348
  stillRunning,
324
349
  }),
325
350
  );
@@ -590,12 +615,41 @@ export function createSubagentTool(
590
615
  };
591
616
  }
592
617
 
618
+ if (action === "extend") {
619
+ const handle = resolveSingleActiveChild(registry, record, params.child_id, "extend");
620
+ const child = record.children.find((c) => c.childId === handle.childId);
621
+ const ms = params.timeout_ms === undefined ? undefined : clampTimeout(params.timeout_ms, deps.defaultTimeoutMs);
622
+ const { deadlineAt, hardDeadlineAt } = handle.extend(ms);
623
+ const now = clock.now();
624
+ const hard =
625
+ hardDeadlineAt === null
626
+ ? ""
627
+ : ` The configured hard ceiling (hardTimeoutMs) is not moved: it stops this subagent in ${formatDurationMs(Math.max(0, hardDeadlineAt - now))}.`;
628
+ return {
629
+ content: [
630
+ {
631
+ type: "text",
632
+ text:
633
+ `Extended subagent ${child?.name ?? handle.childId} (${handle.childId}) in run ${record.runId}: ` +
634
+ `the next <pi-famulus-wake kind="subagent-overrun"> comes in ${formatDurationMs(deadlineAt - now)} if it is still running. ` +
635
+ `Its result arrives as a wake when it finishes; do not poll.${hard}`,
636
+ },
637
+ ],
638
+ details: { run_id: record.runId, child_id: handle.childId, deadline_at: deadlineAt, hard_deadline_at: hardDeadlineAt },
639
+ };
640
+ }
641
+
593
642
  // resume
594
643
  if (!params.message) throw new Error("message is required for resume");
595
644
  const child = resolveSingleTerminalChild(record, params.child_id);
596
- const handle = registry.handle(child.childId);
597
- if (!handle) throw new Error(`subagent ${child.childId} has no live session to resume`);
598
- await handle.resume(params.message);
645
+ // A resume is a new turn with its own soft budget: timeout_ms when given,
646
+ // else the child's spawn budget (the runner's default). Returns once the
647
+ // request is accepted; it never waits for an admission slot.
648
+ const { queuedBehind } = await registry.resumeChild(
649
+ child.childId,
650
+ params.message,
651
+ params.timeout_ms === undefined ? {} : { timeoutMs: clampTimeout(params.timeout_ms, deps.defaultTimeoutMs) },
652
+ );
599
653
  // Resume is asynchronous. If siblings are still running, hand this child
600
654
  // back as soon as it finishes; otherwise the run-complete wake covers it.
601
655
  const handedOver = new Set<string>();
@@ -610,11 +664,12 @@ export function createSubagentTool(
610
664
  type: "text",
611
665
  text:
612
666
  `Resumed subagent ${child.name} (${child.childId}) in run ${record.runId}. ` +
667
+ queuedText(queuedBehind) +
613
668
  "You will be notified via <pi-famulus-wake kind=\"subagent-handover\"> if others are still running, " +
614
669
  "otherwise via <pi-famulus-wake kind=\"subagent-done\"> when it completes. Do not poll.",
615
670
  },
616
671
  ],
617
- details: { run_id: record.runId, child_id: child.childId },
672
+ details: { run_id: record.runId, child_id: child.childId, queued_behind: queuedBehind },
618
673
  };
619
674
  };
620
675
 
@@ -626,7 +681,10 @@ export function createSubagentTool(
626
681
  "interpolation). By default the call waits up to a foreground budget (default 45s); longer runs " +
627
682
  "continue in the background. Each child that finishes while others are still running wakes you with " +
628
683
  "<pi-famulus-wake kind=\"subagent-handover\"> (its prompt and result). The whole run wakes you with <pi-famulus-wake kind=\"subagent-done\">. " +
629
- "Never poll or sleep to wait. Use action=list/get/status/interrupt/resume/steer to manage existing runs.",
684
+ "Never poll or sleep to wait. " +
685
+ "A subagent still running past its timeout_ms is not stopped; it wakes you with " +
686
+ "<pi-famulus-wake kind=\"subagent-overrun\"> so you can extend, steer, or interrupt it. " +
687
+ "Use action=list/get/status/interrupt/resume/steer/extend to manage existing runs.",
630
688
  promptSnippet: "Fan out subagents in parallel or sequence them in a chain",
631
689
  promptGuidelines: [
632
690
  '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.',
@@ -694,6 +752,13 @@ export function createSubagentTool(
694
752
  };
695
753
  }
696
754
 
755
+ /** Resume result clause for a turn waiting for an admission slot ("" when it started at once). */
756
+ function queuedText(queuedBehind: number | null): string {
757
+ if (queuedBehind === null) return "";
758
+ const ahead = queuedBehind === 0 ? "" : ` and ${queuedBehind} subagent(s) are waiting ahead of it`;
759
+ return `Every subagent slot is busy${ahead}, so it is queued and starts when a slot frees. `;
760
+ }
761
+
697
762
  function resolveSingleActiveChild(
698
763
  registry: SubagentRegistry,
699
764
  record: RunRecord,
@@ -728,6 +793,14 @@ function resolveSingleTerminalChild(record: RunRecord, childIdOrName: string | u
728
793
  const known = record.children.map((c) => `${c.name} (${c.childId})`).join(", ");
729
794
  throw new Error(`no child "${childIdOrName}" in run ${record.runId} (children: ${known})`);
730
795
  }
796
+ if (child.status === "pending") {
797
+ // Queued for an admission slot (a launch or an earlier resume): steer
798
+ // would fail too, and a second resume adds nothing.
799
+ throw new Error(
800
+ `subagent ${child.name} (${child.childId}) is already queued and starts when a subagent slot frees; ` +
801
+ "its result arrives as a wake when it finishes. Do not resume it again.",
802
+ );
803
+ }
731
804
  if (!matches(child)) {
732
805
  throw new Error(`subagent ${child.name} (${child.childId}) is still ${child.status}; use steer instead`);
733
806
  }
@@ -16,7 +16,9 @@ export interface AgentDefinition {
16
16
  description: string; // required, non-empty
17
17
  tools: string[]; // default ["read","bash","edit","write"]
18
18
  model?: string; // "provider:id" | bare id
19
- thinking?: "minimal" | "low" | "medium" | "high" | "xhigh";
19
+ // Mirror of src/agents/definition.ts — keep in sync with
20
+ // src/thinking-levels.ts (VALID_THINKING_LEVELS), the single source.
21
+ thinking?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
20
22
  systemPrompt: string; // frontmatter body, trimmed
21
23
  source: "builtin" | "user" | "project";
22
24
  path?: string; // builtins have no path
@@ -58,6 +60,7 @@ export interface ChildRunRequest {
58
60
  taskPrompt?: string;
59
61
  agent: AgentDefinition; // already resolved
60
62
  model?: string; // subagent() parameter-level override
63
+ /** Soft budget per turn: reaching it wakes the parent; it does not abort. */
61
64
  timeoutMs: number;
62
65
  depth: number; // main session = 0, child = 1
63
66
  }
@@ -70,7 +73,13 @@ export interface ChildHandle {
70
73
  // current generation's promise).
71
74
  steer(message: string): Promise<void>; // while running; terminal -> throw
72
75
  followUp(message: string): Promise<void>; // same, queued delivery
73
- resume(message: string): Promise<void>; // terminal -> continue running
76
+ /** terminal -> continue running; opts.timeoutMs is the new turn's soft budget (default: spawn budget). */
77
+ resume(message: string, opts?: { timeoutMs?: number }): Promise<void>;
78
+ /**
79
+ * running -> soft deadline = now + timeoutMs (default: spawn budget);
80
+ * otherwise throws. Returns the new deadline and the unmoved hard ceiling.
81
+ */
82
+ extend(timeoutMs?: number): { deadlineAt: number; hardDeadlineAt: number | null };
74
83
  interrupt(): Promise<void>; // abort; result resolves as interrupted
75
84
  status(): ChildStatus;
76
85
  lastEventAt(): number; // for the stall watchdog / status display
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Thinking levels pi core accepts — the single source of truth for the
3
+ * extension. The pi SDK does not export its list (pi's internal
4
+ * VALID_THINKING_LEVELS), so this is a maintained copy; parity tests pin
5
+ * every level (impl-model-spec.test.ts, impl-agents.test.ts).
6
+ *
7
+ * Pure module: zero pi dependencies, zero npm dependencies. Both src/agents/
8
+ * and src/subagent/ consume this — neither layer imports the other (see
9
+ * design doc Appendix B).
10
+ */
11
+
12
+ export const VALID_THINKING_LEVELS = [
13
+ "off",
14
+ "minimal",
15
+ "low",
16
+ "medium",
17
+ "high",
18
+ "xhigh",
19
+ "max",
20
+ ] as const;
21
+
22
+ export type ThinkingLevel = (typeof VALID_THINKING_LEVELS)[number];
23
+
24
+ export function isValidThinkingLevel(level: string): level is ThinkingLevel {
25
+ return (VALID_THINKING_LEVELS as readonly string[]).includes(level);
26
+ }
@@ -98,6 +98,12 @@ function collapsedText(details: FamulusWake, theme: Theme): string {
98
98
  const { color, glyph } = statusGlyph(bad ? "failed" : details.status);
99
99
  return `${theme.fg(color, glyph)} ${theme.fg("muted", "subagent")} ${countStatuses(details.children.map((child) => child.status))}`;
100
100
  }
101
+ case "subagent-overrun": {
102
+ const shell = details.shell
103
+ ? ` · shell ${formatDuration(details.shell.elapsedMs)}${details.shell.growing === null ? "" : details.shell.growing ? " (output growing)" : " (output idle)"}`
104
+ : "";
105
+ return `${theme.fg("warning", "!")} ${theme.fg("muted", "overrun")} ${details.name} ${formatDuration(details.elapsedMs)} / ${formatDuration(details.budgetMs)} budget · still running${shell}`;
106
+ }
101
107
  case "supervisor-request":
102
108
  return [
103
109
  `${theme.fg("warning", "?")} ${theme.fg("muted", `decision for ${details.name}`)} ${details.message.slice(0, 100)}`,
@@ -133,9 +139,37 @@ export function expandedWakeText(details: FamulusWake | undefined, content: stri
133
139
  case "monitor":
134
140
  return [`Monitor: ${details.description}`, `Task: ${details.id}`, ...(details.status ? [`Status: ${details.status}`] : []), `Event: ${details.event}`, ...(details.eventCount ? [`Events: ${details.eventCount}`] : []), ...(details.droppedLines ? [`Dropped lines: ${details.droppedLines}`] : [])].join("\n");
135
141
  case "subagent-handover":
136
- return [`Subagent handover: ${details.name} (${details.status})`, `Run: ${details.runId}`, `Child: ${details.childId}`, `Task prompt: ${details.prompt}`, `Result: ${details.result}`, ...(details.error ? [`Error: ${details.error}`] : []), ...(details.stillRunning.length ? [`Still running: ${details.stillRunning.map((item) => `${item.id} ${item.title}`).join(", ")}`] : [])].join("\n\n");
142
+ return [`Subagent handover: ${details.name} (${details.status})`, `Run: ${details.runId}`, `Child: ${details.childId}`, `Task prompt: ${details.prompt}`, `Result: ${details.result}`, ...(details.error ? [`Error: ${details.error}`] : []), ...(details.warning ? [`Warning: ${details.warning}`] : []), ...(details.stillRunning.length ? [`Still running: ${details.stillRunning.map((item) => `${item.id} ${item.title}`).join(", ")}`] : [])].join("\n\n");
137
143
  case "subagent-done":
138
- return [`Subagent run: ${details.runId} (${details.status})`, `Duration: ${formatDuration(details.durationMs)}`, ...details.children.map((child) => [`${child.name} (${child.childId}) · ${child.status}`, `Task prompt: ${child.prompt}`, `Result: ${child.result}`, ...(child.error ? [`Error: ${child.error}`] : [])].join("\n"))].join("\n\n");
144
+ return [`Subagent run: ${details.runId} (${details.status})`, `Duration: ${formatDuration(details.durationMs)}`, ...details.children.map((child) => [`${child.name} (${child.childId}) · ${child.status}`, `Task prompt: ${child.prompt}`, `Result: ${child.result}`, ...(child.error ? [`Error: ${child.error}`] : []), ...(child.warning ? [`Warning: ${child.warning}`] : [])].join("\n"))].join("\n\n");
145
+ case "subagent-overrun": {
146
+ const shell = details.shell;
147
+ return [
148
+ `Subagent past its budget: ${details.name} (still running, reminder ${details.reminder})`,
149
+ `Run: ${details.runId}`,
150
+ `Child: ${details.childId}`,
151
+ `Elapsed this turn: ${formatDuration(details.elapsedMs)} of ${formatDuration(details.budgetMs)}`,
152
+ `Last activity (${formatDuration(details.lastActivity.agoMs)} ago): ${details.lastActivity.text}`,
153
+ ...(shell
154
+ ? [
155
+ [
156
+ `Shell ${shell.taskId} · ${formatDuration(shell.elapsedMs)}`,
157
+ `$ ${shell.command}`,
158
+ `Output: ${shell.outputPath}${shell.outputBytes !== null ? ` (${shell.outputBytes} bytes)` : ""}`,
159
+ ...(shell.outputIdleMs !== null ? [`Last output: ${formatDuration(shell.outputIdleMs)} ago`] : []),
160
+ ].join("\n"),
161
+ ]
162
+ : []),
163
+ ...(details.hardCeilingMs !== undefined
164
+ ? [`Hard ceiling stops it in ${formatDuration(details.hardCeilingMs)} (extend does not move it).`]
165
+ : []),
166
+ // Same condition as overrunOptions in wake.ts: do not promise a
167
+ // reminder that the hard ceiling will stop the child before.
168
+ ...(details.hardCeilingMs === undefined || details.nextReminderMs < details.hardCeilingMs
169
+ ? [`Next reminder in ${formatDuration(details.nextReminderMs)} unless you extend, steer, or interrupt it.`]
170
+ : []),
171
+ ].join("\n\n");
172
+ }
139
173
  case "supervisor-request":
140
174
  return [`Decision requested by ${details.name} (${details.from})`, `Request: ${details.message}`, `Reply with /reply ${details.from} <decision>`].join("\n");
141
175
  case "supervisor-update":
@@ -10,13 +10,12 @@ import type { ManagerClient, TaskRecord } from "../manager-client";
10
10
  import { taskOutputPath } from "../config";
11
11
  import { formatConversation } from "../subagent/conversation";
12
12
  import type { SubagentRegistry } from "../subagent/registry";
13
- import { formatAge, type WorkIndex, type WorkItem } from "../work-index";
13
+ import { formatAge, nextAgeBoundary, type WorkIndex, type WorkItem } from "../work-index";
14
14
  import { notifyPlainFallback, showScrollDetail } from "./scroll-detail-view";
15
15
  import { readTaskFileTailCached, stderrPathFor } from "./task-output-paths";
16
16
  import { fitLines, loadPiTui, truncateToWidth } from "./pi-tui-load";
17
17
  import { statusGlyph } from "./tool-component";
18
18
 
19
- const AGE_TICK_MS = 1000;
20
19
 
21
20
  function isLive(item: WorkItem): boolean {
22
21
  return item.status === "pending" || item.status === "running";
@@ -261,9 +260,36 @@ async function showTaskList(
261
260
  let loadingAll = false;
262
261
  let confirmId: string | undefined;
263
262
  let hint = "";
264
- const unsub = subscribe(() => tui.requestRender());
265
- const ageTimer = clock.setInterval(() => tui.requestRender(), AGE_TICK_MS);
266
- clock.unref?.(ageTimer);
263
+ // formatAge rounds (now - startedAt) to the nearest second, so each
264
+ // live row's visible age flips on half-second boundaries relative to
265
+ // its own startedAt. Schedule the earliest pending flip and
266
+ // re-evaluate after every tick and every index change.
267
+ let ageTimer: unknown = null;
268
+ const clearAgeTimer = () => {
269
+ if (ageTimer === null) return;
270
+ clock.clearTimeout(ageTimer);
271
+ ageTimer = null;
272
+ };
273
+ const scheduleAgeTick = () => {
274
+ clearAgeTimer();
275
+ const now = clock.now();
276
+ let next = Infinity;
277
+ for (const item of visibleItems()) {
278
+ if (!isLive(item)) continue;
279
+ next = Math.min(next, nextAgeBoundary(now, item.startedAt));
280
+ }
281
+ if (!Number.isFinite(next)) return;
282
+ ageTimer = clock.setTimeout(() => {
283
+ ageTimer = null;
284
+ tui.requestRender();
285
+ scheduleAgeTick();
286
+ }, next - now);
287
+ clock.unref?.(ageTimer);
288
+ };
289
+ const unsub = subscribe(() => {
290
+ tui.requestRender();
291
+ scheduleAgeTick();
292
+ });
267
293
  const border = new DynamicBorder((text) => theme.fg("border", text));
268
294
  const pageSize = () => Math.max(1, (tui.terminal?.rows ?? 24) - 8);
269
295
  const matches = (data: string, id: "tui.select.up" | "tui.select.down" | "tui.select.confirm" | "tui.select.cancel") => {
@@ -273,6 +299,7 @@ async function showTaskList(
273
299
  scope === "all" ? mergeItems(getItems(), allItems) : getItems(),
274
300
  filter,
275
301
  );
302
+ scheduleAgeTick();
276
303
  const choose = (id: string | undefined) => {
277
304
  selectedId = id;
278
305
  hint = "";
@@ -394,7 +421,10 @@ async function showTaskList(
394
421
  tui.requestRender();
395
422
  }
396
423
  },
397
- dispose() { clock.clearInterval(ageTimer); unsub(); },
424
+ dispose() {
425
+ clearAgeTimer();
426
+ unsub();
427
+ },
398
428
  };
399
429
  },
400
430
  // Bottom sheet over the editor, where the user typed /tasks; the list is
@@ -0,0 +1,137 @@
1
+ /**
2
+ * The moment a wake enters the model's context (design.md §4.5).
3
+ *
4
+ * NotifyCenter hands a wake to pi.sendMessage; pi may hold it for minutes
5
+ * (a steer waits for the current tool calls and turn to end). pi emits the
6
+ * extension `message_end` event for a custom message right before it is
7
+ * pushed into the agent's context: in agent-core's loop for steered
8
+ * messages, and in the prompt for a triggerTurn message. That is where the
9
+ * real delivery is logged (`wake.inject`).
10
+ *
11
+ * Passive wakes (`triggerTurn: false`) never reach extension `message_end`:
12
+ * pi appends them through a path that only notifies its own listeners. The
13
+ * NotifyCenter sends them only when the agent is idle, so they are appended
14
+ * at send time and `wake.deliver mode=passive` is the proxy for them.
15
+ *
16
+ * Zero pi dependency: the handler takes the message object pi passes.
17
+ */
18
+ import {
19
+ FAMULUS_WAKE_CUSTOM_TYPE,
20
+ renderWakeXml,
21
+ wakeIds,
22
+ wakeRootIndex,
23
+ type FamulusWake,
24
+ type WakeChildStatus,
25
+ } from "./wake";
26
+ import type { RunRegistry } from "./subagent/registry";
27
+
28
+ export interface WakeDeliveryDeps {
29
+ now: () => number;
30
+ logEvent?: (type: string, fields?: Record<string, unknown>) => void;
31
+ /** Current subagent statuses; null/absent when no registry is live. */
32
+ lookup?: () => WakeStatusLookup | null;
33
+ }
34
+
35
+ interface CustomMessageLike {
36
+ role?: string;
37
+ customType?: string;
38
+ content?: unknown;
39
+ details?: unknown;
40
+ }
41
+
42
+ /** The wake's details when `message` is a pi-famulus-wake custom message. */
43
+ export function famulusWakeDetails(message: unknown): FamulusWake | undefined {
44
+ const m = message as CustomMessageLike | null | undefined;
45
+ if (!m || m.role !== "custom" || m.customType !== FAMULUS_WAKE_CUSTOM_TYPE) return undefined;
46
+ const details = m.details as FamulusWake | undefined;
47
+ return details && typeof details === "object" && typeof details.kind === "string" ? details : undefined;
48
+ }
49
+
50
+ /** Current statuses, read from the subagent registry at injection. */
51
+ export interface WakeStatusLookup {
52
+ childStatus: (runId: string, childId: string) => WakeChildStatus | undefined;
53
+ runStatus: (runId: string) => string | undefined;
54
+ }
55
+
56
+ /** Lookup over the run records of a subagent registry. */
57
+ export function registryStatusLookup(registry: Pick<RunRegistry, "get">): WakeStatusLookup {
58
+ return {
59
+ childStatus: (runId, childId) => registry.get(runId)?.children.find((c) => c.childId === childId)?.status,
60
+ runStatus: (runId) => registry.get(runId)?.status,
61
+ };
62
+ }
63
+
64
+ /**
65
+ * Subagent wakes are snapshots. Re-check each child's status against the
66
+ * registry: a changed child shows its current status, with the snapshot's
67
+ * status kept as statusAsOf (rendered status-as-of, plus a
68
+ * <changed-since-as-of> line). Policy: mark, never drop: the snapshot's
69
+ * results stay useful (design.md §4.5).
70
+ */
71
+ function recheckStatuses(details: FamulusWake, lookup: WakeStatusLookup): FamulusWake {
72
+ if (details.kind === "subagent-handover") {
73
+ const now = lookup.childStatus(details.runId, details.childId);
74
+ if (now === undefined || now === details.status) return details;
75
+ return { ...details, status: now, statusAsOf: details.status };
76
+ }
77
+ if (details.kind === "subagent-done") {
78
+ let changed = false;
79
+ const children = details.children.map((child) => {
80
+ const now = lookup.childStatus(details.runId, child.childId);
81
+ if (now === undefined || now === child.status) return child;
82
+ changed = true;
83
+ return { ...child, status: now, statusAsOf: child.status };
84
+ });
85
+ if (!changed) return details;
86
+ const runNow = lookup.runStatus(details.runId);
87
+ return { ...details, children, ...(runNow && runNow !== details.status ? { runStatusNow: runNow } : {}) };
88
+ }
89
+ return details;
90
+ }
91
+
92
+ /**
93
+ * The wake as the model should see it on entering the context: the root
94
+ * gains age-ms (how old its snapshot is now), and subagent statuses are
95
+ * re-checked when a lookup is given. Returns undefined when nothing
96
+ * changes: not a wake, not stamped, or content that is not the canonical
97
+ * rendering of its details (never rewrite text this module did not make).
98
+ */
99
+ export function wakeAtInjection<M extends CustomMessageLike>(
100
+ message: M,
101
+ now: number,
102
+ lookup?: WakeStatusLookup,
103
+ ): M | undefined {
104
+ const details = famulusWakeDetails(message);
105
+ if (!details || details.asOf === undefined || typeof message.content !== "string") return undefined;
106
+ const root = wakeRootIndex(message.content);
107
+ if (root < 0 || message.content.slice(root) !== renderWakeXml(details)) return undefined;
108
+ let next: FamulusWake = { ...details, ageMs: Math.max(0, now - details.asOf) };
109
+ if (lookup) next = recheckStatuses(next, lookup);
110
+ return { ...message, content: message.content.slice(0, root) + renderWakeXml(next), details: next };
111
+ }
112
+
113
+ /**
114
+ * pi `message_end` handler body: log the injection and return the
115
+ * replacement message (same role), if any.
116
+ */
117
+ export function onWakeMessageEnd<M extends CustomMessageLike>(message: M, deps: WakeDeliveryDeps): M | undefined {
118
+ try {
119
+ logWakeInjected(message, deps);
120
+ return wakeAtInjection(message, deps.now(), deps.lookup?.() ?? undefined);
121
+ } catch {
122
+ // Never break pi's message handling over a wake annotation.
123
+ return undefined;
124
+ }
125
+ }
126
+
127
+ /** Log `wake.inject` for a wake entering the context. Non-wake messages are ignored. */
128
+ export function logWakeInjected(message: unknown, deps: WakeDeliveryDeps): void {
129
+ const details = famulusWakeDetails(message);
130
+ if (!details) return;
131
+ const now = deps.now();
132
+ deps.logEvent?.("wake.inject", {
133
+ kind: details.kind,
134
+ ids: wakeIds(details),
135
+ ...(details.asOf !== undefined ? { as_of: details.asOf, lag_ms: Math.max(0, now - details.asOf) } : {}),
136
+ });
137
+ }