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.
@@ -25,6 +25,7 @@ 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";
28
29
  import {
29
30
  appendStatus,
30
31
  bareSleepError,
@@ -66,6 +67,8 @@ export interface ChildBashDeps {
66
67
  childId?: string;
67
68
  runId?: string;
68
69
  clock?: Clock;
70
+ /** Records the shell this child is blocked on, for its overrun wake. */
71
+ shells?: Pick<ChildShellTracker, "start" | "end">;
69
72
  }
70
73
 
71
74
  const CHILD_SLEEP_GUIDANCE =
@@ -132,52 +135,66 @@ export function createChildBashTool(
132
135
  const outputPath = taskOutputPath(deps.home, deps.sessionId(), start.task_id);
133
136
 
134
137
  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)");
138
+ const childId = deps.childId;
139
+ if (childId) {
140
+ deps.shells?.start(childId, {
141
+ taskId: start.task_id,
142
+ command: input.command,
143
+ startedAt: clock.now(),
144
+ outputPath,
145
+ });
146
+ }
147
+ try {
148
+ const deadline = timeoutMs !== null ? clock.now() + timeoutMs : null;
149
+ let waitResult: { done: boolean; exit_code?: number | null } | null = null;
150
+ for (;;) {
151
+ const budget =
152
+ deadline === null
153
+ ? WAIT_SLICE_MS
154
+ : Math.min(WAIT_SLICE_MS, Math.max(1, deadline - clock.now()));
155
+ try {
156
+ waitResult = await withAbort(client.wait(start.task_id, budget), signal, () => {
157
+ client.stop(start.task_id, "tool").catch(() => {});
158
+ });
159
+ } catch (err) {
160
+ if ((err as Error).message === "aborted") {
161
+ throw new Error("Command aborted (task stopped)");
162
+ }
163
+ throw new Error(
164
+ `Lost contact with pi-famulus while waiting for task ${start.task_id}: ` +
165
+ `${(err as Error).message}. Output so far: ${outputPath}.`,
166
+ );
167
+ }
168
+ if (waitResult.done) break;
169
+ if (deadline !== null && clock.now() >= deadline) {
170
+ await client.stop(start.task_id, "timeout").catch(() => {});
171
+ const collected = await collectOutput(client, start.task_id).catch(() => null);
172
+ const text = collected ? formatFinishedOutput(collected, outputPath).text : "";
173
+ throw new Error(
174
+ appendStatus(text, timedOutStatus(input.timeout!)),
175
+ );
149
176
  }
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
177
  }
164
- }
165
178
 
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)));
179
+ const collected = await collectOutput(client, start.task_id);
180
+ const { text, details } = formatFinishedOutput(collected, outputPath);
181
+ const exitCode = waitResult.exit_code ?? null;
182
+ if (exitCode !== 0 && exitCode !== null) {
183
+ throw new Error(appendStatus(text, `Command exited with code ${exitCode}`));
184
+ }
185
+ // No exit code: killed (timeout, stop, crash), unless the manager
186
+ // finished it as completed with its runner status unobservable.
187
+ if (exitCode === null && collected.status !== "completed") {
188
+ throw new Error(appendStatus(text, await killedStatus(client, start.task_id, input.timeout)));
189
+ }
190
+ const finalDetails = details
191
+ ? { ...details, task_id: start.task_id }
192
+ : ({ task_id: start.task_id } as FamulusChildBashDetails);
193
+ return { content: [{ type: "text", text }], details: finalDetails };
194
+ } finally {
195
+ // The overrun wake must not name a shell that already returned.
196
+ if (childId) deps.shells?.end(childId, start.task_id);
176
197
  }
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
198
  },
182
199
  };
183
200
  }
@@ -9,7 +9,7 @@
9
9
  * Driven by WorkIndex changes. Does not poll the manager.
10
10
  */
11
11
  import { realClock, type Clock, type ClockTimer } from "../clock";
12
- import { formatAge, type WorkIndex, type WorkItem } from "../work-index";
12
+ import { formatAge, nextAgeBoundary, type WorkIndex, type WorkItem } from "../work-index";
13
13
  import { truncateToWidth } from "../tui/pi-tui-load";
14
14
 
15
15
  export const FLEET_WIDGET_KEY = "pi-famulus-fleet";
@@ -72,6 +72,7 @@ export class FleetWidget {
72
72
  private tui: FleetTui | null = null;
73
73
  private unsubscribe: (() => void) | null = null;
74
74
  private ageTimer: ClockTimer | null = null;
75
+ private ageTimerIsTimeout = false;
75
76
  private readonly clock: Clock;
76
77
 
77
78
  constructor(deps: FleetStatusDeps) {
@@ -93,10 +94,11 @@ export class FleetWidget {
93
94
  const counts = this.deps.index.counts();
94
95
  const activeAgents = items.filter((item) => item.kind === "agent" && isActive(item));
95
96
  const total = counts.workers + counts.subagents + counts.monitors;
96
- if (activeAgents.length > 0 && !this.ageTimer) {
97
- this.ageTimer = this.clock.setInterval(() => this.tui?.requestRender(), 5000);
98
- this.clock.unref?.(this.ageTimer);
99
- } else if (activeAgents.length === 0) {
97
+ // Always reschedule: an item added while a timeout is pending may have
98
+ // an earlier boundary, and a removed item must not tick for stale state.
99
+ if (activeAgents.length > 0) {
100
+ this.startAgeTicker();
101
+ } else {
100
102
  this.clearAgeTimer();
101
103
  }
102
104
  if (total === 0) {
@@ -109,6 +111,11 @@ export class FleetWidget {
109
111
  (tui, theme) => {
110
112
  this.tui = tui;
111
113
  this.widgetRegistered = true;
114
+ // The ticker refuses to schedule while no component is mounted
115
+ // (refresh may have run before the host invoked the factory).
116
+ if (!this.ageTimer && this.deps.index.list(this.clock.now()).some((item) => item.kind === "agent" && isActive(item))) {
117
+ this.scheduleNextAgeTick();
118
+ }
112
119
  return {
113
120
  render: (width) => this.renderLine(width, theme),
114
121
  invalidate: () => {},
@@ -136,10 +143,43 @@ export class FleetWidget {
136
143
  if (ui) this.clearWidget(ui);
137
144
  }
138
145
 
146
+ /**
147
+ * Tick exactly when a displayed age changes. formatAge rounds
148
+ * (now - startedAt) to the nearest second, so each live item's visible
149
+ * value flips on half-second boundaries relative to its own startedAt —
150
+ * a global phase would be early/late for items started off the second.
151
+ * Schedule the earliest pending flip and re-evaluate after every tick.
152
+ */
153
+ private startAgeTicker(): void {
154
+ this.clearAgeTimer();
155
+ this.scheduleNextAgeTick();
156
+ }
157
+
158
+ private scheduleNextAgeTick(): void {
159
+ const now = this.clock.now();
160
+ const items = this.deps.index.list(now).filter((item) => item.kind === "agent" && isActive(item));
161
+ if (items.length === 0 || !this.tui) return;
162
+ let next = Infinity;
163
+ for (const item of items) {
164
+ next = Math.min(next, nextAgeBoundary(now, item.startedAt));
165
+ }
166
+ this.ageTimerIsTimeout = true;
167
+ this.ageTimer = this.clock.setTimeout(() => {
168
+ this.ageTimer = null;
169
+ this.ageTimerIsTimeout = false;
170
+ this.tui?.requestRender();
171
+ // Phases and the active set may have changed; schedule the next flip.
172
+ this.scheduleNextAgeTick();
173
+ }, next - now);
174
+ this.clock.unref?.(this.ageTimer);
175
+ }
176
+
139
177
  private clearAgeTimer(): void {
140
178
  if (!this.ageTimer) return;
141
- this.clock.clearInterval(this.ageTimer);
179
+ if (this.ageTimerIsTimeout) this.clock.clearTimeout(this.ageTimer);
180
+ else this.clock.clearInterval(this.ageTimer);
142
181
  this.ageTimer = null;
182
+ this.ageTimerIsTimeout = false;
143
183
  }
144
184
 
145
185
  private clearWidget(ui: FleetUi): void {
@@ -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,176 @@
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 { formatSubagentOverrun, 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[] } | 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 info = buildOverrunInfo(infoDeps, tick);
157
+ if (!info) return;
158
+ const shell = info.shell;
159
+ deps.logEvent("agent.overrun", {
160
+ child_id: info.childId,
161
+ run_id: info.runId,
162
+ reminder: info.reminder,
163
+ elapsed_ms: info.elapsedMs,
164
+ budget_ms: info.budgetMs,
165
+ ...(shell
166
+ ? {
167
+ shell_task_id: shell.taskId,
168
+ shell_elapsed_ms: shell.elapsedMs,
169
+ ...(shell.outputBytes !== null ? { output_bytes: shell.outputBytes } : {}),
170
+ ...(shell.growing !== null ? { growing: shell.growing } : {}),
171
+ }
172
+ : {}),
173
+ });
174
+ deps.notify(formatSubagentOverrun(info));
175
+ };
176
+ }
@@ -137,13 +137,13 @@ function resolveModel(deps: PiRuntimeDeps, req: ChildRunRequest): ResolvedModel
137
137
  if (!res.ok) throw new Error(modelResolutionError(req.model, res));
138
138
  const model = findModel(res.provider, res.id);
139
139
  if (!model) throw new Error(`model ${res.provider}/${res.id} is not usable in this session`);
140
- return { model, thinkingOverride: res.thinking };
140
+ return { model, thinkingOverride: res.thinking, warning: res.warning };
141
141
  }
142
142
  if (req.agent.model !== undefined) {
143
143
  const res = resolveModelSpec(req.agent.model, candidates);
144
144
  if (res.ok) {
145
145
  const model = findModel(res.provider, res.id);
146
- if (model) return { model, thinkingOverride: res.thinking };
146
+ if (model) return { model, thinkingOverride: res.thinking, warning: res.warning };
147
147
  }
148
148
  return {
149
149
  model: deps.getParentModel(),
@@ -161,9 +161,11 @@ function wrapSession(
161
161
  return {
162
162
  ...(extras.warning !== undefined ? { warning: extras.warning } : {}),
163
163
  ...(extras.resolvedModel !== undefined ? { resolvedModel: extras.resolvedModel } : {}),
164
- prompt: (text) => session.prompt(text),
165
- steer: (text) => session.steer(text),
166
- followUp: (text) => session.followUp(text),
164
+ // pi ≥0.99 returns QueuedInputDisposition ("handled" | "queued") from
165
+ // input methods; the adapter contract stays Promise<void>.
166
+ prompt: async (text) => { await session.prompt(text); },
167
+ steer: async (text) => { await session.steer(text); },
168
+ followUp: async (text) => { await session.followUp(text); },
167
169
  abort: () => session.abort(),
168
170
  waitForIdle: () => session.waitForIdle(),
169
171
  getLastAssistantText: () => session.getLastAssistantText(),