@staix/agent-hub 0.12.20 → 0.12.22

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.
Files changed (67) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +17 -9
  3. package/docs/agent-notes/adapters.md +13 -3
  4. package/docs/agent-notes/benchmarks.md +1 -1
  5. package/docs/agent-notes/bus.md +7 -3
  6. package/docs/agent-notes/daemon.md +12 -2
  7. package/docs/agent-notes/local-worker.md +5 -1
  8. package/docs/agent-notes/tasks.md +4 -2
  9. package/docs/agent-notes/tests.md +5 -2
  10. package/docs/bench.md +143 -0
  11. package/docs/events.md +15 -5
  12. package/docs/operations.md +510 -54
  13. package/docs/quickstart.md +16 -11
  14. package/docs/research.md +92 -0
  15. package/docs/security.md +49 -11
  16. package/docs/smoke.md +138 -0
  17. package/docs/specs/2026-09-19-agent-hub-design.md +227 -20
  18. package/docs/specs/2026-09-20-upgrade-recovery-design.md +152 -18
  19. package/docs/verified.json +34 -17
  20. package/package.json +1 -1
  21. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  22. package/plugins/agent-hub/server.js +3 -2
  23. package/src/adapters/acp.ts +77 -10
  24. package/src/adapters/codex-appserver.ts +72 -2
  25. package/src/adapters/local-worker.ts +46 -11
  26. package/src/adapters/pi.ts +215 -22
  27. package/src/cli/bench.ts +287 -0
  28. package/src/cli/console-state.ts +226 -28
  29. package/src/cli/console.ts +16 -4
  30. package/src/cli/facts-hook.ts +50 -8
  31. package/src/cli/help.ts +16 -5
  32. package/src/cli/identity.ts +10 -4
  33. package/src/cli/launch.ts +272 -18
  34. package/src/cli/main.ts +290 -30
  35. package/src/cli/preview.ts +13 -3
  36. package/src/cli/recovery-package.ts +17 -0
  37. package/src/cli/status-lines.ts +2 -1
  38. package/src/cli/terminal-recovery.ts +83 -31
  39. package/src/cli/upgrade-interactive.ts +297 -0
  40. package/src/cli/upgrade-runtime.ts +123 -20
  41. package/src/cli/upgrade.ts +108 -19
  42. package/src/hub/bench.ts +211 -0
  43. package/src/hub/bus.ts +24 -7
  44. package/src/hub/conductor.ts +22 -21
  45. package/src/hub/config-trust.ts +1 -0
  46. package/src/hub/control-client.ts +4 -1
  47. package/src/hub/daemon.ts +477 -86
  48. package/src/hub/delivery-journal.ts +6 -6
  49. package/src/hub/events.ts +4 -2
  50. package/src/hub/lifecycle.ts +8 -13
  51. package/src/hub/manager.ts +16 -14
  52. package/src/hub/peers.ts +2 -2
  53. package/src/hub/permission-mode.ts +44 -0
  54. package/src/hub/recovery-store.ts +59 -9
  55. package/src/hub/registry.ts +36 -22
  56. package/src/hub/research.ts +255 -0
  57. package/src/hub/tasks.ts +99 -55
  58. package/src/hub/tool-refusal.ts +7 -0
  59. package/src/hub/ui.ts +4 -1
  60. package/src/local/deny.ts +48 -1
  61. package/src/local/sandbox.ts +6 -3
  62. package/src/local/tools.ts +34 -11
  63. package/src/pi/extension.ts +43 -20
  64. package/src/pi/process-signature.ts +23 -0
  65. package/src/pi/tool-receipts.ts +24 -2
  66. package/src/ui/index.html +98 -17
  67. package/src/ui/task-progress.ts +33 -0
@@ -8,17 +8,30 @@ import { BasePeer } from "../hub/peers.ts";
8
8
  import { stopOwnedProcess, trackGroup } from "../hub/child-process.ts";
9
9
  import { processSignature } from "../pi/process-signature.ts";
10
10
  import { realPath } from "../hub/project.ts";
11
- import { toolResultFailed } from "../local/tools.ts";
11
+ import { ApprovalWaitStop, toolResultFailed, type ApprovalProvenance } from "../local/tools.ts";
12
12
  import { piToolStepCeiling, type PiToolStepCeiling } from "../pi/ceiling.ts";
13
13
  import type { ExecutionBudgetDecision, ExecutionUnit } from "../hub/execution-budget.ts";
14
14
 
15
15
  export interface PiModelDescriptor { id: string; name?: string; contextWindow?: number; maxTokens?: number; reasoning?: boolean; }
16
16
  export interface PiToolSchema { name: string; description?: string; parameters: Record<string, unknown>; }
17
17
  export interface PiRelay { url: string; token: string; models: PiModelDescriptor[]; }
18
+ export interface PiExit {
19
+ cause: "process_exit" | "spawn_error" | "session_shutdown" | "owner_lost" | "owner_stopped";
20
+ code: number | null;
21
+ signal: NodeJS.Signals | null;
22
+ expected: boolean;
23
+ started: boolean;
24
+ turnActive: boolean;
25
+ toolActive: boolean;
26
+ lastToolName?: string;
27
+ }
28
+ export interface PiToolContext { userBash: boolean; isCurrent: () => boolean; onApproval: (provenance: ApprovalProvenance) => void; }
29
+ export interface PiToolResult { text: string; exitCode: number | null; }
30
+ class PiToolSettlementFailure extends Error {}
18
31
  export interface PiOptions {
19
32
  cwd: string; stateDir: string; cmd?: string[]; mode: "headless" | "tui"; backend: "auto" | "dgx" | "mlx"; sessionFile?: string; sessionId?: string;
20
33
  model?: string;
21
- relay: PiRelay; executeTool: (name: string, args: unknown, toolCallId: string, sessionId?: string, signal?: AbortSignal) => Promise<string>; tools: PiToolSchema[];
34
+ relay: PiRelay; executeTool: (name: string, args: unknown, toolCallId: string, sessionId?: string, signal?: AbortSignal, context?: PiToolContext) => Promise<string | PiToolResult>; tools: PiToolSchema[];
22
35
  preamble?: string;
23
36
  selectModel?: (envs: Envelope[]) => Promise<string | undefined>; maxSteps?: number;
24
37
  /** Atomic task/run admission immediately before provider requests or tool execution. */
@@ -28,6 +41,9 @@ export interface PiOptions {
28
41
  /** `ceiling` is the validated, session/turn-bound tool-step ceiling signal (#179), present only when the trusted extension emitted one for this turn. */
29
42
  onTurnFailure?: (envs: Envelope[], reason: string, ceiling?: PiToolStepCeiling) => Promise<void>;
30
43
  watchdogMs?: number; log?: (line: string) => void;
44
+ /** Once per owner, after it is offline; a TUI owner may have unknown OS exit status. */
45
+ onExit?: (exit: PiExit) => void;
46
+ onApprovalStop?: (reason: string) => void;
31
47
  /** How long stop() waits for a graceful TUI owner exit before verified teardown. Tests shrink this. */
32
48
  stopGraceMs?: number;
33
49
  }
@@ -65,8 +81,17 @@ export class PiPeer extends BasePeer {
65
81
  private persistedOnce = false;
66
82
  private activeEnvs: Envelope[] = [];
67
83
  private activeTools = 0;
84
+ private readonly pendingToolGenerations = new Map<string, { generation: number; abort: AbortController; settled: Promise<void> }>();
85
+ private readonly abortedToolIds = new Set<string>();
86
+ private abortIdsOverflow = false;
68
87
  private readonly activeDeliveryIds = new Set<string>();
69
88
  private agentRunning = false;
89
+ private approvalExpiries = 0;
90
+ private approvalAnswerEpoch: number | undefined;
91
+ private approvalTurnAbort = false;
92
+ private approvalAbortSent = false;
93
+ private approvalStopReason = "";
94
+ private requestedStopReason: string | undefined;
70
95
  private owner = randomUUID();
71
96
  private _tuiLaunch?: PiTuiLaunch;
72
97
  private requestedModel = "";
@@ -74,6 +99,10 @@ export class PiPeer extends BasePeer {
74
99
  private ownerClaimed = false;
75
100
  private ownerToken = "";
76
101
  private stopping = true;
102
+ private exitReported = false;
103
+ private started = false;
104
+ private lastToolName?: string;
105
+ private shutdownExit?: PiExit;
77
106
  private ownerPid?: number;
78
107
  private ownerSignature?: string;
79
108
  private ownerMonitor?: ReturnType<typeof setInterval>;
@@ -164,11 +193,18 @@ export class PiPeer extends BasePeer {
164
193
  }
165
194
  private async handleToolRequest(body: any): Promise<Response> {
166
195
  if (this.stopping || this.state === "offline") return Response.json({ text: "error: Pi owner is stopped" }, { status: 409 });
196
+ if (!this.verifiedOwner() || typeof body.sessionId !== "string" || !body.sessionId || body.sessionId !== this.sessionId || !Number.isSafeInteger(body.generation) || body.generation !== this.budgetGeneration) {
197
+ const text = "error: Pi tool lineage is missing, stale or unverified; restart Pi with the current hub extension before retrying";
198
+ this.opts.log?.(`[${this.id}] ${text}`);
199
+ return Response.json({ text, failed: true }, { status: 409 });
200
+ }
201
+ if (this.abortIdsOverflow || this.abortedToolIds.has(String(body.toolCallId))) return Response.json({ text: "error: tool call cancelled before execution", failed: true });
202
+ if (this.pendingToolGenerations.has(String(body.toolCallId ?? ""))) return Response.json({ text: "error: this Pi tool call is already in progress", failed: true }, { status: 409 });
167
203
  let executionSignal = this.executionAbort?.signal;
168
204
  let idleBashTimer: ReturnType<typeof setTimeout> | undefined;
169
205
  if (body.purpose === "idle_user_bash") {
170
206
  const reservation = typeof body.reservation === "string" ? this.idleBashReservations.get(body.reservation) : undefined;
171
- if (!reservation || reservation.expiresAt < Date.now() || reservation.generation !== body.generation || !this.ownerClaimed || this.ownerPid === undefined || this.state !== "idle" || this.agentRunning || this.activeTools > 0 || this.activeEnvs.length > 0 || !Number.isSafeInteger(body.generation) || body.generation !== this.budgetGeneration) return Response.json({ text: "error: stale or non-idle Pi user shell request" }, { status: 409 });
207
+ if (this.opts.mode !== "tui" || !this.verifiedOwner() || !reservation || reservation.expiresAt < Date.now() || reservation.generation !== body.generation || !this.ownerClaimed || this.ownerPid === undefined || this.state !== "idle" || this.agentRunning || this.activeTools > 0 || this.activeEnvs.length > 0 || !Number.isSafeInteger(body.generation) || body.generation !== this.budgetGeneration) return Response.json({ text: "error: stale or non-idle Pi user shell request" }, { status: 409 });
172
208
  if (reservation.deadlineAt !== undefined && reservation.deadlineAt <= Date.now()) {
173
209
  this.idleBashReservations.delete(String(body.reservation));
174
210
  return Response.json({ text: "error: Pi user shell execution budget expired" }, { status: 409 });
@@ -179,17 +215,60 @@ export class PiPeer extends BasePeer {
179
215
  if (reservation.deadlineAt !== undefined) idleBashTimer = setTimeout(() => idleBashAbort.abort(), Math.max(0, reservation.deadlineAt - Date.now()));
180
216
  }
181
217
  this.noteActivity();
218
+ const generation = this.budgetGeneration;
219
+ const turnSignal = this.executionAbort?.signal;
220
+ const parentSignal = executionSignal;
221
+ const toolAbort = new AbortController();
222
+ const abortTool = () => toolAbort.abort();
223
+ parentSignal?.addEventListener("abort", abortTool, { once: true });
224
+ if (parentSignal?.aborted) abortTool();
225
+ executionSignal = toolAbort.signal;
226
+ const callId = String(body.toolCallId ?? "");
227
+ let settled!: () => void;
228
+ const settlement = new Promise<void>((resolve) => { settled = resolve; });
229
+ this.pendingToolGenerations.set(callId, { generation, abort: toolAbort, settled: settlement });
230
+ const name = String(body.name);
231
+ this.lastToolName = this.opts.tools.some((tool) => tool.name === name) && /^[a-zA-Z0-9_.-]{1,128}$/.test(name) ? name : "unknown";
182
232
  this.activeTools++;
183
233
  if (this.state === "idle") this.setState("busy");
184
234
  if (this.state === "busy") this.touch();
185
235
  // `failed` is the managed-tool failure verdict (toolResultFailed, the one contract): Pi 1.0.1
186
236
  // classifies a native tool result by isError === true alone, so the extension needs the flag,
187
237
  // not just the text (#181). An older extension simply ignores the extra field.
188
- try { const text = await this.opts.executeTool(String(body.name), body.args, String(body.toolCallId ?? ""), this.sessionId, executionSignal); return Response.json({ text, failed: toolResultFailed(String(body.name), text) }); }
238
+ try {
239
+ const result = await this.opts.executeTool(name, body.args, callId, this.sessionId, executionSignal, {
240
+ userBash: body.purpose === "idle_user_bash",
241
+ isCurrent: () => generation === this.budgetGeneration && !this.stopping && this.state !== "offline" && !executionSignal?.aborted && !this.approvalStopReason && this.verifiedOwner() && (body.purpose === "idle_user_bash" ? this.opts.mode === "tui" : turnSignal === this.executionAbort?.signal),
242
+ onApproval: (provenance) => {
243
+ if (!this.agentRunning || generation !== this.budgetGeneration || turnSignal !== this.executionAbort?.signal || this.approvalStopReason) return;
244
+ if (this.approvalAnswerEpoch !== provenance.answerEpoch) {
245
+ this.approvalExpiries = 0; this.approvalAnswerEpoch = provenance.answerEpoch;
246
+ }
247
+ if (provenance.source === "person") this.approvalExpiries = 0;
248
+ if (provenance.source === "expired" && provenance.eligibleExpiry !== false && ++this.approvalExpiries >= 2) {
249
+ this.approvalStopReason = "Pi turn stopped after two unanswered approvals; no person answered. Do not retry the calls; inspect prior work before handoff or stopping.";
250
+ try { this.opts.onApprovalStop?.(this.approvalStopReason); } catch { /* stop remains authoritative */ }
251
+ }
252
+ },
253
+ });
254
+ const text = typeof result === "string" ? result : result.text;
255
+ return Response.json({ text, failed: typeof result !== "string" && result.exitCode !== null ? result.exitCode !== 0 : toolResultFailed(name, text), ...(typeof result === "string" ? {} : { exitCode: result.exitCode }) });
256
+ }
189
257
  catch (error) { return Response.json({ text: `error: ${(error as Error).message}`, failed: true }, { status: 200 }); }
190
258
  finally {
191
259
  clearTimeout(idleBashTimer);
192
260
  this.activeTools--;
261
+ parentSignal?.removeEventListener("abort", abortTool);
262
+ if (this.pendingToolGenerations.get(callId)?.abort === toolAbort) this.pendingToolGenerations.delete(callId);
263
+ settled();
264
+ if (this.approvalStopReason && !this.approvalAbortSent) {
265
+ this.approvalAbortSent = true;
266
+ const reason = this.approvalStopReason;
267
+ // Finish this request's expiry reply before withdrawing siblings and aborting the native turn.
268
+ setTimeout(() => {
269
+ if (this.agentRunning && generation === this.budgetGeneration && this.approvalStopReason === reason && !this.stopping) void this.endApprovalTurn(generation, reason);
270
+ }, 10);
271
+ }
193
272
  if (this.state === "busy") {
194
273
  if (!this.activeTools && !this.agentRunning && !this.activeEnvs.length) this.setState("idle");
195
274
  else this.touch();
@@ -200,12 +279,14 @@ export class PiPeer extends BasePeer {
200
279
  private async handleBudgetRequest(body: any): Promise<Response> {
201
280
  if (!["model_calls", "tool_calls"].includes(String(body.unit))) return Response.json({ error: "invalid Pi budget unit" }, { status: 400 });
202
281
  const idleUserBash = body.idleUserBash === true;
203
- const current = Number.isSafeInteger(body.generation) && body.generation === this.budgetGeneration;
204
- const idleOwner = this.ownerClaimed && this.ownerPid !== undefined && this.state === "idle" && !this.agentRunning && this.activeTools === 0 && this.activeEnvs.length === 0;
205
- if (!current || (idleUserBash ? !idleOwner : !this.agentRunning || this.state !== "busy")) return Response.json({ error: "stale Pi budget request" }, { status: 409 });
282
+ const generation = body.generation, controller = this.executionAbort, session = this.sessionId, ownerPid = this.ownerPid, ownerSignature = this.ownerSignature;
283
+ const current = () => Number.isSafeInteger(generation) && generation === this.budgetGeneration && controller === this.executionAbort && session === this.sessionId && ownerPid === this.ownerPid && ownerSignature === this.ownerSignature && !this.stopping && this.verifiedOwner();
284
+ const idleOwner = this.opts.mode === "tui" && !this.stopping && this.verifiedOwner() && this.state === "idle" && !this.agentRunning && this.activeTools === 0 && this.activeEnvs.length === 0;
285
+ if (!current() || (idleUserBash ? !idleOwner : !this.agentRunning || this.state !== "busy")) return Response.json({ error: "stale Pi budget request" }, { status: 409 });
206
286
  // An interactive user shell has no task delivery. It may spend an eligible run budget only.
207
287
  const envs = idleUserBash ? [] : this.budgetEnvelopes;
208
288
  const decisions = this.opts.admitBudget ? await this.opts.admitBudget(envs, body.unit as ExecutionUnit) : [];
289
+ if (!current() || (idleUserBash ? this.state !== "idle" || this.agentRunning || this.activeTools > 0 || this.activeEnvs.length > 0 : !this.agentRunning || this.state !== "busy")) return Response.json({ error: "Pi owner or turn changed during admission" }, { status: 409 });
209
290
  const denied = decisions.find((decision) => !decision.allowed);
210
291
  const remaining = decisions.filter((decision) => decision.unit === "elapsed_ms" && decision.remaining !== null).reduce<number | undefined>((min, decision) => min === undefined ? decision.remaining! : Math.min(min, decision.remaining!), undefined);
211
292
  let reservation: string | undefined;
@@ -230,6 +311,10 @@ export class PiPeer extends BasePeer {
230
311
 
231
312
  private async startImpl(): Promise<void> {
232
313
  this.stopping = false;
314
+ this.requestedStopReason = undefined;
315
+ this.approvalTurnAbort = false;
316
+ this.exitReported = false; this.started = false; this.lastToolName = undefined; this.shutdownExit = undefined;
317
+ this.approvalExpiries = 0; this.approvalAnswerEpoch = undefined; this.approvalStopReason = ""; this.approvalAbortSent = false;
233
318
  mkdirSync(this.opts.stateDir, { recursive: true });
234
319
  const sessions = join(this.opts.stateDir, "pi-sessions");
235
320
  mkdirSync(sessions, { recursive: true, mode: 0o700 });
@@ -275,22 +360,65 @@ export class PiPeer extends BasePeer {
275
360
  trackGroup(this.proc);
276
361
  this.proc.stdout.on("data", (chunk) => this.onOutput(String(chunk)));
277
362
  this.proc.stderr.on("data", (chunk) => this.opts.log?.(`[${this.id}] ${String(chunk).trimEnd()}`));
278
- this.proc.on("error", (error) => this.fail(error));
279
- this.proc.on("exit", (code) => { if (code !== 0) this.fail(new Error(`pi exited with code ${code}`)); else this.setState("offline"); });
363
+ const proc = this.proc;
364
+ proc.on("error", (error) => {
365
+ const exit = this.exitMetadata("spawn_error");
366
+ this.fail(error);
367
+ this.reportExit(exit);
368
+ });
369
+ proc.on("exit", (code, signal) => {
370
+ const exit = { ...(this.shutdownExit ?? this.exitMetadata("process_exit")), code, signal };
371
+ this.fail(new Error(`pi exited with ${signal ? `signal ${signal}` : `code ${code}`}`));
372
+ this.reportExit(exit);
373
+ });
280
374
  const state = await this.waitRpc("get_state", 15_000);
281
375
  if (state.success !== true || typeof state.data?.sessionId !== "string" || typeof state.data?.sessionFile !== "string" || !state.data.sessionId || !state.data.sessionFile) throw new Error("Pi get_state did not prove session identity");
282
376
  if ((this.opts.sessionId && state.data.sessionId !== this.opts.sessionId) || (this.opts.sessionFile && state.data.sessionFile !== this.opts.sessionFile)) {
283
377
  throw new Error("Pi get_state session identity does not match the requested recovery session");
284
378
  }
285
379
  this.sessionId = state.data.sessionId; this.sessionFile = state.data.sessionFile;
380
+ // The hub spawned this headless owner and get_state proved its session. Pin the same
381
+ // identity a native extension claim uses, including fakes that do not load the extension.
382
+ if (!this.ownerClaimed) {
383
+ const pid = this.proc?.pid, signature = pid ? processSignature(pid) : undefined;
384
+ if (!pid || !signature) throw new Error("Pi headless owner process identity is not verified");
385
+ this.ownerPid = pid; this.ownerSignature = signature;
386
+ this.ownerToken = String(this._tuiLaunch?.env.AGENTHUB_PI_OWNER_TOKEN ?? ""); this.ownerClaimed = true;
387
+ }
286
388
  this.persistedOnce = existsSync(this.sessionFile);
287
389
  if ((this.opts.sessionId && this.sessionId !== this.opts.sessionId) || (this.opts.sessionFile && this.sessionFile !== this.opts.sessionFile)) throw new Error("Pi startup session identity mismatch");
390
+ this.started = true;
288
391
  this.setState("idle");
289
392
  }
290
393
 
291
- async stop(): Promise<void> {
292
- for (const id of this.activeDeliveryIds) this.delivery({ id, state: "needs_review", reason: "Pi session stopped before settlement" });
394
+ private verifiedOwner(): boolean {
395
+ return this.ownerClaimed && !!this.ownerPid && !!this.ownerSignature && processSignature(this.ownerPid) === this.ownerSignature;
396
+ }
397
+ private abortTools(): Promise<void>[] {
398
+ this.executionAbort?.abort();
399
+ this.idleBashReservations.clear();
400
+ const pending = [...this.pendingToolGenerations.values()];
401
+ for (const call of pending) call.abort.abort();
402
+ return pending.map((call) => call.settled);
403
+ }
404
+ get mode(): "headless" | "tui" { return this.opts.mode; }
405
+
406
+ async stop(reason?: string): Promise<void> {
407
+ if (reason !== undefined) this.requestedStopReason = reason;
408
+ this.stopping = true; // fence new calls before withdrawing both turn and idle-shell execution
409
+ for (const id of this.activeDeliveryIds) this.delivery({ id, state: "needs_review", reason: reason ?? "Pi session stopped before settlement" });
293
410
  this.activeDeliveryIds.clear();
411
+ const settling = this.abortTools();
412
+ let unsettled: unknown;
413
+ if (settling.length) {
414
+ let timer: ReturnType<typeof setTimeout> | undefined;
415
+ try {
416
+ await Promise.race([Promise.all(settling), new Promise<never>((_, reject) => { timer = setTimeout(() => reject(new PiToolSettlementFailure("Pi managed tools did not settle after cancellation")), this.opts.stopGraceMs ?? 5_000); })]);
417
+ } catch (error) { unsettled = error; } // a tool past its grace must not block the teardown (#253): stop reports it at the end
418
+ finally { clearTimeout(timer); }
419
+ }
420
+ const tuiExit = this.opts.mode === "tui" && this.ownerClaimed && this.ownerPid && this.ownerSignature
421
+ ? { ...this.exitMetadata("owner_stopped"), expected: true } : undefined;
294
422
  this.stopping = true;
295
423
  this.clearOwnerMonitor();
296
424
  if (this.opts.mode === "tui") {
@@ -329,6 +457,8 @@ export class PiPeer extends BasePeer {
329
457
  this.pending.clear();
330
458
  this.server?.stop(true); this.server = undefined; this.setState("offline");
331
459
  if (failed) throw failed;
460
+ if (tuiExit) this.reportExit(tuiExit); // the verified owner is gone; never invent an exit for an unclaimed launch
461
+ if (unsettled) throw unsettled;
332
462
  }
333
463
 
334
464
  /**
@@ -411,19 +541,40 @@ export class PiPeer extends BasePeer {
411
541
  }
412
542
 
413
543
  private handleBridgeEvent(event: any): void {
544
+ if (this.stopping && ["agent_start", "agent_end", "agent_settled"].includes(event.type)) return;
414
545
  if (event.type === "session_start") {
415
546
  if (!this.ownerClaimed) { this.ownerClaimed = true; this.ownerToken = String(event.ownerToken ?? ""); }
547
+ this.approvalTurnAbort = event.approvalTurnAbort === true;
416
548
  this.sessionId = String(event.sessionId ?? ""); this.sessionFile = String(event.sessionFile ?? "");
417
549
  this.ownerPid = Number.isInteger(event.pid) ? event.pid : undefined; this.ownerSignature = typeof event.signature === "string" ? event.signature : undefined;
418
550
  if ((this.opts.sessionId && this.sessionId !== this.opts.sessionId) || (this.opts.sessionFile && this.sessionFile !== this.opts.sessionFile)) { this.opts.log?.(`[${this.id}] Pi session identity mismatch`); return; }
419
- this.startOwnerMonitor(); if (this.opts.mode === "tui") this.setState("idle");
551
+ this.startOwnerMonitor(); if (this.opts.mode === "tui") { this.started = true; this.setState("idle"); }
552
+ }
553
+ if (event.type === "session_shutdown") {
554
+ const exit = this.exitMetadata("session_shutdown");
555
+ this.shutdownExit ??= exit; // headless waits for the OS exit, retaining the pre-cleanup turn and stop facts
556
+ this.stopping = true; this.ownerClaimed = false; this.clearOwnerMonitor(); this.resolveTuiExit?.(); this.resolveTuiExit = undefined;
557
+ this.fail(new Error("Pi session shut down before settlement"));
558
+ if (this.opts.mode === "tui") this.reportExit(exit);
559
+ }
560
+ if (event.type === "tool_abort" && this.agentRunning && this.ownerClaimed && this.ownerPid && this.ownerSignature
561
+ && processSignature(this.ownerPid) === this.ownerSignature && event.sessionId === this.sessionId && event.generation === this.budgetGeneration
562
+ && typeof event.toolCallId === "string" && event.toolCallId.length > 0 && event.toolCallId.length <= 512) {
563
+ const pending = this.pendingToolGenerations.get(event.toolCallId);
564
+ if (pending && pending.generation === event.generation) pending.abort.abort();
565
+ else { // a native abort may arrive before its HTTP tool request; fence that call in this generation
566
+ // ponytail: 64 early aborts per turn; fence further calls rather than forget a cancellation.
567
+ // A native per-call acknowledgement would replace this bounded early-arrival cache.
568
+ if (this.abortedToolIds.size >= 64 && !this.abortedToolIds.has(event.toolCallId)) this.abortIdsOverflow = true;
569
+ else this.abortedToolIds.add(event.toolCallId);
570
+ }
420
571
  }
421
- if (event.type === "session_shutdown") { this.stopping = true; this.ownerClaimed = false; this.clearOwnerMonitor(); this.resolveTuiExit?.(); this.resolveTuiExit = undefined; this.fail(new Error("Pi session shut down before settlement")); }
422
572
  if (event.type === "agent_start") {
423
573
  const generation = Number.isSafeInteger(event.generation) ? event.generation : this.budgetGeneration + 1;
424
574
  if (generation <= this.budgetGeneration) return;
425
575
  this.modelStep = 0;
426
- this.usageSeen.clear(); this.idleBashReservations.clear(); this.ceilingStops.clear(); this.executionAbort = new AbortController(); this.budgetGeneration = generation; this.noteActivity(); this.agentRunning = true; this.setState("busy");
576
+ this.approvalExpiries = 0; this.approvalAnswerEpoch = undefined; this.approvalStopReason = ""; this.approvalAbortSent = false; this.abortedToolIds.clear(); this.abortIdsOverflow = false;
577
+ this.usageSeen.clear(); this.ceilingStops.clear(); this.abortTools(); this.executionAbort = new AbortController(); this.budgetGeneration = generation; this.noteActivity(); this.agentRunning = true; this.setState("busy");
427
578
  }
428
579
  // #179: the extension's tool-step ceiling signal. Only a schema-valid event bound to THIS session,
429
580
  // THIS turn generation and a running turn is stored; the first signal of a turn stands. Anything
@@ -457,7 +608,8 @@ export class PiPeer extends BasePeer {
457
608
  this.agentRunning = false;
458
609
  if (typeof event.text === "string" && event.text.trim()) this.settledText = event.text;
459
610
  const generation = Number.isSafeInteger(event.generation) ? event.generation : this.budgetGeneration;
460
- const text = this.settledText.trim(); const error = this.budgetStops.get(generation) ?? this.settledError; const cancelled = !error && this.settledCancelled;
611
+ const approvalStopReason = this.approvalStopReason;
612
+ const text = this.settledText.trim(); const error = approvalStopReason || this.budgetStops.get(generation) || this.settledError; const cancelled = !error && this.settledCancelled;
461
613
  const ceiling = this.ceilingStops.get(generation);
462
614
  this.budgetStops.delete(generation);
463
615
  this.ceilingStops.delete(generation);
@@ -467,12 +619,16 @@ export class PiPeer extends BasePeer {
467
619
  const reply = { inReplyTo: this.currentReply, to: replyAudience(this.activeEnvs) };
468
620
  if (cancelled) this.onMessage?.("Pi turn cancelled; inspect any partial effects before continuing.", reply);
469
621
  else if (error?.startsWith("execution budget")) this.onMessage?.(`Pi stopped at the execution budget: ${error}. Inspect partial work before continuing.`, reply);
622
+ else if (approvalStopReason) this.onMessage?.(approvalStopReason, reply);
470
623
  else if (error) void this.opts.onTurnFailure?.(this.activeEnvs, error, ceiling);
471
624
  else if (text) this.onMessage?.(text, reply);
472
- for (const id of this.activeDeliveryIds) this.delivery({ id, state: error || cancelled ? "needs_review" : "completed", ...(error || cancelled ? { reason: error || "Pi turn cancelled; partial effects are possible" } : {}) });
625
+ for (const id of this.activeDeliveryIds) this.delivery({ id, state: approvalStopReason ? "completed" : error || cancelled ? "needs_review" : "completed", ...(error || cancelled ? { reason: error || "Pi turn cancelled; partial effects are possible" } : {}) });
473
626
  this.activeDeliveryIds.clear();
474
- this.currentReply = undefined; this.activeEnvs = []; if (this.state === "busy" && !this.activeTools) this.setState("idle");
475
- this.executionAbort = undefined;
627
+ this.currentReply = undefined; this.activeEnvs = [];
628
+ this.executionAbort?.abort(); this.executionAbort = undefined;
629
+ this.approvalExpiries = 0; this.approvalAnswerEpoch = undefined; this.approvalStopReason = ""; this.approvalAbortSent = false;
630
+ this.abortedToolIds.clear(); this.abortIdsOverflow = false;
631
+ if (this.state === "busy" && !this.activeTools) this.setState("idle");
476
632
  }
477
633
  }
478
634
 
@@ -507,15 +663,34 @@ export class PiPeer extends BasePeer {
507
663
  try { return await Promise.race([this.sendRpc({ type: command }), new Promise<never>((_, reject) => { timer = setTimeout(() => reject(new Error(`Pi ${command} timed out`)), timeout); })]); }
508
664
  finally { clearTimeout(timer); }
509
665
  }
666
+ private exitMetadata(cause: PiExit["cause"]): PiExit {
667
+ return { cause, code: null, signal: null, expected: this.stopping, started: this.started,
668
+ turnActive: this.agentRunning || this.activeEnvs.length > 0, toolActive: this.activeTools > 0,
669
+ ...(this.lastToolName ? { lastToolName: this.lastToolName } : {}) };
670
+ }
671
+
672
+ private reportExit(exit: PiExit): void {
673
+ if (this.exitReported) return;
674
+ this.exitReported = true;
675
+ const line = `[${this.id}] Pi exit: cause=${exit.cause} code=${exit.code ?? "unknown"} signal=${exit.signal ?? "unknown"} expected=${exit.expected} started=${exit.started} turnActive=${exit.turnActive} toolActive=${exit.toolActive} lastTool=${exit.lastToolName ?? "none"}`;
676
+ try { this.opts.log?.(line); } catch { /* reporting must not prevent owner settlement */ }
677
+ try { this.opts.onExit?.(exit); } catch { /* the owner is already offline */ }
678
+ }
679
+
510
680
  private fail(error: Error): void {
681
+ const approvalStopReason = this.approvalStopReason;
682
+ this.abortTools();
683
+ this.agentRunning = false;
684
+ this.approvalExpiries = 0; this.approvalAnswerEpoch = undefined; this.approvalStopReason = ""; this.approvalAbortSent = false;
685
+ this.abortedToolIds.clear(); this.abortIdsOverflow = false;
511
686
  for (const pending of this.pending.values()) pending.reject(error);
512
687
  this.pending.clear();
513
688
  const envs = this.activeEnvs; this.activeEnvs = [];
514
689
  for (const id of this.activeDeliveryIds) this.delivery({ id, state: "needs_review", reason: error.message });
515
690
  this.activeDeliveryIds.clear();
516
- if (envs.length) {
517
- this.onMessage?.("Pi turn failed; inspect its session and any partial effects before continuing.", { inReplyTo: this.currentReply });
518
- void this.opts.onTurnFailure?.(envs, error.message).catch(() => this.opts.log?.("Pi failure handoff could not be completed"));
691
+ if (envs.length && !this.requestedStopReason) {
692
+ this.onMessage?.(approvalStopReason ? `${approvalStopReason} Pi is offline because its extension could not abort the turn; restart with ahub pi and settle the held delivery after inspecting prior work.` : "Pi turn failed; inspect its session and any partial effects before continuing.", { inReplyTo: this.currentReply });
693
+ if (!approvalStopReason && !(error instanceof ApprovalWaitStop)) void this.opts.onTurnFailure?.(envs, error.message).catch(() => this.opts.log?.("Pi failure handoff could not be completed"));
519
694
  }
520
695
  this.currentReply = undefined;
521
696
  this.setState("offline");
@@ -525,15 +700,28 @@ export class PiPeer extends BasePeer {
525
700
  if (this.opts.mode !== "tui" || !this.ownerPid || !this.ownerSignature) return;
526
701
  this.ownerMonitor = setInterval(() => {
527
702
  if (!this.ownerPid || ownerStillAlive(this.ownerPid, this.ownerSignature)) return;
703
+ const exit = this.exitMetadata("owner_lost");
528
704
  this.stopping = true; this.ownerClaimed = false; this.clearOwnerMonitor();
529
705
  this.resolveTuiExit?.(); this.resolveTuiExit = undefined;
530
706
  for (const pending of this.tuiCommands.values()) pending.reject(new Error("Pi owner exited"));
531
707
  this.tuiCommands.clear(); this.tuiQueue = [];
532
708
  this.fail(new Error("Pi owner process exited without session_shutdown"));
709
+ this.reportExit(exit);
533
710
  }, 500);
534
711
  this.ownerMonitor.unref?.();
535
712
  }
536
713
  private clearOwnerMonitor(): void { if (this.ownerMonitor) clearInterval(this.ownerMonitor); this.ownerMonitor = undefined; }
714
+ private async endApprovalTurn(generation: number, reason: string): Promise<void> {
715
+ if (!this.agentRunning || generation !== this.budgetGeneration || this.approvalStopReason !== reason || this.stopping || this.state === "offline") return;
716
+ this.executionAbort?.abort();
717
+ if (this.approvalTurnAbort) {
718
+ try { await this.sendTui({ type: "abort_budget", generation, cause: "approval", reason }); return; }
719
+ catch { if (generation !== this.budgetGeneration || !this.agentRunning) return; }
720
+ }
721
+ this.opts.log?.(`[${this.id}] approval turn abort unsupported; Pi goes offline. Restart with ahub pi and settle the held delivery after inspecting prior work.`);
722
+ await this.terminateFailedTurn(new ApprovalWaitStop(reason));
723
+ }
724
+
537
725
  private async terminateFailedTurn(error: Error): Promise<void> {
538
726
  const envs = this.activeEnvs.slice(), reply = this.currentReply;
539
727
  this.stopping = true;
@@ -541,7 +729,12 @@ export class PiPeer extends BasePeer {
541
729
  await this.stop();
542
730
  // The process exit handler may already have reported this failure.
543
731
  if (this.activeEnvs.length) this.fail(error);
544
- } catch {
732
+ } catch (stopError) {
733
+ if (stopError instanceof PiToolSettlementFailure && this.state === "offline") {
734
+ this.opts.log?.(`Pi teardown completed with a managed-tool settlement failure: ${(stopError as Error).message}`);
735
+ if (this.activeEnvs.length) this.fail(error);
736
+ return;
737
+ }
545
738
  this.activeEnvs = envs; this.currentReply = reply;
546
739
  this.stopping = true;
547
740
  this.setState("busy");