pi-famulus 0.1.9 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,314 @@
1
+ /**
2
+ * ChildSessionAdapter over a daemon-hosted `pi --mode rpc` process.
3
+ * The runner's timers stay; only the session lives in another process.
4
+ */
5
+ import { randomUUID } from "node:crypto";
6
+ import { mkdirSync, writeFileSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import type { ManagerClient, ManagerEvent } from "../manager-client";
9
+ import { parentResourceArgs, resolvePiArgv } from "../pi-command";
10
+ import { resolveChildModel, type PiRuntimeDeps } from "./pi-runtime";
11
+ import type {
12
+ ChildRunRequest,
13
+ ChildSessionAdapter,
14
+ ChildSessionEvent,
15
+ ChildTokenUsage,
16
+ CreateSessionFn,
17
+ } from "./types";
18
+
19
+ export interface HostedSessionDeps extends PiRuntimeDeps {
20
+ getClient: () => ManagerClient | null;
21
+ getHome: () => string;
22
+ /** This extension's entry file, passed to the child with -e. */
23
+ extensionEntry: string;
24
+ piCommand?: string | null;
25
+ trustProject?: boolean;
26
+ }
27
+
28
+ interface HostingView {
29
+ residency: string;
30
+ activity: string;
31
+ driver: string;
32
+ cacheExpiresAt?: number;
33
+ sessionFile?: string;
34
+ sessionId?: string;
35
+ }
36
+
37
+ export function createHostedSessionFn(deps: HostedSessionDeps): CreateSessionFn {
38
+ return async (req) => {
39
+ const client = deps.getClient();
40
+ if (!client || !client.isAvailable()) {
41
+ throw new Error("pi-famulus daemon is not connected; a subagent needs it to run pi --mode rpc.");
42
+ }
43
+ client.requireHostedProtocol();
44
+ const pi = resolvePiArgv(deps.piCommand);
45
+ if ("error" in pi) throw new Error(pi.error);
46
+ const model = resolveChildModel(deps, req);
47
+ const thinking =
48
+ model.thinkingOverride ?? req.agent.thinking ?? deps.getParentThinkingLevel() ?? "off";
49
+ const cwd = deps.getCwd();
50
+ const promptPath = join(deps.getHome(), "agents", "prompts");
51
+ mkdirSync(promptPath, { recursive: true });
52
+ const promptFile = join(promptPath, `${req.childId}.md`);
53
+ writeFileSync(promptFile, req.agent.systemPrompt);
54
+ const inherited = parentResourceArgs();
55
+ const extensionPaths = inherited.filter((_, i) => inherited[i - 1] === "-e");
56
+ const alreadyLoadsUs = extensionPaths.some((path) => path === deps.extensionEntry);
57
+ const trustInherited = inherited.some((arg) => arg === "-a" || arg === "--approve" || arg === "-na" || arg === "--no-approve");
58
+ const argv = [
59
+ ...pi.argv,
60
+ "--mode", "rpc",
61
+ "--session-id", req.childId,
62
+ "--name", `${req.agent.name}: ${req.name}`,
63
+ ...inherited,
64
+ ...(alreadyLoadsUs ? [] : ["-e", deps.extensionEntry]),
65
+ ...(model.model ? ["--model", `${model.model.provider}/${model.model.id}`] : []),
66
+ "--thinking", String(thinking),
67
+ "--append-system-prompt", promptFile,
68
+ ...(trustInherited ? [] : [deps.getProjectTrusted?.() ? "-a" : "-na"]),
69
+ ...(req.agent.tools ? ["--tools", hostedChildTools(req.agent.tools).join(",")] : []),
70
+ ];
71
+ const env = { ...process.env } as Record<string, string>;
72
+ if (req.agent.tools) env.PI_FAMULUS_CHILD_TOOLS = hostedChildTools(req.agent.tools).join(",");
73
+ env.PI_FAMULUS_CHILD_RUN = req.runId;
74
+ env.PI_FAMULUS_CHILD_NAME = req.name;
75
+ const spawned = await client.agentSpawn({
76
+ childId: req.childId,
77
+ runId: req.runId,
78
+ name: req.name,
79
+ argv,
80
+ cwd,
81
+ env,
82
+ });
83
+ const warning = model.warning;
84
+ await client.agentWatch(req.childId);
85
+ const session = new HostedRpcSession(client, req.childId, {
86
+ warning,
87
+ resolvedModel: model.model ? `${model.model.provider}/${model.model.id}` : undefined,
88
+ effectiveThinkingLevel: typeof thinking === "string" ? thinking : undefined,
89
+ sessionFile: typeof spawned.session_file === "string" ? spawned.session_file : undefined,
90
+ sessionId: typeof spawned.session_id === "string" ? spawned.session_id : req.childId,
91
+ });
92
+ return session;
93
+ };
94
+ }
95
+
96
+ class HostedRpcSession implements ChildSessionAdapter {
97
+ readonly warning?: string;
98
+ readonly resolvedModel?: string;
99
+ readonly effectiveThinkingLevel?: string;
100
+ private readonly listeners = new Set<(event: ChildSessionEvent) => void>();
101
+ private readonly unsubscribe: () => void;
102
+ private streaming = false;
103
+ private lastText = "";
104
+ private lastFailure: { stopReason: "error" | "aborted"; errorMessage?: string } | undefined;
105
+ private turns: { role: string; text: string }[] = [];
106
+ private usage: ChildTokenUsage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
107
+ private settle: (() => void) | null = null;
108
+ private hostingView: HostingView;
109
+ private disposed = false;
110
+
111
+ constructor(
112
+ private readonly client: ManagerClient,
113
+ private readonly childId: string,
114
+ init: { warning?: string; resolvedModel?: string; effectiveThinkingLevel?: string; sessionFile?: string; sessionId?: string },
115
+ ) {
116
+ this.warning = init.warning;
117
+ this.resolvedModel = init.resolvedModel;
118
+ this.effectiveThinkingLevel = init.effectiveThinkingLevel;
119
+ this.hostingView = {
120
+ residency: "warm",
121
+ activity: "starting",
122
+ driver: "parent",
123
+ sessionFile: init.sessionFile,
124
+ sessionId: init.sessionId,
125
+ };
126
+ this.unsubscribe = this.client.onEvent((event) => this.onManagerEvent(event as ManagerEvent & Record<string, unknown>));
127
+ }
128
+
129
+ hosting(): HostingView {
130
+ return { ...this.hostingView };
131
+ }
132
+
133
+ async prompt(text: string): Promise<void> {
134
+ const done = this.armSettle();
135
+ const op = this.streaming ? "prompt_steer" : "prompt";
136
+ await this.client.agentInput(this.childId, op, text, "parent");
137
+ if (!this.streaming) await done;
138
+ }
139
+
140
+ async steer(text: string): Promise<void> {
141
+ await this.client.agentInput(this.childId, "prompt_steer", text, "parent");
142
+ }
143
+
144
+ async followUp(text: string): Promise<void> {
145
+ await this.client.agentInput(this.childId, "prompt_follow", text, "parent");
146
+ }
147
+
148
+ async abort(): Promise<void> {
149
+ await this.client.agentInput(this.childId, "abort", "", "parent");
150
+ this.streaming = false;
151
+ this.settle?.();
152
+ this.settle = null;
153
+ }
154
+
155
+ async park(): Promise<void> {
156
+ await this.client.agentPark(this.childId);
157
+ this.hostingView.residency = "parked";
158
+ }
159
+
160
+ async waitForIdle(): Promise<void> {
161
+ if (!this.streaming) return;
162
+ await this.armSettle();
163
+ }
164
+
165
+ getLastAssistantText(): string | undefined {
166
+ return this.lastText || undefined;
167
+ }
168
+
169
+ getLastAssistantFailure(): { stopReason: "error" | "aborted"; errorMessage?: string } | undefined {
170
+ return this.lastFailure;
171
+ }
172
+
173
+ getConversation() {
174
+ return this.turns.map((turn) => ({ ...turn }));
175
+ }
176
+
177
+ tokenUsage(): ChildTokenUsage {
178
+ return { ...this.usage };
179
+ }
180
+
181
+ isStreaming(): boolean {
182
+ return this.streaming;
183
+ }
184
+
185
+ subscribe(listener: (event: ChildSessionEvent) => void): () => void {
186
+ this.listeners.add(listener);
187
+ return () => this.listeners.delete(listener);
188
+ }
189
+
190
+ dispose(): void {
191
+ if (this.disposed) return;
192
+ this.disposed = true;
193
+ this.unsubscribe();
194
+ this.settle?.();
195
+ this.settle = null;
196
+ }
197
+
198
+ private armSettle(): Promise<void> {
199
+ return new Promise((resolve) => {
200
+ this.settle = resolve;
201
+ });
202
+ }
203
+
204
+ private onManagerEvent(event: ManagerEvent & Record<string, unknown>): void {
205
+ if (event.child_id !== this.childId) return;
206
+ if (event.event === "agent_state") {
207
+ this.hostingView = {
208
+ residency: String(event.residency ?? this.hostingView.residency),
209
+ activity: String(event.activity ?? this.hostingView.activity),
210
+ driver: String(event.driver ?? this.hostingView.driver),
211
+ cacheExpiresAt: typeof event.cache_expires_at === "number" ? event.cache_expires_at : this.hostingView.cacheExpiresAt,
212
+ sessionFile: typeof event.session_file === "string" ? event.session_file : this.hostingView.sessionFile,
213
+ sessionId: typeof event.session_id === "string" ? event.session_id : this.hostingView.sessionId,
214
+ };
215
+ if (event.activity === "running") this.streaming = true;
216
+ if (event.activity === "idle" || event.activity === "failed") {
217
+ if (event.activity === "failed" && !this.lastFailure) {
218
+ this.lastFailure = { stopReason: "error", errorMessage: "hosted child process failed" };
219
+ }
220
+ this.streaming = false;
221
+ this.settle?.();
222
+ this.settle = null;
223
+ }
224
+ return;
225
+ }
226
+ if (event.event !== "agent_rpc" || !event.record || typeof event.record !== "object") return;
227
+ const record = event.record as {
228
+ type?: string;
229
+ message?: {
230
+ role?: string;
231
+ content?: { type?: string; text?: string }[];
232
+ usage?: Record<string, unknown>;
233
+ stopReason?: string;
234
+ errorMessage?: string;
235
+ };
236
+ toolCallId?: string;
237
+ toolName?: string;
238
+ args?: unknown;
239
+ isError?: boolean;
240
+ };
241
+ const type = record.type ?? "";
242
+ if (type === "agent_start") this.streaming = true;
243
+ if (type === "agent_settled") {
244
+ this.streaming = false;
245
+ this.settle?.();
246
+ this.settle = null;
247
+ }
248
+ if (type === "message_end" && record.message?.role === "assistant") {
249
+ const text = (record.message.content ?? []).map((part) => part.text ?? "").join("");
250
+ if (text) {
251
+ this.lastText = text;
252
+ this.turns.push({ role: "assistant", text });
253
+ }
254
+ const stopReason = record.message.stopReason;
255
+ if (stopReason === "error" || stopReason === "aborted") {
256
+ this.lastFailure = {
257
+ stopReason,
258
+ ...(record.message.errorMessage ? { errorMessage: record.message.errorMessage } : {}),
259
+ };
260
+ if (record.message.errorMessage) {
261
+ this.turns.push({ role: "assistant error", text: record.message.errorMessage });
262
+ }
263
+ } else {
264
+ this.lastFailure = undefined;
265
+ }
266
+ const usage = record.message.usage;
267
+ if (usage) {
268
+ this.usage.input += num(usage.input);
269
+ this.usage.output += num(usage.output);
270
+ this.usage.cacheRead += num(usage.cacheRead);
271
+ this.usage.cacheWrite += num(usage.cacheWrite);
272
+ }
273
+ }
274
+ if (type === "message_end" && record.message?.role === "user") {
275
+ const text = (record.message.content ?? []).map((part) => part.text ?? "").join("");
276
+ if (text) this.turns.push({ role: "user", text });
277
+ }
278
+ if (type === "tool_execution_end") {
279
+ const name = record.toolName ?? "tool";
280
+ const text = record.args === undefined ? name : `${name} ${JSON.stringify(record.args)}`;
281
+ this.turns.push({
282
+ role: record.isError ? `tool ${name} error` : `tool ${name}`,
283
+ text,
284
+ });
285
+ }
286
+ const adapted: ChildSessionEvent = {
287
+ type,
288
+ ...(record.message?.role ? { role: record.message.role } : {}),
289
+ ...(record.toolCallId ? { toolCallId: record.toolCallId } : {}),
290
+ ...(record.toolName ? { toolName: record.toolName } : {}),
291
+ ...(record.args !== undefined ? { args: record.args } : {}),
292
+ ...(record.isError !== undefined ? { isError: record.isError } : {}),
293
+ };
294
+ for (const listener of this.listeners) listener(adapted);
295
+ }
296
+ }
297
+
298
+ function num(value: unknown): number {
299
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
300
+ }
301
+
302
+ const SUPERVISOR_TOOLS = ["contact_supervisor", "agent_message"];
303
+
304
+ /** Agent allowlists still keep the two supervisor tools. */
305
+ export function hostedChildTools(tools: string[]): string[] {
306
+ const kept = new Set(tools);
307
+ for (const name of SUPERVISOR_TOOLS) kept.add(name);
308
+ return [...kept];
309
+ }
310
+
311
+ /** Request id for a child→parent decision. */
312
+ export function newCommsRequestId(): string {
313
+ return `rq_${randomUUID().slice(0, 8)}`;
314
+ }
@@ -38,6 +38,17 @@ export class ChildShellTracker {
38
38
  this.sizes.delete(taskId);
39
39
  }
40
40
 
41
+ /** A hosted child's shell is observed from the parent session's task_exited. */
42
+ endByTask(taskId: string): void {
43
+ for (const [childId, shell] of this.shells) {
44
+ if (shell.taskId === taskId) {
45
+ this.shells.delete(childId);
46
+ this.sizes.delete(taskId);
47
+ return;
48
+ }
49
+ }
50
+ }
51
+
41
52
  current(childId: string): ChildShell | undefined {
42
53
  return this.shells.get(childId);
43
54
  }
@@ -157,6 +157,10 @@ interface ResolvedModel {
157
157
  * falls back to the parent model with a warning (user-authored files must not
158
158
  * hard-fail across machines).
159
159
  */
160
+ export function resolveChildModel(deps: PiRuntimeDeps, req: ChildRunRequest): ResolvedModel {
161
+ return resolveModel(deps, req);
162
+ }
163
+
160
164
  function resolveModel(deps: PiRuntimeDeps, req: ChildRunRequest): ResolvedModel {
161
165
  const candidates = modelCandidates(deps);
162
166
  const findModel = (provider: string, id: string): Model | undefined =>
@@ -342,6 +342,84 @@ export class SubagentRegistry implements RunRegistry {
342
342
  // M3 extensions beyond Appendix B
343
343
  // -------------------------------------------------------------------------
344
344
 
345
+ /**
346
+ * Reconstruct a run/child the previous parent process wrote to disk
347
+ * so resume can reattach by child_id.
348
+ */
349
+ adoptExisting(info: {
350
+ runId: string;
351
+ childId: string;
352
+ name: string;
353
+ agent: string;
354
+ workKind?: string;
355
+ startedAt?: number;
356
+ prompt?: string;
357
+ }): void {
358
+ if (this.children.has(info.childId)) return;
359
+ let run = this.runs.get(info.runId);
360
+ if (!run) {
361
+ run = {
362
+ runId: info.runId,
363
+ kind: "tasks",
364
+ status: "interrupted",
365
+ createdAt: info.startedAt ?? this.now(),
366
+ children: [],
367
+ };
368
+ this.runs.set(info.runId, run);
369
+ }
370
+ const child: InternalChild = {
371
+ childId: info.childId,
372
+ runId: info.runId,
373
+ name: info.name,
374
+ agent: info.agent,
375
+ workKind: info.workKind ?? "other",
376
+ status: "interrupted",
377
+ startedAt: info.startedAt ?? this.now(),
378
+ endedAt: this.now(),
379
+ turn: 1,
380
+ queueMs: 0,
381
+ prompt: info.prompt,
382
+ };
383
+ run.children.push(child);
384
+ this.children.set(child.childId, child);
385
+ this.emit(run);
386
+ }
387
+
388
+ /** Bind a live hosted session to an adopted child without sending its first prompt. */
389
+ async attachChild(req: ChildRunRequest): Promise<ChildHandle> {
390
+ const child = this.children.get(req.childId);
391
+ if (!child) throw new Error(`unknown child ${req.childId} (adoptExisting first)`);
392
+ const runner = this.runner;
393
+ if (!runner?.attach) {
394
+ throw new Error(`subagent ${req.childId} has no live session to resume`);
395
+ }
396
+ child.prompt = req.taskPrompt ?? req.prompt;
397
+ child.preamble = req.agent.systemPrompt || undefined;
398
+ child.model = req.model ?? req.agent.model;
399
+ let handle: ChildHandle;
400
+ try {
401
+ handle = await runner.attach(req, (startingHandle) => {
402
+ child.handle = startingHandle;
403
+ });
404
+ } catch (error) {
405
+ const failed = child.handle as DisposableChildHandle | undefined;
406
+ child.handle = undefined;
407
+ await failed?.dispose?.();
408
+ throw error;
409
+ }
410
+ if (!this.children.has(child.childId)) return handle;
411
+ child.handle = handle;
412
+ handle.result.then((result) => this.settleChild(child.childId, result));
413
+ const resolved = handle.resolvedModel();
414
+ if (resolved) {
415
+ const thinking = handle.effectiveThinkingLevel?.();
416
+ child.model = `${resolved}${thinking ? `:${thinking}` : ""}`;
417
+ }
418
+ const run = this.runs.get(child.runId);
419
+ if (run) this.emit(run);
420
+ return handle;
421
+ }
422
+
345
423
  /** Allocate a pending child inside a run. Returns the child id. */
346
424
  addChild(runId: string, info: { name: string; agent: string; workKind?: string }): string {
347
425
  const run = this.runs.get(runId);
@@ -87,6 +87,8 @@ export interface InProcessRunnerOptions {
87
87
  stallRetryDelayMs?: number;
88
88
  /** Called on every stall detection (before any retry), with the 1-based attempt. */
89
89
  onStall?: (childId: string, attempt: number) => void;
90
+ /** Same tool name and arguments three times in a row. */
91
+ onToolLoop?: (childId: string, toolName: string, args: unknown) => void;
90
92
  /** Overrun reminder interval (ms). Default 10 minutes. */
91
93
  overrunRepeatMs?: number;
92
94
  /** Abort ceiling per user turn (ms). 0/absent = off: the soft deadline never aborts. */
@@ -99,10 +101,10 @@ export interface InProcessRunnerOptions {
99
101
  /** Shared time source and scheduler. */
100
102
  clock?: Clock;
101
103
  /**
102
- * Per-generation admission hook. Awaited before each (re)start; the
103
- * releaser gets terminal=false for a resumable interruption so its local
104
- * slot can be returned while the child-owned machine permit is retained.
105
- * Rejecting cancels the generation as {status:"interrupted", error}.
104
+ * Per-generation admission hook. Awaited before each (re)start. The
105
+ * releaser is called with terminal=true when the turn settles, including
106
+ * an interrupted turn: the machine permit is released and the next turn
107
+ * acquires again. Rejecting cancels the generation as {status:"interrupted", error}.
106
108
  */
107
109
  acquire?: (req: ChildRunRequest, ticket: AdmissionTicket) => Promise<(terminal?: boolean) => void>;
108
110
  /**
@@ -116,6 +118,20 @@ function errorMessage(err: unknown): string {
116
118
  return err instanceof Error ? err.message : String(err);
117
119
  }
118
120
 
121
+ /** 429 / rate-limit text is recorded separately from other model errors. */
122
+ export function classifyModelFailure(message: string): {
123
+ endReason: "model-error" | "rate-limit";
124
+ retryAfter?: number;
125
+ } {
126
+ const rateLimited = /\b429\b|rate[\s-]?limit|too many requests/i.test(message);
127
+ if (!rateLimited) return { endReason: "model-error" };
128
+ const retry = /retry[\s-]?after[:\s]+(\d+)/i.exec(message);
129
+ return {
130
+ endReason: "rate-limit",
131
+ ...(retry ? { retryAfter: Number(retry[1]) } : {}),
132
+ };
133
+ }
134
+
119
135
  export class InProcessRunner implements ChildRunner {
120
136
  private readonly opts: InProcessRunnerOptions;
121
137
 
@@ -129,6 +145,13 @@ export class InProcessRunner implements ChildRunner {
129
145
  await handle.launch();
130
146
  return handle;
131
147
  }
148
+
149
+ async attach(req: ChildRunRequest, onHandle?: (handle: ChildHandle) => void): Promise<DisposableChildHandle> {
150
+ const handle = new InProcessChildHandle(req, this.opts);
151
+ onHandle?.(handle);
152
+ await handle.attachIdle();
153
+ return handle;
154
+ }
132
155
  }
133
156
 
134
157
  class InProcessChildHandle implements DisposableChildHandle {
@@ -138,6 +161,9 @@ class InProcessChildHandle implements DisposableChildHandle {
138
161
  private readonly stallRetries: number;
139
162
  private readonly stallRetryDelayMs: number;
140
163
  private readonly onStall?: (childId: string, attempt: number) => void;
164
+ private readonly onToolLoop?: (childId: string, toolName: string, args: unknown) => void;
165
+ private lastToolKey: string | null = null;
166
+ private lastToolCount = 0;
141
167
  private readonly overrunRepeatMs: number;
142
168
  private readonly hardTimeoutMs: number;
143
169
  private readonly onOverrun?: (tick: OverrunTick) => void;
@@ -233,6 +259,7 @@ class InProcessChildHandle implements DisposableChildHandle {
233
259
  ? Math.max(0, Math.floor(rawDelay))
234
260
  : DEFAULT_STALL_RETRY_DELAY_MS;
235
261
  this.onStall = opts.onStall;
262
+ this.onToolLoop = opts.onToolLoop;
236
263
  const rawRepeat = opts.overrunRepeatMs ?? DEFAULT_OVERRUN_REPEAT_MS;
237
264
  this.overrunRepeatMs =
238
265
  Number.isFinite(rawRepeat) && rawRepeat > 0
@@ -331,6 +358,27 @@ class InProcessChildHandle implements DisposableChildHandle {
331
358
  await this.beginGeneration(this.req.prompt, true);
332
359
  }
333
360
 
361
+ /**
362
+ * Bind an already-hosted session so resume() can send the next turn.
363
+ * The previous parent died; this generation is interrupted, not prompted.
364
+ */
365
+ async attachIdle(): Promise<void> {
366
+ this.sessionCreation = this.createSession(this.req);
367
+ const created =
368
+ await this.sessionCreation;
369
+ this.sessionCreation = null;
370
+ if (this.disposed) return;
371
+ this.session = created;
372
+ this.resolvedModel_ = created.resolvedModel;
373
+ this.unsubscribe = this.session.subscribe((event) => this.handleSessionEvent(event));
374
+ this.settle(this.generation, {
375
+ status: "interrupted",
376
+ text: this.session.getLastAssistantText() ?? "",
377
+ error: "parent session ended",
378
+ durationMs: 0,
379
+ });
380
+ }
381
+
334
382
  async steer(message: string): Promise<void> {
335
383
  if (this.status_ !== "running" || !this.session) {
336
384
  throw new Error(`subagent ${this.req.childId} is not running (status: ${this.status_})`);
@@ -462,8 +510,17 @@ class InProcessChildHandle implements DisposableChildHandle {
462
510
  await this.beginGeneration(message, false, false, gen);
463
511
  }
464
512
 
513
+ hosting(): { residency?: string; driver?: string; cacheExpiresAt?: number; sessionFile?: string; sessionId?: string } | undefined {
514
+ const session = this.session as { hosting?: () => { residency?: string; driver?: string; cacheExpiresAt?: number; sessionFile?: string; sessionId?: string } } | null;
515
+ return session?.hosting?.();
516
+ }
517
+
465
518
  async interrupt(): Promise<void> {
466
- if (this.status_ !== "running" && this.status_ !== "pending") return;
519
+ if (this.status_ !== "running" && this.status_ !== "pending") {
520
+ const session = this.session as { park?: () => Promise<void> } | null;
521
+ if (session?.park) await session.park();
522
+ return;
523
+ }
467
524
  this.settle(this.generation, {
468
525
  status: "interrupted",
469
526
  // A pending turn has produced nothing yet; the last assistant text
@@ -471,11 +528,10 @@ class InProcessChildHandle implements DisposableChildHandle {
471
528
  text: this.status_ === "pending" ? "" : this.partialText(),
472
529
  durationMs: this.now() - this.startedAt,
473
530
  });
474
- try {
475
- await this.session?.abort();
476
- } catch {
477
- // abort is best-effort; the result is already settled
478
- }
531
+ // Resume may be accepted as soon as the interrupted result is visible.
532
+ // Drain this abort just like a timeout/stall abort before prompting again.
533
+ this.abortPromise = this.session ? this.session.abort().catch(() => {}) : null;
534
+ await this.abortPromise;
479
535
  }
480
536
 
481
537
  dispose(): void | Promise<void> {
@@ -563,8 +619,8 @@ class InProcessChildHandle implements DisposableChildHandle {
563
619
  signal: admissionAbort.signal,
564
620
  });
565
621
  if (this.disposed || this.isSettled(gen)) {
566
- // A queued interruption is resumable; only disposal is terminal.
567
- release(this.disposed);
622
+ // The turn is over (interrupted or disposed). The next turn acquires again.
623
+ release(true);
568
624
  return;
569
625
  }
570
626
  this.releaseSlot = release;
@@ -681,11 +737,14 @@ class InProcessChildHandle implements DisposableChildHandle {
681
737
  if (!this.isCurrent(gen)) return;
682
738
  const failure = session?.getLastAssistantFailure?.();
683
739
  if (failure) {
740
+ const error = failure.errorMessage?.trim() || `Model stopped with ${failure.stopReason}`;
741
+ const classified = classifyModelFailure(error);
684
742
  this.settle(gen, {
685
743
  status: "failed",
686
744
  text: this.partialText(),
687
- error: failure.errorMessage?.trim() || `Model stopped with ${failure.stopReason}`,
688
- endReason: "model-error",
745
+ error,
746
+ endReason: classified.endReason,
747
+ ...(classified.retryAfter !== undefined ? { retryAfter: classified.retryAfter } : {}),
689
748
  durationMs: this.now() - this.startedAt,
690
749
  });
691
750
  return;
@@ -756,8 +815,9 @@ class InProcessChildHandle implements DisposableChildHandle {
756
815
  result.warning = this.session.warning;
757
816
  }
758
817
  this.status_ = result.status;
759
- this.terminalSettle = result.status !== "interrupted" || result.error === "disposed";
760
- this.release(this.terminalSettle);
818
+ // Every settled turn releases its machine permit, including an
819
+ // interrupted one. The next turn (resume, send, user input) acquires again.
820
+ this.release(true);
761
821
  this.notifyActivity();
762
822
  this.resolveResult(result);
763
823
  }
@@ -861,7 +921,7 @@ class InProcessChildHandle implements DisposableChildHandle {
861
921
  this.toolStartedAt = null;
862
922
  }
863
923
 
864
- private handleSessionEvent(event: { type: string; role?: string; toolCallId?: string; usage?: { input: number; output: number } }): void {
924
+ private handleSessionEvent(event: { type: string; role?: string; toolCallId?: string; toolName?: string; args?: unknown; usage?: { input: number; output: number } }): void {
865
925
  const now = this.now();
866
926
  const active = this.status_ === "running";
867
927
  let currentToolEvent = false;
@@ -905,6 +965,7 @@ class InProcessChildHandle implements DisposableChildHandle {
905
965
  if (currentToolEvent) {
906
966
  this.toolDepth++;
907
967
  this.clearStall();
968
+ this.noteToolCall(event.toolName, event.args);
908
969
  }
909
970
  return;
910
971
  }
@@ -921,6 +982,22 @@ class InProcessChildHandle implements DisposableChildHandle {
921
982
  if (this.toolDepth === 0 && !this.decisionPaused) this.armStall(this.generation);
922
983
  }
923
984
 
985
+ private noteToolCall(toolName: string | undefined, args: unknown): void {
986
+ const name = toolName ?? "";
987
+ const key = `${name}\0${JSON.stringify(args ?? null)}`;
988
+ if (this.lastToolKey === key) this.lastToolCount += 1;
989
+ else {
990
+ this.lastToolKey = key;
991
+ this.lastToolCount = 1;
992
+ }
993
+ if (this.lastToolCount !== 3 || !name) return;
994
+ try {
995
+ this.onToolLoop?.(this.req.childId, name, args);
996
+ } catch {
997
+ // loop observers must not break the child
998
+ }
999
+ }
1000
+
924
1001
  private release(terminal = true): void {
925
1002
  const release = this.releaseSlot;
926
1003
  if (terminal) this.releaseSlot = null;