@staix/agent-hub 0.12.21 → 0.12.23

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 (87) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +27 -12
  3. package/docs/agent-notes/adapters.md +15 -7
  4. package/docs/agent-notes/benchmarks.md +4 -1
  5. package/docs/agent-notes/budget.md +4 -0
  6. package/docs/agent-notes/bus.md +2 -1
  7. package/docs/agent-notes/daemon.md +12 -1
  8. package/docs/agent-notes/local-worker.md +4 -1
  9. package/docs/agent-notes/tasks.md +2 -1
  10. package/docs/agent-notes/tests.md +4 -0
  11. package/docs/events.md +17 -4
  12. package/docs/operations.md +478 -41
  13. package/docs/quickstart.md +14 -6
  14. package/docs/security.md +23 -11
  15. package/docs/smoke.md +133 -7
  16. package/docs/specs/2026-09-19-agent-hub-design.md +115 -15
  17. package/docs/specs/2026-09-20-upgrade-recovery-design.md +84 -8
  18. package/docs/verification/2026-10-10-cli-output-phase1.md +309 -0
  19. package/docs/verification/2026-10-10-cli-output-phase2.md +672 -0
  20. package/docs/verification/2026-10-11-0.12.23.md +90 -0
  21. package/docs/verified.json +32 -17
  22. package/package.json +1 -1
  23. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  24. package/plugins/agent-hub/server.js +14 -3
  25. package/src/adapters/acp-quota.ts +13 -0
  26. package/src/adapters/acp.ts +105 -17
  27. package/src/adapters/codex-appserver.ts +70 -35
  28. package/src/adapters/local-worker.ts +44 -10
  29. package/src/adapters/pi.ts +158 -19
  30. package/src/cli/bench.ts +2 -1
  31. package/src/cli/console-state.ts +28 -22
  32. package/src/cli/console.ts +20 -10
  33. package/src/cli/help.ts +25 -19
  34. package/src/cli/identity.ts +3 -3
  35. package/src/cli/init.ts +1 -1
  36. package/src/cli/launch.ts +182 -7
  37. package/src/cli/main.ts +409 -177
  38. package/src/cli/output.ts +363 -0
  39. package/src/cli/preview.ts +2 -1
  40. package/src/cli/recovery-package.ts +17 -0
  41. package/src/cli/reset.ts +3 -0
  42. package/src/cli/status-lines.ts +0 -28
  43. package/src/cli/terminal-recovery.ts +27 -1
  44. package/src/cli/upgrade-interactive.ts +297 -0
  45. package/src/cli/upgrade-runtime.ts +80 -9
  46. package/src/cli/upgrade.ts +50 -15
  47. package/src/hub/board.ts +2 -0
  48. package/src/hub/budget.ts +39 -7
  49. package/src/hub/bus.ts +15 -14
  50. package/src/hub/child-process.ts +11 -0
  51. package/src/hub/conductor.ts +23 -11
  52. package/src/hub/config-trust.ts +16 -5
  53. package/src/hub/conflicts.ts +2 -0
  54. package/src/hub/context-window.ts +8 -1
  55. package/src/hub/crash.ts +2 -1
  56. package/src/hub/daemon.ts +483 -83
  57. package/src/hub/db-timeout.ts +3 -0
  58. package/src/hub/delivery-journal.ts +2 -1
  59. package/src/hub/envelope.ts +1 -1
  60. package/src/hub/events.ts +4 -1
  61. package/src/hub/execution-budget.ts +2 -0
  62. package/src/hub/facts.ts +5 -4
  63. package/src/hub/git.ts +32 -0
  64. package/src/hub/hub-tools.ts +1 -1
  65. package/src/hub/lifecycle.ts +10 -4
  66. package/src/hub/manager.ts +18 -5
  67. package/src/hub/peers.ts +7 -5
  68. package/src/hub/permission-mode.ts +14 -0
  69. package/src/hub/project.ts +2 -1
  70. package/src/hub/recovery-error.ts +31 -0
  71. package/src/hub/recovery-store.ts +43 -4
  72. package/src/hub/registry.ts +2 -1
  73. package/src/hub/routing.ts +114 -13
  74. package/src/hub/settings.ts +392 -0
  75. package/src/hub/snapshots.ts +4 -2
  76. package/src/hub/start-mode.ts +48 -0
  77. package/src/hub/tasks.ts +158 -43
  78. package/src/hub/terminal-open.ts +64 -0
  79. package/src/hub/ui.ts +25 -13
  80. package/src/local/deny.ts +71 -2
  81. package/src/local/sandbox.ts +47 -6
  82. package/src/local/tools.ts +25 -6
  83. package/src/memory/recall.ts +2 -2
  84. package/src/models/mlx.ts +2 -1
  85. package/src/pi/extension.ts +43 -20
  86. package/src/pi/tool-receipts.ts +19 -2
  87. package/src/ui/index.html +172 -21
@@ -37,7 +37,7 @@ export interface CodexOptions {
37
37
  onItem?: (item: any, nativeTurn?: string) => void;
38
38
  /** How often to ask app-server for the rate limits while a TUI is attached. */
39
39
  usagePollMs?: number;
40
- /** How long a fact's steer waits for app-server's answer (tests shorten it). */
40
+ /** Facts wait from send; durable steers wait only after their original turn ends (default 10 s). */
41
41
  steerTimeoutMs?: number;
42
42
  cwd: string;
43
43
  /** Optional launch environment; recovery authority is always removed before spawn. */
@@ -63,9 +63,9 @@ const TRACKED = new Set(["thread/start", "thread/resume"]);
63
63
  */
64
64
  export class CodexPeer extends BasePeer {
65
65
  private permissionMode: PermissionMode;
66
- private nativeApprovalPolicy: unknown;
67
- private approvalOverridden = false;
68
- private readonly restoringApprovals = new Set<string | number>();
66
+ private readonly nativeApprovalPolicies = new Map<string, unknown>();
67
+ private readonly overriddenApprovals = new Set<string>();
68
+ private readonly restoringApprovals = new Map<string | number, string>();
69
69
  private proc: ChildProcess | undefined;
70
70
  private server: Server<Link> | undefined;
71
71
  private link: Link | undefined; // the connection that owns the current thread
@@ -84,7 +84,7 @@ export class CodexPeer extends BasePeer {
84
84
  private freshThread = false;
85
85
  private readonly activeTurns = new Set<string>();
86
86
  private nextId = -1;
87
- private readonly pending = new Map<number, { resolve: (result?: any) => void; reject: (e: Error) => void; deliveryId?: string; kind?: "deliver" | "steer" }>();
87
+ private readonly pending = new Map<number, { resolve: (result?: any) => void; reject: (e: Error) => void; deliveryId?: string; kind?: "deliver" | "steer"; expectedTurnId?: string; afterTurn?: () => void }>();
88
88
  private usageTimer: ReturnType<typeof setInterval> | undefined;
89
89
  private injected: Envelope | undefined; // the hub envelope that started the current turn, if any
90
90
  private answering: Envelope[] = []; // everything delivered or steered into the current turn: who its answer is for
@@ -191,7 +191,6 @@ export class CodexPeer extends BasePeer {
191
191
  this.pending.set(id, {
192
192
  deliveryId, kind: "deliver",
193
193
  resolve: (result) => {
194
- if (this.restoringApprovals.delete(id) && this.permissionMode === "ask") this.approvalOverridden = false;
195
194
  this.primed = true;
196
195
  this.injected = replyParent(envs);
197
196
  this.answering = [...envs];
@@ -229,12 +228,35 @@ export class CodexPeer extends BasePeer {
229
228
  return Promise.reject(new Error(`${this.id} has no steerable turn`));
230
229
  }
231
230
  const id = this.nextId--;
231
+ const expectedEpoch = this.contextEpoch;
232
232
  this.steers.add(id);
233
233
  return new Promise<void>((resolve, reject) => {
234
+ let timer: ReturnType<typeof setTimeout> | undefined;
235
+ const settle = () => { clearTimeout(timer); this.steers.delete(id); };
236
+ const fail = (e: Error) => {
237
+ settle();
238
+ if (deliveryId) this.delivery({ id: deliveryId, state: this.knownRejection(e) ? "failed_safe" : "needs_review", reason: e.message });
239
+ reject(e);
240
+ };
234
241
  this.pending.set(id, {
235
- deliveryId, kind: "steer",
236
- resolve: () => {
237
- this.steers.delete(id);
242
+ deliveryId, kind: "steer", expectedTurnId,
243
+ afterTurn: deliveryId ? () => {
244
+ if (timer !== undefined) return;
245
+ timer = setTimeout(() => {
246
+ const pending = this.pending.get(id);
247
+ this.pending.delete(id);
248
+ pending?.reject(new Error("steer unanswered: RPC timeout"));
249
+ }, this.opts.steerTimeoutMs ?? 10_000);
250
+ timer.unref?.();
251
+ } : undefined,
252
+ resolve: (result) => {
253
+ // Native completion can precede the RPC answer. A late refusal is definitive; a late acceptance is uncertain.
254
+ if (deliveryId && (this.link !== link || this.contextEpoch !== expectedEpoch || !this.activeTurns.has(expectedTurnId) ||
255
+ (typeof result?.turnId === "string" && result.turnId !== expectedTurnId))) {
256
+ fail(new Error("steer accepted after its original native turn ended or changed"));
257
+ return;
258
+ }
259
+ settle();
238
260
  this.primed = true;
239
261
  // The turn now answers these too; the highest hop wins so a steer cannot reset the hop cap.
240
262
  this.injected = replyParent(this.injected ? [this.injected, ...envs] : envs);
@@ -245,11 +267,7 @@ export class CodexPeer extends BasePeer {
245
267
  }
246
268
  resolve();
247
269
  },
248
- reject: (e) => {
249
- this.steers.delete(id);
250
- if (deliveryId) this.delivery({ id: deliveryId, state: this.knownRejection(e) ? "failed_safe" : "needs_review", reason: e.message });
251
- reject(e);
252
- },
270
+ reject: fail,
253
271
  });
254
272
  const input = [{ type: "text", text: this.render(envs) }];
255
273
  link.up.send(JSON.stringify({ method: "turn/steer", id, params: { threadId: this.threadId, expectedTurnId, input } }));
@@ -300,6 +318,16 @@ export class CodexPeer extends BasePeer {
300
318
  }
301
319
  }
302
320
 
321
+ /** An ended native turn bounds only its unanswered durable steers; legacy steers retain their immediate retry. */
322
+ private endSteers(turnId: string | undefined): void {
323
+ for (const id of this.steers) {
324
+ const pending = this.pending.get(id);
325
+ if (!pending || pending.expectedTurnId !== turnId) continue;
326
+ if (pending.deliveryId) pending.afterTurn?.();
327
+ else { this.pending.delete(id); pending.reject(new Error("steer unanswered: turn completed")); }
328
+ }
329
+ }
330
+
303
331
  private addTurnDelivery(turnId: string, deliveryId: string): void {
304
332
  const ids = this.turnDeliveries.get(turnId) ?? new Set<string>();
305
333
  ids.add(deliveryId); this.turnDeliveries.set(turnId, ids); this.unboundDeliveries.delete(deliveryId);
@@ -364,8 +392,8 @@ export class CodexPeer extends BasePeer {
364
392
  this.contextEpoch++;
365
393
  this.activeTurns.clear();
366
394
  for (const p of this.pending.values()) {
367
- if (p.deliveryId) this.delivery({ id: p.deliveryId, state: "needs_review", reason: "Codex TUI detached" });
368
- p.reject(new Error("codex TUI detached"));
395
+ if (p.deliveryId && p.kind !== "steer") this.delivery({ id: p.deliveryId, state: "needs_review", reason: "Codex TUI detached" });
396
+ p.reject(new Error(p.kind === "steer" ? "Codex TUI detached" : "codex TUI detached"));
369
397
  }
370
398
  this.pending.clear();
371
399
  for (const ids of this.turnDeliveries.values()) for (const id of ids) this.delivery({ id, state: "needs_review", reason: "Codex TUI detached" });
@@ -388,44 +416,50 @@ export class CodexPeer extends BasePeer {
388
416
 
389
417
  /** Reconcile the daemon's current choice after startup, before the returned proxy URL has a TUI. */
390
418
  setStartupPermissionMode(mode: PermissionMode): void {
391
- if (this.link || this.state !== "offline" || this.approvalOverridden) throw new Error("codex startup mode reconciliation requires a fresh unattached proxy");
419
+ if (this.link || this.state !== "offline" || this.overriddenApprovals.size) throw new Error("codex startup mode reconciliation requires a fresh unattached proxy");
392
420
  this.permissionMode = mode;
393
421
  }
394
422
 
395
- /**
396
- * The hub's choice was cleared while no TUI is attached: this proxy sends no override from now on. Returns false
397
- * when an override it already sent cannot be undone on that thread (its native policy was never reported): a thread
398
- * started or resumed through the proxy afterwards reports its own policy, and that one is left alone.
399
- */
423
+ /** Clear the overlay while detached, retaining each thread's restoration debt across a resume. */
400
424
  clearPermissionMode(): boolean {
401
- const restorable = !this.approvalOverridden || this.nativeApprovalPolicy !== undefined;
425
+ const restorable = [...this.overriddenApprovals].every(thread => this.nativeApprovalPolicies.has(thread));
402
426
  this.permissionMode = "ask";
403
427
  return restorable;
404
428
  }
405
429
 
406
430
  async setPermissionMode(mode: PermissionMode): Promise<void> {
407
431
  if (!this.link || this.link.up.readyState !== WebSocket.OPEN) throw new Error("codex permission mode unavailable: attach the TUI through ahub codex first");
408
- if (mode === "ask" && this.approvalOverridden && this.nativeApprovalPolicy === undefined) throw new Error("codex cannot restore ask: native approval policy unavailable; restart the Codex session");
432
+ if (mode === "ask" && [...this.overriddenApprovals].some(thread => !this.nativeApprovalPolicies.has(thread))) throw new Error("codex cannot restore ask: native approval policy unavailable; close the TUI, then run ahub permission codex ask before you restart the Codex session with ahub codex");
409
433
  this.permissionMode = mode;
410
434
  }
411
435
 
412
436
  private applyPermissionPolicy(params: Record<string, unknown>, id: string | number): Record<string, unknown> {
437
+ const thread = typeof params.threadId === "string" ? params.threadId : this.threadId;
413
438
  if (this.permissionMode !== "ask") {
414
- this.approvalOverridden = true;
439
+ this.overriddenApprovals.add(thread);
415
440
  return { ...params, approvalPolicy: this.permissionMode === "never-ask" ? "never" : "on-request" };
416
441
  }
417
- if (this.permissionMode === "ask" && this.approvalOverridden) {
418
- this.restoringApprovals.add(id);
419
- return { ...params, approvalPolicy: this.nativeApprovalPolicy };
442
+ if (this.overriddenApprovals.has(thread) && this.nativeApprovalPolicies.has(thread)) {
443
+ this.restoringApprovals.set(id, thread);
444
+ return { ...params, approvalPolicy: this.nativeApprovalPolicies.get(thread) };
420
445
  }
421
446
  return params;
422
447
  }
423
448
 
449
+ private settlePermissionRestore(id: string | number, failed: boolean): void {
450
+ const thread = this.restoringApprovals.get(id);
451
+ this.restoringApprovals.delete(id);
452
+ if (thread !== undefined && !failed && this.permissionMode === "ask") this.overriddenApprovals.delete(thread);
453
+ }
454
+
424
455
  private fromTui(link: Link, raw: string): void {
425
456
  const msg = parse(raw);
426
457
  if (msg?.method === "turn/start" && msg.params && link === this.link) {
427
- if (msg.params.approvalPolicy != null) this.nativeApprovalPolicy = msg.params.approvalPolicy;
428
- if (this.permissionMode !== "ask" || this.approvalOverridden) {
458
+ const thread = typeof msg.params.threadId === "string" ? msg.params.threadId : this.threadId;
459
+ // A resumed TUI can echo the sticky hub overlay. The restoring turn must use the kept baseline.
460
+ const restoring = this.overriddenApprovals.has(thread) && this.nativeApprovalPolicies.has(thread);
461
+ if (msg.params.approvalPolicy != null && !restoring) this.nativeApprovalPolicies.set(thread, msg.params.approvalPolicy);
462
+ if (this.permissionMode !== "ask" || this.overriddenApprovals.has(thread)) {
429
463
  // Native turn overrides persist: ask restores the captured native policy once on successful admission.
430
464
  msg.params = this.applyPermissionPolicy(msg.params, msg.id);
431
465
  raw = JSON.stringify(msg);
@@ -441,6 +475,7 @@ export class CodexPeer extends BasePeer {
441
475
  if (this.state === "busy") this.touch();
442
476
  if (!msg) return void link.tui.send(raw);
443
477
 
478
+ if (msg.id !== undefined && !msg.method) this.settlePermissionRestore(msg.id, !!msg.error);
444
479
  if (typeof msg.id === "number" && msg.id < 0 && !msg.method) {
445
480
  const p = this.pending.get(msg.id);
446
481
  this.pending.delete(msg.id);
@@ -448,14 +483,13 @@ export class CodexPeer extends BasePeer {
448
483
  else p?.resolve(msg.result);
449
484
  return; // ours: the TUI never asked for it
450
485
  }
451
- if (msg.id !== undefined && !msg.method && this.restoringApprovals.delete(msg.id) && !msg.error && this.permissionMode === "ask") this.approvalOverridden = false;
452
486
  if (typeof msg.result?.userAgent === "string") this.version = /^[^/\s]+\/(\d+\.\d+\.\d+(?:-[\w.]+)?)/.exec(msg.result.userAgent)?.[1] ?? this.version;
453
487
  const tracked = msg.id !== undefined && !msg.method ? link.tracked.get(msg.id) : undefined;
454
488
  if (tracked && link.tracked.delete(msg.id)) {
455
489
  if (typeof msg.result?.thread?.id === "string") {
456
- this.nativeApprovalPolicy = msg.result?.approvalPolicy;
457
- this.approvalOverridden = false;
458
- this.restoringApprovals.clear();
490
+ // A resumed response may report the sticky hub override, not the original native choice.
491
+ const thread = msg.result.thread.id;
492
+ if (!this.overriddenApprovals.has(thread) && msg.result.approvalPolicy != null) this.nativeApprovalPolicies.set(thread, msg.result.approvalPolicy);
459
493
  this.adopt(link, msg.result.thread.id, tracked === "thread/start");
460
494
  }
461
495
  }
@@ -465,6 +499,7 @@ export class CodexPeer extends BasePeer {
465
499
 
466
500
  private adopt(link: Link, threadId: unknown, fresh = false): void {
467
501
  if (typeof threadId !== "string" || !threadId) return;
502
+ this.abandonSteers("thread changed");
468
503
  this.tokenTotal = undefined;
469
504
  this.freshThread = fresh;
470
505
  this.link = link;
@@ -554,8 +589,8 @@ export class CodexPeer extends BasePeer {
554
589
  if (params.turn?.status === "failed") {
555
590
  this.opts.log?.(`[${this.id}] turn failed: ${params.turn.error?.message ?? "unknown error"}`);
556
591
  }
592
+ this.endSteers(nativeTurn);
557
593
  if (this.activeTurns.size) return;
558
- this.abandonSteers("turn completed");
559
594
  if (typeof nativeTurn === "string") {
560
595
  for (const id of this.turnDeliveries.get(nativeTurn) ?? []) this.delivery({ id, state: params.turn?.status === "failed" ? "needs_review" : "completed", ...(params.turn?.status === "failed" ? { reason: params.turn?.error?.message ?? "Codex turn failed" } : {}) });
561
596
  this.turnDeliveries.delete(nativeTurn);
@@ -8,7 +8,7 @@ import { renderDigest, replyAudience, replyParent, STANDING_INSTRUCTION, USER, t
8
8
  import { CONDUCTOR_TOOL_NAMES, CONDUCTOR_TOOLS, TASK_TOOL_NAMES, TASK_TOOLS } from "../hub/hub-tools.ts";
9
9
  import { BasePeer } from "../hub/peers.ts";
10
10
  import { profile, proxyEnv, type SandboxNetwork } from "../local/sandbox.ts";
11
- import { runTool, toolResultFailed, TOOL_SCHEMAS, touchedPaths, type ToolContext } from "../local/tools.ts";
11
+ import { runTool, toolResultFailed, TOOL_SCHEMAS, touchedPaths, ApprovalWaitStop, type ToolApproval, type ApprovalProvenance, type ToolContext } from "../local/tools.ts";
12
12
  import type { Capture } from "../memory/capture.ts";
13
13
  import type { ChatMessage, ChatResult, OmniRoute, ToolCall } from "../omniroute/client.ts";
14
14
  import { safeModelLabel } from "../omniroute/usage.ts";
@@ -32,6 +32,7 @@ export interface LocalOptions {
32
32
  onRouteOutcome?: (event: RouteLabelEvent) => void;
33
33
  /** The daemon turn id created synchronously on busy, shared with turn_start/end. */
34
34
  turnId?: () => string | undefined;
35
+ onApprovalStop?: (reason: string) => void;
35
36
  onTool?: (observation: ToolObservation, task?: string) => void;
36
37
  tools: { deny: string[]; permit: ToolContext["permit"]; bashNetwork?: SandboxNetwork; readAllow?: string[] };
37
38
  capture?: Capture;
@@ -85,6 +86,9 @@ export class LocalPeer extends BasePeer {
85
86
  private abort: AbortController | undefined;
86
87
  private budgetTimer?: ReturnType<typeof setTimeout>;
87
88
  private budgetStopReason = "";
89
+ private approvalExpiries = 0;
90
+ private approvalAnswerEpoch: number | undefined;
91
+ private approvalStopReason = "";
88
92
  private activeDeliveryId: string | undefined;
89
93
  private readonly sandboxProfile: string; // built once: profile() spawns git and must stay off the per-call path
90
94
  /** What served the last call, for `ahub status`. */
@@ -127,15 +131,15 @@ export class LocalPeer extends BasePeer {
127
131
  this.setState("idle");
128
132
  }
129
133
 
130
- async stop(): Promise<void> {
134
+ async stop(reason?: string): Promise<void> {
131
135
  this.endRouteTurn(this.labelTurnId, "failed");
132
- if (this.activeDeliveryId) this.delivery({ id: this.activeDeliveryId, state: "needs_review", reason: "turn stopped before settlement" });
136
+ if (this.activeDeliveryId) this.delivery({ id: this.activeDeliveryId, state: "needs_review", reason: reason ?? "turn stopped before settlement" });
133
137
  this.activeDeliveryId = undefined;
134
138
  this.turn++;
135
139
  clearTimeout(this.budgetTimer); this.budgetTimer = undefined;
136
140
  this.abort?.abort();
141
+ this.setState("offline"); // fence direct delivery before the bounded memory request awaits
137
142
  await this.opts.capture?.end();
138
- this.setState("offline");
139
143
  }
140
144
 
141
145
  /** Resolves once the turn is claimed; a turn that cannot reach any model hands the envelopes back through onFailed. */
@@ -146,6 +150,7 @@ export class LocalPeer extends BasePeer {
146
150
  }
147
151
  const turn = ++this.turn;
148
152
  this.budgetStopReason = "";
153
+ this.approvalExpiries = 0; this.approvalAnswerEpoch = undefined; this.approvalStopReason = "";
149
154
  clearTimeout(this.budgetTimer); this.budgetTimer = undefined;
150
155
  this.activeDeliveryId = deliveryId;
151
156
  this.abort = new AbortController();
@@ -172,6 +177,14 @@ export class LocalPeer extends BasePeer {
172
177
  })
173
178
  .catch((e: Error) => {
174
179
  if (turn !== this.turn) return; // aborted by the watchdog or stop(): nothing to report, nothing was committed
180
+ if (e instanceof ApprovalWaitStop) {
181
+ this.completeMissingToolResults(msgs, "not run: turn ended after unanswered approvals");
182
+ msgs.push({ role: "assistant", content: e.message });
183
+ if (!policy?.pii) this.commit(msgs);
184
+ this.onMessage?.(e.message, reply);
185
+ if (deliveryId && this.activeDeliveryId === deliveryId) this.delivery({ id: deliveryId, state: "completed", reason: e.message });
186
+ return; // even without effects, an unanswered approval must not trigger safe replay
187
+ }
175
188
  if (this.budgetStopReason && !(e instanceof ExecutionBudgetStop)) e = new ExecutionBudgetStop(this.budgetStopReason);
176
189
  this.opts.log?.(`[${this.id}] turn failed: ${e.message}`);
177
190
  if (e instanceof ExecutionBudgetStop) {
@@ -263,6 +276,7 @@ export class LocalPeer extends BasePeer {
263
276
  if (SIDE_EFFECTS.has(call.function.name) && !output.startsWith("error:")) progress.sideEffects++;
264
277
  const result: ChatMessage = { role: "tool", tool_call_id: call.id, content: output, is_error: toolResultFailed(name, output) };
265
278
  msgs.push(result); results.push(result);
279
+ if (this.approvalStopReason) { this.abort?.abort(); throw new ApprovalWaitStop(this.approvalStopReason); }
266
280
  this.observeTool(call, output, policy, `${this.sessionId}.${turn}.${step}`);
267
281
  // Hub tool arguments/results are not capture evidence; Tasks owns its screened memory writes.
268
282
  if (name !== "hub_send" && !TASK_TOOL_NAMES.has(name) && !CONDUCTOR_TOOL_NAMES.has(name)) capture?.observe({ tool: call.function.name, args: call.function.arguments, output, id: call.id, paths: touchedPaths(call.function.name, safeParse(call.function.arguments)) });
@@ -277,14 +291,34 @@ export class LocalPeer extends BasePeer {
277
291
  return {
278
292
  cwd: this.opts.cwd,
279
293
  deny: this.opts.tools.deny,
280
- permit: async (title, tool, _signal, path) => {
281
- if (turnSignal.aborted) return false;
282
- return new Promise<boolean>((resolve, reject) => {
283
- const finish = (allowed: boolean) => { turnSignal.removeEventListener("abort", onAbort); resolve(allowed); };
284
- const onAbort = () => finish(false);
294
+ permit: async (title, tool, _signal, canonicalTarget) => {
295
+ if (turnSignal.aborted) return "aborted";
296
+ const generation = this.turn;
297
+ let observed = false;
298
+ const observe = (provenance: ApprovalProvenance) => {
299
+ observed = true;
300
+ if (generation !== this.turn || turnSignal.aborted || this.approvalStopReason) return;
301
+ if (this.approvalAnswerEpoch !== provenance.answerEpoch) {
302
+ this.approvalExpiries = 0; this.approvalAnswerEpoch = provenance.answerEpoch;
303
+ }
304
+ if (provenance.source === "person") this.approvalExpiries = 0;
305
+ if (provenance.source === "expired" && provenance.eligibleExpiry !== false && ++this.approvalExpiries >= 2) {
306
+ this.approvalStopReason = "Local turn stopped after two unanswered approvals; no person answered. Do not retry the calls; inspect prior work before handoff or stopping.";
307
+ try { this.opts.onApprovalStop?.(this.approvalStopReason); } catch { /* stop remains authoritative */ }
308
+ }
309
+ };
310
+ const picked = await new Promise<ToolApproval>((resolve, reject) => {
311
+ const finish = (decision: ToolApproval) => { turnSignal.removeEventListener("abort", onAbort); resolve(decision); };
312
+ const onAbort = () => finish("aborted");
285
313
  turnSignal.addEventListener("abort", onAbort, { once: true });
286
- this.opts.tools.permit(title, tool, turnSignal, path).then(finish, (error) => { turnSignal.removeEventListener("abort", onAbort); reject(error); });
314
+ this.opts.tools.permit(title, tool, turnSignal, canonicalTarget, observe).then(finish, (error) => { turnSignal.removeEventListener("abort", onAbort); reject(error); });
287
315
  });
316
+ if (generation !== this.turn || (turnSignal.aborted && picked !== "expired")) return "aborted";
317
+ // Legacy permit callbacks can represent a person's denial without provenance. A true
318
+ // result may be automatic, so it must never reset the unanswered-request streak.
319
+ if (!observed && picked === false) observe({ source: "person", answerEpoch: (this.approvalAnswerEpoch ?? 0) + 1 });
320
+ if (!observed && picked === "expired") observe({ source: "expired", answerEpoch: this.approvalAnswerEpoch ?? 0, eligibleExpiry: true });
321
+ return picked;
288
322
  },
289
323
  sandboxProfile: this.sandboxProfile,
290
324
  sandboxEnv: { ...proxyEnv(this.opts.tools.bashNetwork ?? false), AGENTHUB_PEER_ID: this.id },