pi-famulus 0.1.2 → 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.
@@ -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":
@@ -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
+ }
package/src/wake.ts CHANGED
@@ -29,16 +29,56 @@ export interface TaskWake {
29
29
  signal?: string;
30
30
  }
31
31
 
32
+ export type WakeChildStatus = "pending" | "running" | "completed" | "failed" | "interrupted";
33
+
32
34
  export interface SubagentDoneChild {
33
35
  childId: string;
34
36
  name: string;
35
- status: "pending" | "running" | "completed" | "failed" | "interrupted";
37
+ /** Status when the model sees the wake (re-checked at injection). */
38
+ status: WakeChildStatus;
39
+ /** Set when the status changed after as-of: the status in the snapshot. */
40
+ statusAsOf?: WakeChildStatus;
36
41
  prompt: string;
37
42
  result: string;
38
43
  error?: string;
44
+ /** Non-fatal caveat from the child run (e.g. an adapted model spec). */
45
+ warning?: string;
46
+ }
47
+
48
+ /** The foreground shell a child is blocked on when its overrun wake is built. */
49
+ export interface OverrunShell {
50
+ taskId: string;
51
+ /** One line, capped (shellWakeTitle). */
52
+ command: string;
53
+ elapsedMs: number;
54
+ outputPath: string;
55
+ /** Output file size; null when the file could not be read. */
56
+ outputBytes: number | null;
57
+ /** Time since the output file last changed; null when unknown. */
58
+ outputIdleMs: number | null;
59
+ /** Output grew since the previous reminder, or changed in the last minute; null when unknown. */
60
+ growing: boolean | null;
39
61
  }
40
62
 
41
- export type FamulusWake =
63
+ /**
64
+ * When the wake's content was generated (design.md §4.5): stamped by the
65
+ * NotifyCenter when it receives or builds the wake. A steered wake can wait
66
+ * minutes before the model sees it; the model needs to know how old the
67
+ * snapshot is.
68
+ */
69
+ export interface WakeTiming {
70
+ /** Epoch ms. Rendered as the root attribute as-of (UTC, seconds). */
71
+ asOf?: number;
72
+ /**
73
+ * How old the wake was when it entered the model's context (ms). Set at
74
+ * injection (wake-delivery.ts), rendered as the root attribute age-ms.
75
+ */
76
+ ageMs?: number;
77
+ }
78
+
79
+ export type FamulusWake = FamulusWakeBody & WakeTiming;
80
+
81
+ type FamulusWakeBody =
42
82
  | { kind: "task"; stillRunning: WakeItem[]; tasks: TaskWake[] }
43
83
  | {
44
84
  kind: "monitor";
@@ -54,21 +94,46 @@ export type FamulusWake =
54
94
  runId: string;
55
95
  childId: string;
56
96
  name: string;
57
- status: "completed" | "failed" | "interrupted";
97
+ /** The settle this wake reports; pending/running only when re-checked at injection. */
98
+ status: WakeChildStatus;
99
+ /** Set when the status changed after as-of: the status in the snapshot. */
100
+ statusAsOf?: WakeChildStatus;
58
101
  stillRunning: WakeItem[];
59
102
  summary: string;
60
103
  prompt: string;
61
104
  result: string;
62
105
  error?: string;
106
+ warning?: string;
63
107
  }
64
108
  | {
65
109
  kind: "subagent-done";
66
110
  runId: string;
67
111
  status: "completed" | "partial" | "failed" | "interrupted";
112
+ /** Set at injection when a child is pending or running again: the run's status then. */
113
+ runStatusNow?: string;
68
114
  durationMs: number;
69
115
  summary: string;
70
116
  children: SubagentDoneChild[];
71
117
  }
118
+ | {
119
+ kind: "subagent-overrun";
120
+ runId: string;
121
+ childId: string;
122
+ name: string;
123
+ /** Time in this user turn (since admission of the launch or resume). */
124
+ elapsedMs: number;
125
+ /** The turn's soft budget (timeout_ms), extensions included. */
126
+ budgetMs: number;
127
+ /** 1 for the first wake of this turn, then 2, 3, … */
128
+ reminder: number;
129
+ /** When the next reminder is due if the parent does nothing. */
130
+ nextReminderMs: number;
131
+ /** Time left until the opt-in hard ceiling aborts the child; absent when none is set. */
132
+ hardCeilingMs?: number;
133
+ summary: string;
134
+ lastActivity: { agoMs: number; text: string };
135
+ shell?: OverrunShell;
136
+ }
72
137
  | { kind: "supervisor-request"; from: string; name: string; message: string }
73
138
  | { kind: "supervisor-update"; from: string; name: string; message: string };
74
139
 
@@ -99,7 +164,79 @@ export function formatFamulusWake(details: FamulusWake, leadIn: string = FAMULUS
99
164
  return { customType: FAMULUS_WAKE_CUSTOM_TYPE, content, details };
100
165
  }
101
166
 
167
+ /** Task/child/monitor ids a wake is about (event log `ids`). */
168
+ export function wakeIds(details: { kind?: string }): string[] {
169
+ const d = details as {
170
+ kind?: string;
171
+ id?: string;
172
+ taskId?: string;
173
+ tasks?: { id: string }[];
174
+ children?: { childId: string }[];
175
+ childId?: string;
176
+ from?: string;
177
+ };
178
+ if (d.kind === "task") return (d.tasks ?? []).map((task) => task.id);
179
+ if (d.kind === "subagent-done") return (d.children ?? []).map((child) => child.childId);
180
+ return [d.id ?? d.taskId ?? d.childId ?? d.from].filter((id): id is string => Boolean(id));
181
+ }
182
+
183
+ /** UTC, second precision: the model needs how old a snapshot is, not milliseconds. */
184
+ export function wakeTimestamp(ms: number): string {
185
+ return new Date(ms).toISOString().replace(/\.\d{3}Z$/, "Z");
186
+ }
187
+
188
+ /** Append attributes to the root <pi-famulus-wake …> tag of rendered content. */
189
+ function addRootAttrs(content: string, attrs: string[]): string {
190
+ if (attrs.length === 0) return content;
191
+ // `\s`: the lead-in mentions a bare <pi-famulus-wake>; the root tag has attributes.
192
+ const root = /<pi-famulus-wake\s[^>]*>/.exec(content);
193
+ if (!root) return content;
194
+ const end = root.index + root[0].length - 1;
195
+ return `${content.slice(0, end)} ${attrs.join(" ")}${content.slice(end)}`;
196
+ }
197
+
198
+ function timingAttrs(details: WakeTiming): string[] {
199
+ const attrs: string[] = [];
200
+ if (details.asOf !== undefined) attrs.push(`as-of="${wakeTimestamp(details.asOf)}"`);
201
+ if (details.ageMs !== undefined) attrs.push(`age-ms="${Math.round(details.ageMs)}"`);
202
+ return attrs;
203
+ }
204
+
205
+ /** The <pi-famulus-wake> element for `details` (no lead-in). */
206
+ export function renderWakeXml(details: FamulusWake): string {
207
+ return renderWake(details);
208
+ }
209
+
210
+ /** Index of the root <pi-famulus-wake …> tag in content, or -1. */
211
+ export function wakeRootIndex(content: string): number {
212
+ return content.search(/<pi-famulus-wake\s/);
213
+ }
214
+
215
+ /**
216
+ * Stamp a wake with the time it was generated. Leaves non-wake messages and
217
+ * already-stamped wakes alone, so a wake held for later delivery keeps the
218
+ * time it was built.
219
+ */
220
+ export function stampWakeAsOf<M extends { customType: string; content: string; details?: unknown }>(
221
+ message: M,
222
+ asOf: number,
223
+ ): M {
224
+ if (message.customType !== FAMULUS_WAKE_CUSTOM_TYPE) return message;
225
+ const details = message.details as (WakeTiming & { kind?: string }) | undefined;
226
+ if (!details || typeof details !== "object" || !details.kind || details.asOf !== undefined) return message;
227
+ const stamped = { ...details, asOf };
228
+ const content = addRootAttrs(message.content, timingAttrs(stamped));
229
+ // addRootAttrs is a no-op when the root tag is missing; keep details in
230
+ // sync with content so wakeAtInjection does not reject a phantom stamp.
231
+ if (content === message.content) return message;
232
+ return { ...message, content, details: stamped };
233
+ }
234
+
102
235
  function renderWake(details: FamulusWake): string {
236
+ return addRootAttrs(renderWakeBody(details), timingAttrs(details));
237
+ }
238
+
239
+ function renderWakeBody(details: FamulusWake): string {
103
240
  switch (details.kind) {
104
241
  case "task":
105
242
  return renderTask(details);
@@ -109,6 +246,8 @@ function renderWake(details: FamulusWake): string {
109
246
  return renderHandover(details);
110
247
  case "subagent-done":
111
248
  return renderDone(details);
249
+ case "subagent-overrun":
250
+ return renderOverrun(details);
112
251
  case "supervisor-request":
113
252
  return renderRequest(details);
114
253
  case "supervisor-update":
@@ -168,12 +307,23 @@ function renderHandover(details: Extract<FamulusWake, { kind: "subagent-handover
168
307
  `name="${escapeXmlAttr(details.name)}"`,
169
308
  `status="${escapeXmlAttr(details.status)}"`,
170
309
  ];
310
+ if (details.statusAsOf) attrs.push(`status-as-of="${escapeXmlAttr(details.statusAsOf)}"`);
171
311
  const parts = [`<pi-famulus-wake ${attrs.join(" ")}>`];
172
312
  const still = stillRunningXml(details.stillRunning);
173
313
  if (still) parts.push(still);
174
314
  parts.push(` <summary>${escapeXml(details.summary)}</summary>`);
315
+ if (details.statusAsOf) {
316
+ const active = details.status === "pending" || details.status === "running";
317
+ parts.push(
318
+ ` <changed-since-as-of>${escapeXml(
319
+ `${details.name} (${details.childId}): ${details.statusAsOf} → ${details.status}.` +
320
+ (active ? " Its result arrives as a new wake when it finishes." : ""),
321
+ )}</changed-since-as-of>`,
322
+ );
323
+ }
175
324
  parts.push(` <prompt>${escapeXml(details.prompt)}</prompt>`);
176
325
  if (details.error) parts.push(` <error>${escapeXml(details.error)}</error>`);
326
+ if (details.warning) parts.push(` <warning>${escapeXml(details.warning)}</warning>`);
177
327
  parts.push(` <result>${escapeXml(details.result)}</result>`);
178
328
  parts.push("</pi-famulus-wake>");
179
329
  return parts.join("\n");
@@ -186,13 +336,26 @@ function renderDone(details: Extract<FamulusWake, { kind: "subagent-done" }>): s
186
336
  `status="${escapeXmlAttr(details.status)}"`,
187
337
  `duration-ms="${Math.round(details.durationMs)}"`,
188
338
  ];
339
+ if (details.runStatusNow) attrs.push(`status-now="${escapeXmlAttr(details.runStatusNow)}"`);
189
340
  const parts = [`<pi-famulus-wake ${attrs.join(" ")}>`, ` <summary>${escapeXml(details.summary)}</summary>`];
341
+ const changed = details.children.filter((child) => child.statusAsOf !== undefined);
342
+ if (changed.length > 0) {
343
+ const list = changed.map((c) => `${c.name} (${c.childId}): ${c.statusAsOf} → ${c.status}`).join("; ");
344
+ const active = details.children.some((c) => c.status === "pending" || c.status === "running");
345
+ parts.push(
346
+ ` <changed-since-as-of>${escapeXml(
347
+ `${list}.` + (active ? " The run is active again; another subagent-done arrives when it finishes." : ""),
348
+ )}</changed-since-as-of>`,
349
+ );
350
+ }
190
351
  for (const child of details.children) {
352
+ const asOf = child.statusAsOf ? ` status-as-of="${escapeXmlAttr(child.statusAsOf)}"` : "";
191
353
  parts.push(
192
- ` <child id="${escapeXmlAttr(child.childId)}" name="${escapeXmlAttr(child.name)}" status="${escapeXmlAttr(child.status)}">`,
354
+ ` <child id="${escapeXmlAttr(child.childId)}" name="${escapeXmlAttr(child.name)}" status="${escapeXmlAttr(child.status)}"${asOf}>`,
193
355
  );
194
356
  parts.push(` <prompt>${escapeXml(child.prompt)}</prompt>`);
195
357
  if (child.error) parts.push(` <error>${escapeXml(child.error)}</error>`);
358
+ if (child.warning) parts.push(` <warning>${escapeXml(child.warning)}</warning>`);
196
359
  parts.push(` <result>${escapeXml(child.result)}</result>`);
197
360
  parts.push(" </child>");
198
361
  }
@@ -200,6 +363,77 @@ function renderDone(details: Extract<FamulusWake, { kind: "subagent-done" }>): s
200
363
  return parts.join("\n");
201
364
  }
202
365
 
366
+ /** Coarse human duration for model-facing text: "45s", "12m", "1h05m". */
367
+ export function wakeDuration(ms: number): string {
368
+ const s = Math.max(0, Math.round(ms / 1000));
369
+ if (s < 60) return `${s}s`;
370
+ const m = Math.round(s / 60);
371
+ if (m < 60) return `${m}m`;
372
+ return `${Math.floor(m / 60)}h${String(m % 60).padStart(2, "0")}m`;
373
+ }
374
+
375
+ function renderOverrun(details: Extract<FamulusWake, { kind: "subagent-overrun" }>): string {
376
+ const attrs = [
377
+ 'kind="subagent-overrun"',
378
+ `run-id="${escapeXmlAttr(details.runId)}"`,
379
+ `child-id="${escapeXmlAttr(details.childId)}"`,
380
+ `name="${escapeXmlAttr(details.name)}"`,
381
+ `elapsed-ms="${Math.round(details.elapsedMs)}"`,
382
+ `budget-ms="${Math.round(details.budgetMs)}"`,
383
+ `reminder="${details.reminder}"`,
384
+ ];
385
+ if (details.hardCeilingMs !== undefined) attrs.push(`hard-ceiling-ms="${Math.round(details.hardCeilingMs)}"`);
386
+ const parts = [`<pi-famulus-wake ${attrs.join(" ")}>`, ` <summary>${escapeXml(details.summary)}</summary>`];
387
+ parts.push(
388
+ ` <last-activity ago-ms="${Math.round(details.lastActivity.agoMs)}">${escapeXml(details.lastActivity.text)}</last-activity>`,
389
+ );
390
+ const shell = details.shell;
391
+ if (shell) {
392
+ const shellAttrs = [`task-id="${escapeXmlAttr(shell.taskId)}"`, `elapsed-ms="${Math.round(shell.elapsedMs)}"`];
393
+ if (shell.outputBytes !== null) shellAttrs.push(`output-bytes="${shell.outputBytes}"`);
394
+ if (shell.outputIdleMs !== null) shellAttrs.push(`output-idle-ms="${Math.round(shell.outputIdleMs)}"`);
395
+ if (shell.growing !== null) shellAttrs.push(`growing="${shell.growing ? "yes" : "no"}"`);
396
+ parts.push(` <shell ${shellAttrs.join(" ")}>`);
397
+ parts.push(` <command>${escapeXml(shell.command)}</command>`);
398
+ parts.push(` <output-file>${escapeXml(shell.outputPath)}</output-file>`);
399
+ parts.push(" </shell>");
400
+ }
401
+ parts.push(` <options>${escapeXml(overrunOptions(details))}</options>`);
402
+ parts.push("</pi-famulus-wake>");
403
+ return parts.join("\n");
404
+ }
405
+
406
+ /** The parent's three actions and what happens if it takes none. */
407
+ function overrunOptions(details: Extract<FamulusWake, { kind: "subagent-overrun" }>): string {
408
+ const ids = `run_id: "${details.runId}", child_id: "${details.childId}"`;
409
+ const actions =
410
+ `give it more time with subagent({ action: "extend", ${ids}, timeout_ms: <ms from now> }); ` +
411
+ `redirect it with agent_message({ action: "send", to: "${details.childId}", message: "<instruction>" }); ` +
412
+ `or stop it with subagent({ action: "interrupt", ${ids} }). `;
413
+ const hard = details.hardCeilingMs;
414
+ if (hard === undefined) {
415
+ return (
416
+ "It has not been stopped. Choose one: " +
417
+ actions +
418
+ `If you do none of these, it keeps running and the next reminder is scheduled in ${wakeDuration(details.nextReminderMs)}; ` +
419
+ "its result arrives as usual when it finishes."
420
+ );
421
+ }
422
+ // Opt-in hard ceiling: never promise "keeps running" past it.
423
+ const ceiling = wakeDuration(hard);
424
+ const noAction =
425
+ details.nextReminderMs < hard
426
+ ? `If you do none of these, it keeps running and the next reminder is scheduled in ${wakeDuration(details.nextReminderMs)}, until the hard ceiling stops it in ${ceiling}.`
427
+ : "If you do none of these, it keeps running until the hard ceiling stops it.";
428
+ return (
429
+ `It has not been stopped yet, but the configured hard ceiling stops it in ${ceiling}, and extend does not move that ceiling. ` +
430
+ "Choose one: " +
431
+ actions +
432
+ noAction +
433
+ " Its result, or the interruption, arrives as a wake."
434
+ );
435
+ }
436
+
203
437
  function renderRequest(details: Extract<FamulusWake, { kind: "supervisor-request" }>): string {
204
438
  const recipe = `agent_message { action: "reply", to: "${details.from}", message: "<your decision>" }`;
205
439
  return [