pi-famulus 0.1.2 → 0.1.4

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.
@@ -25,6 +25,8 @@ import type {
25
25
  import { taskOutputPath } from "../config";
26
26
  import { realClock, type Clock } from "../clock";
27
27
  import type { ManagerClient } from "../manager-client";
28
+ import type { ChildShellTracker } from "./overrun";
29
+ import { fill, PROMPTS } from "../prompts.generated";
28
30
  import {
29
31
  appendStatus,
30
32
  bareSleepError,
@@ -43,10 +45,8 @@ const MAX_BYTES = SHELL_MAX_BYTES;
43
45
  const WAIT_SLICE_MS = 60_000;
44
46
 
45
47
  const childBashParameters = Type.Object({
46
- command: Type.String({ description: "The bash command to execute" }),
47
- timeout: Type.Optional(
48
- Type.Number({ description: "Hard kill timeout in seconds (optional, no default timeout)" }),
49
- ),
48
+ command: Type.String({ description: PROMPTS["tools.child.bash.param.command"] }),
49
+ timeout: Type.Optional(Type.Number({ description: PROMPTS["tools.child.bash.param.timeout"] })),
50
50
  });
51
51
 
52
52
  type ChildBashParams = { command: string; timeout?: number };
@@ -66,13 +66,12 @@ export interface ChildBashDeps {
66
66
  childId?: string;
67
67
  runId?: string;
68
68
  clock?: Clock;
69
+ /** Records the shell this child is blocked on, for its overrun wake. */
70
+ shells?: Pick<ChildShellTracker, "start" | "end">;
69
71
  }
70
72
 
71
- const CHILD_SLEEP_GUIDANCE =
72
- "Sleeping to wait for work is never useful: run the actual command directly, or report back to the supervisor if you are blocked.";
73
-
74
73
  function rejectBareSleep(command: string): string | null {
75
- return bareSleepError(command, CHILD_SLEEP_GUIDANCE);
74
+ return bareSleepError(command, "tools.child.bash.error.bare-sleep");
76
75
  }
77
76
 
78
77
  function resolveTimeoutMs(timeoutSeconds: number | undefined): number | null {
@@ -98,12 +97,8 @@ export function createChildBashTool(
98
97
  return {
99
98
  name: "bash",
100
99
  label: "Bash",
101
- description:
102
- "Execute a bash command in the current working directory. Returns stdout and stderr. " +
103
- `Output is truncated to last ${MAX_LINES} lines or ${MAX_BYTES / 1024}KB (whichever is hit first). ` +
104
- "The command runs to completion (or the optional timeout in seconds, after which it is killed). " +
105
- "There is no background execution inside subagents.",
106
- promptSnippet: "Execute bash commands (ls, grep, find, etc.)",
100
+ description: fill("tools.child.bash.description", { maxLines: MAX_LINES, maxKb: MAX_BYTES / 1024 }),
101
+ promptSnippet: PROMPTS["tools.child.bash.snippet"],
107
102
  parameters: childBashParameters,
108
103
  async execute(_toolCallId, params, signal, _onUpdate, ctx: ExtensionContext) {
109
104
  const input = params as ChildBashParams;
@@ -132,52 +127,66 @@ export function createChildBashTool(
132
127
  const outputPath = taskOutputPath(deps.home, deps.sessionId(), start.task_id);
133
128
 
134
129
  const clock = deps.clock ?? realClock;
135
- const deadline = timeoutMs !== null ? clock.now() + timeoutMs : null;
136
- let waitResult: { done: boolean; exit_code?: number | null } | null = null;
137
- for (;;) {
138
- const budget =
139
- deadline === null
140
- ? WAIT_SLICE_MS
141
- : Math.min(WAIT_SLICE_MS, Math.max(1, deadline - clock.now()));
142
- try {
143
- waitResult = await withAbort(client.wait(start.task_id, budget), signal, () => {
144
- client.stop(start.task_id, "tool").catch(() => {});
145
- });
146
- } catch (err) {
147
- if ((err as Error).message === "aborted") {
148
- throw new Error("Command aborted (task stopped)");
130
+ const childId = deps.childId;
131
+ if (childId) {
132
+ deps.shells?.start(childId, {
133
+ taskId: start.task_id,
134
+ command: input.command,
135
+ startedAt: clock.now(),
136
+ outputPath,
137
+ });
138
+ }
139
+ try {
140
+ const deadline = timeoutMs !== null ? clock.now() + timeoutMs : null;
141
+ let waitResult: { done: boolean; exit_code?: number | null } | null = null;
142
+ for (;;) {
143
+ const budget =
144
+ deadline === null
145
+ ? WAIT_SLICE_MS
146
+ : Math.min(WAIT_SLICE_MS, Math.max(1, deadline - clock.now()));
147
+ try {
148
+ waitResult = await withAbort(client.wait(start.task_id, budget), signal, () => {
149
+ client.stop(start.task_id, "tool").catch(() => {});
150
+ });
151
+ } catch (err) {
152
+ if ((err as Error).message === "aborted") {
153
+ throw new Error("Command aborted (task stopped)");
154
+ }
155
+ throw new Error(
156
+ `Lost contact with pi-famulus while waiting for task ${start.task_id}: ` +
157
+ `${(err as Error).message}. Output so far: ${outputPath}.`,
158
+ );
159
+ }
160
+ if (waitResult.done) break;
161
+ if (deadline !== null && clock.now() >= deadline) {
162
+ await client.stop(start.task_id, "timeout").catch(() => {});
163
+ const collected = await collectOutput(client, start.task_id).catch(() => null);
164
+ const text = collected ? formatFinishedOutput(collected, outputPath).text : "";
165
+ throw new Error(
166
+ appendStatus(text, timedOutStatus(input.timeout!)),
167
+ );
149
168
  }
150
- throw new Error(
151
- `Lost contact with pi-famulus while waiting for task ${start.task_id}: ` +
152
- `${(err as Error).message}. Output so far: ${outputPath}.`,
153
- );
154
- }
155
- if (waitResult.done) break;
156
- if (deadline !== null && clock.now() >= deadline) {
157
- await client.stop(start.task_id, "timeout").catch(() => {});
158
- const collected = await collectOutput(client, start.task_id).catch(() => null);
159
- const text = collected ? formatFinishedOutput(collected, outputPath).text : "";
160
- throw new Error(
161
- appendStatus(text, timedOutStatus(input.timeout!)),
162
- );
163
169
  }
164
- }
165
170
 
166
- const collected = await collectOutput(client, start.task_id);
167
- const { text, details } = formatFinishedOutput(collected, outputPath);
168
- const exitCode = waitResult.exit_code ?? null;
169
- if (exitCode !== 0 && exitCode !== null) {
170
- throw new Error(appendStatus(text, `Command exited with code ${exitCode}`));
171
- }
172
- // No exit code: killed (timeout, stop, crash), unless the manager
173
- // finished it as completed with its runner status unobservable.
174
- if (exitCode === null && collected.status !== "completed") {
175
- throw new Error(appendStatus(text, await killedStatus(client, start.task_id, input.timeout)));
171
+ const collected = await collectOutput(client, start.task_id);
172
+ const { text, details } = formatFinishedOutput(collected, outputPath);
173
+ const exitCode = waitResult.exit_code ?? null;
174
+ if (exitCode !== 0 && exitCode !== null) {
175
+ throw new Error(appendStatus(text, `Command exited with code ${exitCode}`));
176
+ }
177
+ // No exit code: killed (timeout, stop, crash), unless the manager
178
+ // finished it as completed with its runner status unobservable.
179
+ if (exitCode === null && collected.status !== "completed") {
180
+ throw new Error(appendStatus(text, await killedStatus(client, start.task_id, input.timeout)));
181
+ }
182
+ const finalDetails = details
183
+ ? { ...details, task_id: start.task_id }
184
+ : ({ task_id: start.task_id } as FamulusChildBashDetails);
185
+ return { content: [{ type: "text", text }], details: finalDetails };
186
+ } finally {
187
+ // The overrun wake must not name a shell that already returned.
188
+ if (childId) deps.shells?.end(childId, start.task_id);
176
189
  }
177
- const finalDetails = details
178
- ? { ...details, task_id: start.task_id }
179
- : ({ task_id: start.task_id } as FamulusChildBashDetails);
180
- return { content: [{ type: "text", text }], details: finalDetails };
181
190
  },
182
191
  };
183
192
  }
@@ -6,8 +6,23 @@
6
6
  * candidate set (scopedModels whitelist when non-empty, else all available).
7
7
  *
8
8
  * Spec grammar: `[provider/|provider:]id[:thinking]`
9
- * - thinking suffix: minimal|low|medium|high|xhigh (unrecognized suffixes
10
- * are treated as part of the id, e.g. OpenRouter's ":exacto")
9
+ * - thinking suffix: one of VALID_THINKING_LEVELS (pi core parity;
10
+ * src/thinking-levels.ts is the single source of truth). Unrecognized
11
+ * suffixes are NOT silently kept in the id: when the full spec fails to
12
+ * match, the resolver retries without the trailing ":suffix" and adapts
13
+ * with a warning — the same semantics as pi's
14
+ * parseModelPattern(allowInvalidThinkingLevelFallback). The retry only
15
+ * fires on a full-spec no-match, so literal ids whose trailing ':segment'
16
+ * is not a valid level (OpenRouter ":exacto") still win at exact match.
17
+ * Note the converse: an id literally ending in a valid level (":high")
18
+ * is read as a thinking override, never as a literal id.
19
+ * A "provider:id" separator is never a thinking suffix: when the
20
+ * trailing ':' is the spec's first separator and its prefix is a known
21
+ * provider (case-insensitive; candidate.provider or the first '/' segment
22
+ * of candidate.id), the colon is left in the base ("openai:off" /
23
+ * "openai:max" / "openai:nonexistent" stay hard no-matches). Detection
24
+ * runs before valid-level splitting so a recognized level cannot bypass
25
+ * the guard. Bare "gpt-5.2:off" and "openai:gpt-5.2:off" still split.
11
26
  * - provider prefix: "provider/id" or "provider:id" both accepted
12
27
  * - bare id: exact unique match, else case-insensitive substring on id/name
13
28
  */
@@ -19,10 +34,24 @@ export interface ModelCandidate {
19
34
  }
20
35
 
21
36
  export type ModelResolution =
22
- | { ok: true; provider: string; id: string; thinking?: string }
23
- | { ok: false; error: "no-match" | "ambiguous"; candidates: string[]; thinking?: string };
37
+ | { ok: true; provider: string; id: string; thinking?: string; warning?: string }
38
+ | {
39
+ ok: false;
40
+ error: "no-match" | "ambiguous";
41
+ candidates: string[];
42
+ thinking?: string;
43
+ /** Set when a trailing ":suffix" was stripped and retried unsuccessfully. */
44
+ suffixHint?: string;
45
+ };
24
46
 
25
- const THINKING_LEVELS = new Set(["minimal", "low", "medium", "high", "xhigh"]);
47
+ /**
48
+ * Thinking levels pi core accepts — re-exported from the single source of
49
+ * truth (src/thinking-levels.ts) for convenience of resolver callers.
50
+ */
51
+ export { VALID_THINKING_LEVELS } from "../thinking-levels";
52
+ import { VALID_THINKING_LEVELS } from "../thinking-levels";
53
+
54
+ const THINKING_LEVELS = new Set<string>(VALID_THINKING_LEVELS);
26
55
 
27
56
  function label(c: ModelCandidate): string {
28
57
  return `${c.provider}/${c.id}`;
@@ -49,10 +78,42 @@ function splitProviderPrefix(spec: string): { provider: string; id: string } | n
49
78
  return null;
50
79
  }
51
80
 
52
- export function resolveModelSpec(spec: string, candidates: ModelCandidate[]): ModelResolution {
53
- const { base, thinking } = splitThinkingSuffix(spec.trim());
81
+ /**
82
+ * Provider names the separator guard recognizes: each candidate's `provider`
83
+ * field, plus the first `/` segment of its id. OpenRouter ids look like
84
+ * `openai/gpt-5.2:exacto`; if the scoped whitelist omits the openai provider
85
+ * itself, that nested prefix still has to count, or `openai:nonexistent`
86
+ * retries as `openai` and unique-fuzzy-matches the OpenRouter id.
87
+ */
88
+ function knownProviders(candidates: ModelCandidate[]): string[] {
89
+ const out: string[] = [];
90
+ for (const c of candidates) {
91
+ out.push(c.provider);
92
+ const slash = c.id.indexOf("/");
93
+ if (slash > 0) out.push(c.id.slice(0, slash));
94
+ }
95
+ return out;
96
+ }
97
+
98
+ /**
99
+ * True when the ':' at `idx` is the "provider:id" separator rather than a
100
+ * thinking suffix: it is the spec's first separator and the text before it
101
+ * names a known provider (case-insensitive). Stripping it would retry the
102
+ * bare provider name, which fuzzy-matches unrelated ids (e.g.
103
+ * "openai:nonexistent" → the OpenRouter id "openai/gpt-5.2:exacto") and
104
+ * silently drops the requested id.
105
+ */
106
+ function isProviderSeparator(spec: string, idx: number, providers: readonly string[]): boolean {
107
+ const provider = spec.slice(0, idx);
108
+ if (/[/:]/.test(provider)) return false;
109
+ const needle = provider.toLowerCase();
110
+ return providers.some((p) => p.toLowerCase() === needle);
111
+ }
112
+
113
+ /** The three matching rules, shared by both resolution passes. */
114
+ function matchBase(base: string, candidates: ModelCandidate[]): ModelResolution {
54
115
  if (base.length === 0) {
55
- return { ok: false, error: "no-match", candidates: candidates.map(label), thinking };
116
+ return { ok: false, error: "no-match", candidates: candidates.map(label) };
56
117
  }
57
118
 
58
119
  // 1) provider-qualified exact match
@@ -61,17 +122,17 @@ export function resolveModelSpec(spec: string, candidates: ModelCandidate[]): Mo
61
122
  const hit = candidates.find(
62
123
  (c) => c.provider === qualified.provider && c.id === qualified.id,
63
124
  );
64
- if (hit) return { ok: true, provider: hit.provider, id: hit.id, thinking };
125
+ if (hit) return { ok: true, provider: hit.provider, id: hit.id };
65
126
  // Fall through: maybe the whole thing is a bare id containing a separator.
66
127
  }
67
128
 
68
129
  // 2) bare-id exact match (must be unique across providers)
69
130
  const exact = candidates.filter((c) => c.id === base);
70
131
  if (exact.length === 1) {
71
- return { ok: true, provider: exact[0].provider, id: exact[0].id, thinking };
132
+ return { ok: true, provider: exact[0].provider, id: exact[0].id };
72
133
  }
73
134
  if (exact.length > 1) {
74
- return { ok: false, error: "ambiguous", candidates: exact.map(label), thinking };
135
+ return { ok: false, error: "ambiguous", candidates: exact.map(label) };
75
136
  }
76
137
 
77
138
  // 3) case-insensitive substring on id or display name
@@ -82,26 +143,83 @@ export function resolveModelSpec(spec: string, candidates: ModelCandidate[]): Mo
82
143
  (c.name !== undefined && c.name.toLowerCase().includes(needle)),
83
144
  );
84
145
  if (fuzzy.length === 1) {
85
- return { ok: true, provider: fuzzy[0].provider, id: fuzzy[0].id, thinking };
146
+ return { ok: true, provider: fuzzy[0].provider, id: fuzzy[0].id };
86
147
  }
87
148
  if (fuzzy.length > 1) {
88
- return { ok: false, error: "ambiguous", candidates: fuzzy.map(label), thinking };
149
+ return { ok: false, error: "ambiguous", candidates: fuzzy.map(label) };
150
+ }
151
+ return { ok: false, error: "no-match", candidates: candidates.map(label) };
152
+ }
153
+
154
+ export function resolveModelSpec(spec: string, candidates: ModelCandidate[]): ModelResolution {
155
+ const trimmed = spec.trim();
156
+ const providers = knownProviders(candidates);
157
+ const lastColon = trimmed.lastIndexOf(":");
158
+ // Known-provider / first-colon detection runs BEFORE valid thinking-suffix
159
+ // splitting. Otherwise a recognized level (off/max, newly in this PR's
160
+ // parity list) strips the separator, matchBase("openai") fuzzy-matches an
161
+ // unrelated id, and the retry guard at the bottom never sees the spec.
162
+ const skipThinkingSplit =
163
+ lastColon > 0 &&
164
+ lastColon < trimmed.length - 1 &&
165
+ isProviderSeparator(trimmed, lastColon, providers);
166
+ const { base, thinking } = skipThinkingSplit
167
+ ? { base: trimmed, thinking: undefined }
168
+ : splitThinkingSuffix(trimmed);
169
+ const first = matchBase(base, candidates);
170
+ if (first.ok || thinking !== undefined || first.error === "ambiguous") {
171
+ // Success, a valid thinking suffix, or an ambiguity: all final. Ambiguity
172
+ // is semantic, not syntax — report it instead of adapting past it.
173
+ return { ...first, thinking };
174
+ }
175
+
176
+ // Unknown trailing ":suffix" on a no-match: mirror pi core's
177
+ // parseModelPattern(allowInvalidThinkingLevelFallback) — strip the suffix,
178
+ // retry, and adapt with a warning instead of failing the whole call. The
179
+ // retry only fires when the full spec matched nothing, so literal ids
180
+ // containing a colon (OpenRouter ":exacto") are unaffected.
181
+ const idx = trimmed.lastIndexOf(":");
182
+ if (idx <= 0 || idx === trimmed.length - 1) return first;
183
+ if (isProviderSeparator(trimmed, idx, providers)) return first;
184
+ const suffix = trimmed.slice(idx + 1);
185
+ const retriedBase = trimmed.slice(0, idx);
186
+ const retried = matchBase(retriedBase, candidates);
187
+ if (retried.ok) {
188
+ return {
189
+ ...retried,
190
+ warning:
191
+ `unknown thinking level "${suffix}" in model spec "${trimmed}"; ` +
192
+ `using the model's default thinking ` +
193
+ `(valid levels: ${VALID_THINKING_LEVELS.join(", ")})`,
194
+ };
89
195
  }
90
- return { ok: false, error: "no-match", candidates: candidates.map(label), thinking };
196
+ return {
197
+ ...retried,
198
+ suffixHint:
199
+ `":${suffix}" is not a valid thinking level ` +
200
+ `(valid: ${VALID_THINKING_LEVELS.join(", ")}); ` +
201
+ `retried without it as "${retriedBase}"`,
202
+ };
91
203
  }
92
204
 
93
205
  /** Render a resolution failure as an actionable error message. */
94
206
  export function modelResolutionError(spec: string, res: ModelResolution & { ok: false }): string {
95
207
  const list = res.candidates.slice(0, 20).join(", ");
96
208
  const more = res.candidates.length > 20 ? `, … +${res.candidates.length - 20} more` : "";
209
+ const hint = res.suffixHint ? ` (${res.suffixHint})` : "";
97
210
  if (res.error === "ambiguous") {
211
+ // With a suffixHint the ambiguity belongs to the retried base, not the
212
+ // full spec — say so, and name the dropped suffix either way.
98
213
  return (
99
- `model spec "${spec}" is ambiguous (matches: ${list}${more}). ` +
214
+ (res.suffixHint
215
+ ? `model spec "${spec}" is ambiguous on the retried base`
216
+ : `model spec "${spec}" is ambiguous`) +
217
+ ` (matches: ${list}${more}).${hint} ` +
100
218
  `Qualify with "provider/<id>".`
101
219
  );
102
220
  }
103
221
  return (
104
- `model spec "${spec}" matched nothing. ` +
222
+ `model spec "${spec}" matched nothing.${hint} ` +
105
223
  `Available: ${list || "none"}${more}. ` +
106
224
  `Use subagent({action:"models"}) to list selectable models.`
107
225
  );
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Overrun wake inputs (design doc §4.6 child lifecycle): which foreground
3
+ * shell each child is blocked on, and the builder that turns a runner
4
+ * overrun tick into the wake payload.
5
+ *
6
+ * Zero pi dependency; the output-file stat is injected so tests can drive it.
7
+ */
8
+ import { formatSubagentOverrunBatch, type SubagentOverrunInfo } from "../format";
9
+ import type { FormattedWake, OverrunShell } from "../wake";
10
+ import type { ConversationTurn } from "./types";
11
+
12
+ export interface ChildShell {
13
+ taskId: string;
14
+ command: string;
15
+ startedAt: number;
16
+ outputPath: string;
17
+ }
18
+
19
+ /** Output that changed within this window counts as growing on a first reading. */
20
+ export const OUTPUT_GROWING_WINDOW_MS = 60_000;
21
+
22
+ /**
23
+ * Foreground shells per child. Child bash records a shell when the manager
24
+ * starts it and clears it when the call returns or throws; a child runs at
25
+ * most one foreground shell at a time (no background bash in children).
26
+ */
27
+ export class ChildShellTracker {
28
+ private readonly shells = new Map<string, ChildShell>();
29
+ /** Output size at the previous overrun reading, per task. */
30
+ private readonly sizes = new Map<string, number>();
31
+
32
+ start(childId: string, shell: ChildShell): void {
33
+ this.shells.set(childId, shell);
34
+ }
35
+
36
+ end(childId: string, taskId: string): void {
37
+ if (this.shells.get(childId)?.taskId === taskId) this.shells.delete(childId);
38
+ this.sizes.delete(taskId);
39
+ }
40
+
41
+ current(childId: string): ChildShell | undefined {
42
+ return this.shells.get(childId);
43
+ }
44
+
45
+ /** Record a size reading and return the previous one for the same task. */
46
+ swapSize(taskId: string, size: number): number | undefined {
47
+ const previous = this.sizes.get(taskId);
48
+ this.sizes.set(taskId, size);
49
+ return previous;
50
+ }
51
+ }
52
+
53
+ /** What the runner knows at an overrun tick. */
54
+ export interface OverrunTick {
55
+ childId: string;
56
+ elapsedMs: number;
57
+ budgetMs: number;
58
+ reminder: number;
59
+ nextReminderMs: number;
60
+ lastEventAt: number;
61
+ /** Time left until the opt-in hard ceiling aborts the child; null when none is set. */
62
+ hardRemainingMs: number | null;
63
+ }
64
+
65
+ export interface OverrunInfoDeps {
66
+ now: () => number;
67
+ /** Run id and display name; undefined once the child's run is gone. */
68
+ lookupChild: (childId: string) => { runId: string; name: string } | undefined;
69
+ conversation: (childId: string) => ConversationTurn[];
70
+ shells: ChildShellTracker;
71
+ /** Output file size and mtime; null when it cannot be read. */
72
+ stat: (path: string) => { size: number; mtimeMs: number } | null;
73
+ }
74
+
75
+ function lastActivityText(turns: ConversationTurn[]): string {
76
+ const last = turns[turns.length - 1];
77
+ return last ? `${last.role}: ${last.text}` : "";
78
+ }
79
+
80
+ function describeShell(deps: OverrunInfoDeps, shell: ChildShell): OverrunShell {
81
+ const now = deps.now();
82
+ const st = deps.stat(shell.outputPath);
83
+ let outputBytes: number | null = null;
84
+ let outputIdleMs: number | null = null;
85
+ let growing: boolean | null = null;
86
+ if (st) {
87
+ outputBytes = st.size;
88
+ outputIdleMs = Math.max(0, now - st.mtimeMs);
89
+ const previous = deps.shells.swapSize(shell.taskId, st.size);
90
+ growing = (previous !== undefined && st.size > previous) || outputIdleMs < OUTPUT_GROWING_WINDOW_MS;
91
+ }
92
+ return {
93
+ taskId: shell.taskId,
94
+ command: shell.command,
95
+ elapsedMs: Math.max(0, now - shell.startedAt),
96
+ outputPath: shell.outputPath,
97
+ outputBytes,
98
+ outputIdleMs,
99
+ growing,
100
+ };
101
+ }
102
+
103
+ export function buildOverrunInfo(deps: OverrunInfoDeps, tick: OverrunTick): SubagentOverrunInfo | undefined {
104
+ const child = deps.lookupChild(tick.childId);
105
+ if (!child) return undefined;
106
+ const shell = deps.shells.current(tick.childId);
107
+ return {
108
+ runId: child.runId,
109
+ childId: tick.childId,
110
+ name: child.name,
111
+ elapsedMs: tick.elapsedMs,
112
+ budgetMs: tick.budgetMs,
113
+ reminder: tick.reminder,
114
+ nextReminderMs: tick.nextReminderMs,
115
+ ...(tick.hardRemainingMs !== null ? { hardCeilingMs: tick.hardRemainingMs } : {}),
116
+ lastActivity: {
117
+ agoMs: Math.max(0, deps.now() - tick.lastEventAt),
118
+ text: lastActivityText(deps.conversation(tick.childId)),
119
+ },
120
+ ...(shell ? { shell: describeShell(deps, shell) } : {}),
121
+ };
122
+ }
123
+
124
+ export interface OverrunNotifierDeps {
125
+ now: () => number;
126
+ registry: {
127
+ list(): { runId: string; children: { childId: string; name: string }[] }[];
128
+ handle(childId: string): { conversation(): ConversationTurn[]; collectDueOverrun?(now: number): OverrunTick | undefined } | undefined;
129
+ };
130
+ shells: ChildShellTracker;
131
+ stat: OverrunInfoDeps["stat"];
132
+ /** Parent wake (NotifyCenter.notify). */
133
+ notify: (wake: FormattedWake) => void;
134
+ logEvent: (type: string, fields: Record<string, unknown>) => void;
135
+ }
136
+
137
+ /**
138
+ * The runner's onOverrun: build the payload, log `agent.overrun`, wake the
139
+ * parent. A child whose run is already gone produces neither.
140
+ */
141
+ export function createOverrunNotifier(deps: OverrunNotifierDeps): (tick: OverrunTick) => void {
142
+ const infoDeps: OverrunInfoDeps = {
143
+ now: deps.now,
144
+ lookupChild: (childId) => {
145
+ for (const run of deps.registry.list()) {
146
+ const child = run.children.find((c) => c.childId === childId);
147
+ if (child) return { runId: run.runId, name: child.name };
148
+ }
149
+ return undefined;
150
+ },
151
+ conversation: (childId) => deps.registry.handle(childId)?.conversation() ?? [],
152
+ shells: deps.shells,
153
+ stat: deps.stat,
154
+ };
155
+ return (tick) => {
156
+ const infos: SubagentOverrunInfo[] = [];
157
+ const primary = buildOverrunInfo(infoDeps, tick);
158
+ if (!primary) return;
159
+ infos.push(primary);
160
+ const now = deps.now();
161
+ for (const run of deps.registry.list()) {
162
+ for (const child of run.children) {
163
+ if (child.childId === tick.childId) continue;
164
+ const due = deps.registry.handle(child.childId)?.collectDueOverrun?.(now);
165
+ if (due) {
166
+ const info = buildOverrunInfo(infoDeps, due);
167
+ if (info) infos.push(info);
168
+ }
169
+ }
170
+ }
171
+ for (const info of infos) {
172
+ const shell = info.shell;
173
+ deps.logEvent("agent.overrun", {
174
+ child_id: info.childId,
175
+ run_id: info.runId,
176
+ reminder: info.reminder,
177
+ elapsed_ms: info.elapsedMs,
178
+ budget_ms: info.budgetMs,
179
+ ...(shell
180
+ ? {
181
+ shell_task_id: shell.taskId,
182
+ shell_elapsed_ms: shell.elapsedMs,
183
+ ...(shell.outputBytes !== null ? { output_bytes: shell.outputBytes } : {}),
184
+ ...(shell.growing !== null ? { growing: shell.growing } : {}),
185
+ }
186
+ : {}),
187
+ });
188
+ }
189
+ deps.notify(formatSubagentOverrunBatch(infos));
190
+ };
191
+ }