@staix/agent-hub 0.30.0 → 0.31.0

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 (125) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +13 -7
  3. package/docs/agent-notes/README.md +1 -0
  4. package/docs/agent-notes/adapters.md +5 -2
  5. package/docs/agent-notes/budget.md +1 -0
  6. package/docs/agent-notes/bus.md +9 -4
  7. package/docs/agent-notes/daemon.md +15 -8
  8. package/docs/agent-notes/local-worker.md +1 -0
  9. package/docs/agent-notes/tasks.md +7 -3
  10. package/docs/agent-notes/tests.md +6 -4
  11. package/docs/design/adapters.md +52 -0
  12. package/docs/design/agent-hub-architecture.md +46 -0
  13. package/docs/design/bus.md +139 -1
  14. package/docs/design/task-board.md +82 -0
  15. package/docs/dogfood.md +22 -4
  16. package/docs/events.md +3 -0
  17. package/docs/memory.md +17 -1
  18. package/docs/operations.md +307 -78
  19. package/docs/profiles.md +35 -0
  20. package/docs/quickstart.md +18 -5
  21. package/docs/security.md +154 -60
  22. package/docs/specs/2026-09-19-agent-hub-design.md +84 -14
  23. package/docs/specs/2026-09-19-multi-project-design.md +7 -2
  24. package/docs/verification/2026-11-04-1171-channel-client-evidence.md +31 -0
  25. package/docs/verification/2026-11-05-0.31.0.md +182 -0
  26. package/docs/verification/2026-11-05-claude-implementer-2.1.318.md +70 -0
  27. package/docs/verification/2026-11-05-hardcoded-values-classification-rules.md +257 -0
  28. package/docs/verification/2026-11-05-hardcoded-values-ratchet.md +55 -0
  29. package/docs/verification/2026-11-06-rooms-presence-cost.md +152 -0
  30. package/docs/verification/claude-shell-wrapper-native.py +1 -1
  31. package/docs/verified.json +14 -14
  32. package/package.json +3 -4
  33. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  34. package/plugins/agent-hub/server.js +4393 -3609
  35. package/src/adapters/acp.ts +5 -4
  36. package/src/adapters/channel-peer.ts +72 -3
  37. package/src/adapters/claude-channel-evidence.ts +52 -0
  38. package/src/adapters/claude-channel.ts +61 -18
  39. package/src/adapters/claude-implementer.ts +29 -7
  40. package/src/adapters/claude-print-child.ts +12 -2
  41. package/src/adapters/codex-appserver.ts +13 -12
  42. package/src/adapters/compat.ts +3 -3
  43. package/src/adapters/definitions/builtin-metadata.ts +13 -13
  44. package/src/adapters/definitions/qwen.ts +1 -0
  45. package/src/adapters/local-worker.ts +61 -18
  46. package/src/adapters/pi.ts +48 -6
  47. package/src/adapters/registry-types.ts +12 -0
  48. package/src/adapters/registry.ts +2 -0
  49. package/src/adapters/reviewer-print.ts +50 -11
  50. package/src/broker/server.ts +44 -13
  51. package/src/broker/store.ts +38 -8
  52. package/src/cli/answer-args.ts +50 -0
  53. package/src/cli/attention.ts +17 -2
  54. package/src/cli/backlog-return-args.ts +37 -0
  55. package/src/cli/console-question.ts +110 -0
  56. package/src/cli/console-state.ts +77 -16
  57. package/src/cli/console.ts +54 -1
  58. package/src/cli/handoff-args.ts +15 -5
  59. package/src/cli/handoff-output.ts +15 -2
  60. package/src/cli/help.ts +8 -5
  61. package/src/cli/identity.ts +13 -3
  62. package/src/cli/main.ts +128 -16
  63. package/src/cli/memory.ts +107 -3
  64. package/src/cli/output.ts +29 -6
  65. package/src/cli/pet.ts +4 -4
  66. package/src/cli/results-args.ts +30 -0
  67. package/src/cli/runtime-version-warning.ts +50 -0
  68. package/src/cli/status-lines.ts +41 -1
  69. package/src/cli/tail-render.ts +1 -0
  70. package/src/cli/upgrade-interactive.ts +14 -2
  71. package/src/hub/action-class.ts +9 -1
  72. package/src/hub/board.ts +369 -18
  73. package/src/hub/bus.ts +493 -53
  74. package/src/hub/completion-detail.ts +50 -0
  75. package/src/hub/completions-store.ts +168 -0
  76. package/src/hub/completions.ts +120 -0
  77. package/src/hub/config-apply.ts +7 -5
  78. package/src/hub/config-reload.ts +10 -1
  79. package/src/hub/control-client.ts +40 -4
  80. package/src/hub/daemon.ts +1569 -134
  81. package/src/hub/delivery-failure.ts +14 -0
  82. package/src/hub/delivery-hints.ts +14 -4
  83. package/src/hub/delivery-journal.ts +751 -42
  84. package/src/hub/delivery-superseded.ts +4 -1
  85. package/src/hub/envelope.ts +3 -3
  86. package/src/hub/events.ts +6 -0
  87. package/src/hub/handoff.ts +145 -6
  88. package/src/hub/hub-tools.ts +56 -7
  89. package/src/hub/manager.ts +59 -3
  90. package/src/hub/memory-policy.ts +44 -1
  91. package/src/hub/memory-sync-policy.ts +109 -0
  92. package/src/hub/memory-sync-state.ts +145 -0
  93. package/src/hub/no-writes.ts +1 -1
  94. package/src/hub/operational-settings.ts +11 -2
  95. package/src/hub/operational-view.ts +12 -1
  96. package/src/hub/peers.ts +25 -2
  97. package/src/hub/planning.ts +6 -1
  98. package/src/hub/question-desk.ts +201 -0
  99. package/src/hub/question-detach.ts +6 -0
  100. package/src/hub/questions.ts +260 -0
  101. package/src/hub/research-evidence.ts +106 -0
  102. package/src/hub/restart.ts +14 -0
  103. package/src/hub/review-anchor.ts +181 -0
  104. package/src/hub/review-brief.ts +13 -0
  105. package/src/hub/review-comments.ts +115 -0
  106. package/src/hub/review-findings.ts +7 -2
  107. package/src/hub/review-material.ts +76 -0
  108. package/src/hub/routing.ts +3 -1
  109. package/src/hub/task-sweep.ts +2 -0
  110. package/src/hub/tasks.ts +577 -63
  111. package/src/memory/capture.ts +5 -2
  112. package/src/pet/config.ts +25 -13
  113. package/src/rooms/link.ts +209 -21
  114. package/src/rooms/remote-peer.ts +186 -19
  115. package/src/rooms/service.ts +338 -22
  116. package/src/rooms/store.ts +437 -65
  117. package/src/rooms/wire.ts +24 -6
  118. package/src/ui/index.html +138 -7
  119. package/src/ui/needs.ts +10 -4
  120. package/src/ui/plugin-questions.ts +56 -0
  121. package/src/ui/plugin-results.ts +81 -0
  122. package/src/ui/plugin.ts +207 -27
  123. package/src/pet/window.html +0 -114
  124. package/src/pet/window.jxa.js +0 -122
  125. package/src/pet/window.ts +0 -154
@@ -2,6 +2,7 @@ import { spawn, type ChildProcess } from "node:child_process";
2
2
  import type { Server, ServerWebSocket } from "bun";
3
3
  import { renderDigest, replyAudience, replyParent, type Envelope, type PeerId } from "../hub/envelope.ts";
4
4
  import type { PermissionMode } from "../hub/permission-mode.ts";
5
+ import type { DeliveryFailureCause } from "../hub/delivery-failure.ts";
5
6
  import { BasePeer, type FrontendProxyPort } from "../hub/peers.ts";
6
7
  import { codexContext, type ContextReading } from "../hub/context-window.ts";
7
8
  import { peerChildEnv, stopOwnedProcess, trackGroup } from "../hub/child-process.ts";
@@ -135,7 +136,7 @@ export class CodexPeer extends BasePeer {
135
136
  }
136
137
  private unboundAccepted = false;
137
138
  private nextId = -1;
138
- private readonly pending = new Map<number, { resolve: (result?: any) => void; reject: (e: Error) => void; deliveryId?: string; kind?: "deliver" | "steer"; expectedTurnId?: string; afterTurn?: () => void; invalidate?: (reason: string) => void }>();
139
+ private readonly pending = new Map<number, { resolve: (result?: any) => void; reject: (e: Error, failureCause?: DeliveryFailureCause) => void; deliveryId?: string; kind?: "deliver" | "steer"; expectedTurnId?: string; afterTurn?: () => void; invalidate?: (reason: string, failureCause?: DeliveryFailureCause) => void }>();
139
140
  private usageTimer: ReturnType<typeof setInterval> | undefined;
140
141
  private injected: Envelope | undefined; // the hub envelope that started the current turn, if any
141
142
  private answering: Envelope[] = []; // everything delivered or steered into the current turn: who its answer is for
@@ -260,12 +261,12 @@ export class CodexPeer extends BasePeer {
260
261
  return new Promise<void>((resolve, reject) => {
261
262
  let invalidated = false;
262
263
  const retiredAtCancel = new Set<string>();
263
- const invalidate = (reason: string) => {
264
+ const invalidate = (reason: string, failureCause?: DeliveryFailureCause) => {
264
265
  if (invalidated) return;
265
266
  invalidated = true;
266
267
  for (const turn of [...this.activeTurns, ...this.turnDeliveries.keys()]) if (!turn.startsWith("unknown:")) retiredAtCancel.add(turn);
267
268
  envs = []; // only the late native-id callback survives, never the cancelled audience/prompt
268
- if (deliveryId) this.delivery({ id: deliveryId, state: "needs_review", reason });
269
+ if (deliveryId) this.delivery({ id: deliveryId, state: "needs_review", reason, ...(failureCause ? { failureCause } : {}) });
269
270
  reject(new Error(reason));
270
271
  };
271
272
  this.pending.set(id, {
@@ -330,9 +331,9 @@ export class CodexPeer extends BasePeer {
330
331
  return new Promise<void>((resolve, reject) => {
331
332
  let timer: ReturnType<typeof setTimeout> | undefined;
332
333
  const settle = () => { clearTimeout(timer); this.steers.delete(id); };
333
- const fail = (e: Error) => {
334
+ const fail = (e: Error, failureCause?: DeliveryFailureCause) => {
334
335
  settle();
335
- if (deliveryId) this.delivery({ id: deliveryId, state: this.knownRejection(e) ? "failed_safe" : "needs_review", reason: e.message });
336
+ if (deliveryId) this.delivery({ id: deliveryId, state: this.knownRejection(e) ? "failed_safe" : "needs_review", reason: e.message, ...(failureCause ? { failureCause } : {}) });
336
337
  reject(e);
337
338
  };
338
339
  this.pending.set(id, {
@@ -342,7 +343,7 @@ export class CodexPeer extends BasePeer {
342
343
  timer = setTimeout(() => {
343
344
  const pending = this.pending.get(id);
344
345
  this.pending.delete(id);
345
- pending?.reject(new Error("steer unanswered: RPC timeout"));
346
+ pending?.reject(new Error("steer unanswered: RPC timeout"), "timeout");
346
347
  }, this.opts.steerTimeoutMs ?? 10_000);
347
348
  timer.unref?.();
348
349
  } : undefined,
@@ -410,17 +411,17 @@ export class CodexPeer extends BasePeer {
410
411
  this.opts.log?.(UNIDENTIFIED_CANCELLATION_NOTICE);
411
412
  }
412
413
  for (const turnId of known) this.retireTurn(turnId);
413
- for (const pending of pendingStarts) pending.invalidate?.("Codex turn watchdog timeout before native acceptance was confirmed");
414
+ for (const pending of pendingStarts) pending.invalidate?.("Codex turn watchdog timeout before native acceptance was confirmed", "timeout");
414
415
  this.activeTurns.clear();
415
416
  this.unboundAccepted = false;
416
- for (const ids of this.turnDeliveries.values()) for (const id of ids) this.delivery({ id, state: "needs_review", reason: "Codex turn watchdog timeout" });
417
- for (const id of this.unboundDeliveries) this.delivery({ id, state: "needs_review", reason: "Codex turn watchdog timeout" });
417
+ for (const ids of this.turnDeliveries.values()) for (const id of ids) this.delivery({ id, state: "needs_review", reason: "Codex turn watchdog timeout", failureCause: "timeout" });
418
+ for (const id of this.unboundDeliveries) this.delivery({ id, state: "needs_review", reason: "Codex turn watchdog timeout", failureCause: "timeout" });
418
419
  this.turnDeliveries.clear(); this.unboundDeliveries.clear();
419
420
  this.injected = undefined;
420
421
  this.answering = [];
421
422
  this.lastAnswer = "";
422
423
  this.deltas.clear();
423
- this.abandonSteers("turn went silent");
424
+ this.abandonSteers("turn went silent", "timeout");
424
425
  if (this.unidentifiedCancellation) this.setState("offline");
425
426
  else super.onWatchdog();
426
427
  }
@@ -432,11 +433,11 @@ export class CodexPeer extends BasePeer {
432
433
  }
433
434
 
434
435
  /** A steer nobody answered must not vanish: rejecting it sends the envelope back to the queue. */
435
- private abandonSteers(reason: string): void {
436
+ private abandonSteers(reason: string, failureCause?: DeliveryFailureCause): void {
436
437
  for (const id of this.steers) {
437
438
  const p = this.pending.get(id);
438
439
  this.pending.delete(id);
439
- p?.reject(new Error(`steer unanswered: ${reason}`));
440
+ p?.reject(new Error(`steer unanswered: ${reason}`), failureCause);
440
441
  }
441
442
  }
442
443
 
@@ -34,11 +34,11 @@ export const COMPAT = {
34
34
  parse: out => plain(out.trim()),
35
35
  trustNote: "The model-free extension load, RPC session identity and managed tool receipt path were checked on 1.4.0 and 1.5.0. Permissions remain host-owned; account-backed inference and another version's extension contract are unverified.",
36
36
  receipt: "docs/verification/2026-11-03-pi-150-compat.md" },
37
- // Transport receipts: docs/verification/2026-10-23-headless-claude-permissions.md; 2.1.310: docs/verification/2026-10-26-claude-implementer-2.1.310.md; 2.1.314 (transport and wrapper): docs/verification/2026-10-31-claude-implementer-2.1.314.md; 2.1.316: docs/verification/2026-10-31-claude-implementer-2.1.316.md; host-wrapper receipt below repeats with them.
38
- "claude-implementer": { label: "Claude headless implementer", verified: ["2.1.309", "2.1.310", "2.1.314", "2.1.316"], gate: "contract", probe: ["claude", "--version"],
37
+ // Transport receipts: docs/verification/2026-10-23-headless-claude-permissions.md; 2.1.310: docs/verification/2026-10-26-claude-implementer-2.1.310.md; 2.1.314 (transport and wrapper): docs/verification/2026-10-31-claude-implementer-2.1.314.md; 2.1.316: docs/verification/2026-10-31-claude-implementer-2.1.316.md; 2.1.318: docs/verification/2026-11-05-claude-implementer-2.1.318.md; host-wrapper receipt below repeats with them.
38
+ "claude-implementer": { label: "Claude headless implementer", verified: ["2.1.309", "2.1.310", "2.1.314", "2.1.316", "2.1.318"], gate: "contract", probe: ["claude", "--version"],
39
39
  parse: out => plain(out.trim().replace(/ \(Claude Code\)$/, "")),
40
40
  trustNote: "The native prompt-tool transport and host-wrapper rewrite, environment isolation and replay contract have isolated receipts. No dontAsk/bypass fallback exists; another version's permission or rewrite behavior is unverified.",
41
- receipt: "docs/verification/2026-10-31-claude-implementer-2.1.316.md" },
41
+ receipt: "docs/verification/2026-11-05-claude-implementer-2.1.318.md" },
42
42
  // 0.163.0 receipt: docs/verification/2026-10-22-codex-reviewer-isolation.md; 0.164.0 receipt: docs/verification/2026-10-24-codex-0164-reviewer-isolation.md; 0.166.0: docs/verification/2026-10-31-codex-0166-reviewer-isolation.md
43
43
  "codex-review": { label: "Codex reviewer (codex-cli)", verified: ["0.146.0", "0.163.0", "0.164.0", "0.166.0"], gate: "isolation", probe: ["codex", "--version"], config: "codex_bin",
44
44
  parse: out => plain(out.trim().replace(/^codex-cli /, "")),
@@ -65,7 +65,7 @@ const CLAUDE_IMPLEMENTER_VARIANT: AdapterDefinition = {
65
65
  identity: { mcp: "env", currentChild: true }, instances: { mode: "headless", roles: ["implementer", "verifier"], modelSelect: true },
66
66
  permission: { grants: { ask: "every write, edit and shell command asks", "ask-when-needed": "file tools, proven read-only shell commands and bound task commands inside the owned worktree may run automatically; protected paths and other commands ask" } },
67
67
  session: { idField: "sessionId", currentId: sessionId("sessionId"), freshOnRestore: false, launchKind: "claude-implementer", crashPlan: () => ({ resume: {}, how: "Claude implementer resumes its recorded per-task sessions" }) },
68
- turnEnd: "adapter", budget: { account: "claude" },
68
+ turnEnd: "adapter", settle: { signal: "prompt-response" }, budget: { account: "claude" },
69
69
  cli: { command: "claude", startWords: () => ["claude"] }, display: CLAUDE_DISPLAY,
70
70
  };
71
71
 
@@ -74,7 +74,7 @@ const CLAUDE_IMPLEMENTER_VARIANT: AdapterDefinition = {
74
74
  export const NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly AdapterDefinition[] = Object.freeze([
75
75
  {
76
76
  projection: { roleRank: 0, helpRank: 0, plannerEligible: true },
77
- api: 1, kind: "claude", displayName: "Claude Code", family: "claude", transport: "claude-channel",
77
+ api: 1, kind: "claude", questionWaitMs: 45000, displayName: "Claude Code", family: "claude", transport: "claude-channel",
78
78
  roles: Object.freeze(["planner", "reviewer"]), defaultOnRoster: true, trust: { onPrem: false, hubSandboxed: false },
79
79
  start: { forms: Object.freeze(["tui"] as const), defaultForm: "tui", launch: claudeNativeLaunch, version: claudeObservedVersion, ...CLAUDE_LAUNCH_POLICY, conductorStartable: true, upStart: { allowed: true, default: true },
80
80
  unattended: true, hubSwitches: switches, ownedFlags: Object.freeze(["--dangerously-load-development-channels", "--dangerously-skip-permissions"]) },
@@ -84,27 +84,27 @@ export const NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly AdapterDefinition
84
84
  "ask-when-needed": "file tools on project files run without asking, outside .agenthub, .git and agent configuration; other tools keep Claude's own rules",
85
85
  "never-ask": "every tool runs without asking" } },
86
86
  session: { idField: "sessionId", currentId: sessionId("sessionId"), configDirEnv: "CLAUDE_CONFIG_DIR", zeroTurnFresh: true, launchKind: "claude-channel", launchRecord: true, ...CLAUDE_RECOVERY_SESSION, crashPlan: claudeCrashPlan, unrecordedTerminal: "always" },
87
- turnEnd: "native-hook", budget: { account: "claude", checkpoint: true, reader: { source: "claude status line", read: claudeWindows }, context: { source: "claude_statusline", read: claudeContext } },
87
+ turnEnd: "native-hook", settle: { signal: "native-stop" }, budget: { account: "claude", checkpoint: true, reader: { source: "claude status line", read: claudeWindows }, context: { source: "claude_statusline", read: claudeContext } },
88
88
  cli: { command: "claude", entry: { lifecycleAdmission: true, execute: runClaudeEntry }, preview: previewClaude, usage: [["ahub claude [--print-command] [--unattended] [args...]", "launch Claude Code with the hub channel"]], retainedTerminalNotice: claudeRetainedTerminalNotice, stopNotice: () => "this peer belongs to a native terminal; end it in its terminal", startWords: () => ["claude"] }, display: CLAUDE_DISPLAY, hooks: { recallPlatform: "claude", recallSource: "claude", recallInjected: true, pluginInventory: claudePluginInventory },
89
89
  reviewerVariant: CLAUDE_REVIEWER_VARIANT, headlessVariant: CLAUDE_IMPLEMENTER_VARIANT,
90
90
  },
91
91
  {
92
92
  reviewerVariant: CODEX_REVIEWER_VARIANT,
93
93
  projection: { roleRank: 1, startRank: 1, helpRank: 1, plannerEligible: true },
94
- api: 1, kind: "codex", displayName: "Codex", family: "codex", transport: "codex-appserver",
94
+ api: 1, kind: "codex", questionWaitMs: 45000, displayName: "Codex", family: "codex", transport: "codex-appserver",
95
95
  roles: Object.freeze(["implementer"]), defaultOnRoster: true, trust: { onPrem: false, hubSandboxed: false },
96
96
  start: { create: createCodex, daemonPort: "codex-appserver", forms: dualForms, defaultForm: "tui", launch: codexNativeLaunch, version: codexObservedVersion, ...CODEX_LAUNCH_POLICY, conductorStartable: true, upStart: { allowed: true, default: true },
97
97
  unattended: true, hubSwitches: switches, ownedFlags: Object.freeze(["--remote", "--remote-auth-token-env", "--yolo", "--dangerously-bypass-approvals-and-sandbox"]) },
98
98
  identity: { mcp: "env" }, instances: { mode: "headless" },
99
99
  permission: { trackUnattended: true, grants: { ask: "the session's own approval policy applies again", "ask-when-needed": "approval policy on-request on every turn", "never-ask": "approval policy never on every turn: nothing asks" } },
100
100
  session: { nativeRevert: true, idField: "threadId", currentId: sessionId("threadId"), configDirEnv: "CODEX_HOME", zeroTurnFresh: true, launchKind: "codex", launchRecord: true, ...CODEX_RECOVERY_SESSION, crashPlan: codexCrashPlan, unrecordedTerminal: "always" },
101
- turnEnd: "adapter", budget: { account: "codex", checkpoint: true, reader: { source: "codex rateLimits", read: codexWindows }, context: { source: "codex_token_usage", read: codexContext } },
101
+ turnEnd: "adapter", settle: { signal: "turn-completed" }, budget: { account: "codex", checkpoint: true, reader: { source: "codex rateLimits", read: codexWindows }, context: { source: "codex_token_usage", read: codexContext } },
102
102
  cli: { command: "codex", entry: { lifecycleAdmission: true, execute: runCodexEntry }, preview: previewCodex, usage: [["ahub codex [--print-command] [--unattended] [args...]", "start the Codex adapter and attach the TUI"]], launchConfigKeys: ["codex_bin"], retainedTerminalNotice: codexRetainedTerminalNotice, rosterReturnNotice: codexRosterReturnNotice, stopNotice: () => "this peer belongs to a native terminal; end it in its terminal", startWords: () => ["codex"] }, display: CODEX_DISPLAY, hooks: { recallPlatform: "codex", recallSource: "codex", recallInjected: true, pluginInventory: codexPluginInventory, classifyTool: codexToolSemantic },
103
103
  },
104
104
  {
105
105
  reviewerVariant: KIMI_REVIEWER_VARIANT,
106
106
  projection: { roleRank: 2, startRank: 2, helpRank: 2, plannerEligible: true },
107
- api: 1, kind: "kimi", displayName: "Kimi Code", family: "kimi", transport: "acp",
107
+ api: 1, kind: "kimi", questionWaitMs: 45000, displayName: "Kimi Code", family: "kimi", transport: "acp",
108
108
  roles: implementerRoles, defaultOnRoster: true, trust: { onPrem: false, hubSandboxed: false },
109
109
  start: { create: createKimi, acpCommand: kimiAcpCommand, explicitHeadlessMode: true, planCommand: () => "ahub kimi", daemonPort: "acp", forms: dualForms, defaultForm: "tui", conductorStartable: true, upStart: { allowed: true }, hubSwitches: [], ownedFlags: [] },
110
110
  // ACP sessions carry headless identity; the existing CLI alone owns the TUI's project MCP entry.
@@ -114,45 +114,45 @@ export const NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly AdapterDefinition
114
114
  permission: { grants: { ask: "Kimi asks as it does on its own", "ask-when-needed": "Kimi's yolo mode: it asks only where it judges it must", "never-ask": "Kimi's auto mode: nothing asks" },
115
115
  unmanagedForms: Object.freeze(["tui"] as const), unmanagedStatusNote: "Kimi approves tools in its own TUI; no hub permission mode", unmanagedNote: "Kimi approves tools and selects its permission mode in its own TUI; no hub permission mode" },
116
116
  session: { idField: "sessionId", currentId: sessionId("sessionId"), freshOnRestore: true, launchKind: "acp", ...KIMI_RECOVERY_SESSION, crashPlan: kimiCrashPlan },
117
- turnEnd: "adapter", budget: { account: "kimi", resetSoftTokensOnResume: true, context: { source: "acp_usage_update", read: acpContextValue } }, capabilities: { watch: true }, hooks: { recallPlatform: "kimi", recallSource: "kimi" },
117
+ turnEnd: "adapter", settle: { signal: "prompt-response" }, budget: { account: "kimi", resetSoftTokensOnResume: true, context: { source: "acp_usage_update", read: acpContextValue } }, capabilities: { watch: true }, hooks: { recallPlatform: "kimi", recallSource: "kimi" },
118
118
  cli: { command: "kimi", entry: { lifecycleAdmission: true, execute: runKimiEntry }, preview: previewKimi, usage: [["ahub kimi [--print-command] [--mode tui|headless] [--model <alias>]", "default: Kimi's own TUI, pull-only via hub_inbox; no push, steer, watchdog, console approvals or automatic quota handoff. Select headless for ACP"]], launchConfigKeys: ["kimi_cmd", "peers.kimi.start_mode"], stopNotice: form => form === "tui" ? "Kimi belongs to its own TUI terminal; end it in that terminal" : "", startWords: form => ["kimi", "--mode", form] }, display: KIMI_DISPLAY,
119
119
  },
120
120
  {
121
121
  projection: { roleRank: 4, startRank: 0, helpRank: 6, plannerEligible: false },
122
- api: 1, kind: "pi", title: "hub", titlePersistence: "native", enabledSwitchStartOnly: true, displayName: "Pi", family: "pi", transport: "hub-native",
122
+ api: 1, kind: "pi", questionWaitMs: 300000, title: "hub", titlePersistence: "native", enabledSwitchStartOnly: true, displayName: "Pi", family: "pi", transport: "hub-native",
123
123
  roles: implementerRoles, defaultOnRoster: false, trust: { onPrem: false, hubSandboxed: true },
124
124
  start: { create: createPi, exclusiveStartRefusal: "Pi start is in progress; inspect status before retrying", refreshTools: true, explicitHeadlessMode: true, daemonPort: "pi-rpc", forms: dualForms, defaultForm: "tui", conductorStartable: true, upStart: { allowed: true }, autoStart: "pi.auto_start", conductorStartArgs: piConductorStartArgs, hubSwitches: [], ownedFlags: [] },
125
125
  identity: { mcp: "env" }, instances: { mode: "headless" },
126
126
  // Approval enforcement belongs to the verified native transport and host permission gate.
127
127
  permission: { grants: { ...hubGrants } },
128
128
  session: { idField: "sessionId", currentId: sessionId("sessionId"), restoreOnStart: true, launchKind: "pi", launchRecord: true, ...PI_RECOVERY_SESSION, crashPlan: piCrashPlan, unrecordedTerminal: "tui", continuityBookmark: piRecoveryContinuity, continuity: { key: "pi", parse: parsePiContinuity } },
129
- turnEnd: "adapter", budget: { account: "pi", instrumented: true, noQuotaReason: "on-prem backends have no provider quota" }, reporting: { tokensNotReported: true }, configuration: { schema: piConfigSchema, routingSchema: piRoutingSchema, settings: PI_SETTINGS, restartReasons: PI_RESTART_REASONS, validateEffective: piEffectiveConfig },
129
+ turnEnd: "adapter", settle: { signal: "agent-settled" }, budget: { account: "pi", instrumented: true, noQuotaReason: "on-prem backends have no provider quota" }, reporting: { tokensNotReported: true }, configuration: { schema: piConfigSchema, routingSchema: piRoutingSchema, settings: PI_SETTINGS, restartReasons: PI_RESTART_REASONS, validateEffective: piEffectiveConfig },
130
130
  routing: { localAllowedRefusal: "local_allowed = false also excludes pi for this class", longContext: "backend-limit", preferredTier: true, handoffPriority: 0, trace: "backend", backendLabel: "pi" },
131
131
  cli: { command: "pi", entry: { lifecycleAdmission: false, execute: runPiEntry }, preview: previewPi, usage: [["ahub pi [--print-command] [--mode headless|tui | --headless] [--backend auto|dgx|mlx] [--model <alias>] [--session-id <id>] [--session-file <path>] [--replace-session]", "start Pi: its TUI in this terminal unless --headless or the start mode setting (peers.pi.start_mode) says headless; without a terminal the hub opens one or says how"]], launchConfigKeys: ["pi.cmd", "pi.backend", "pi.dgx_coding", "pi.dgx_fast", "pi.max_steps", "routing.pi", "peers.pi.start_mode"], previewArgs: ["--mode", "tui"], retainedTerminalNotice: piRetainedTerminalNotice, personOnlyFlags: ["--replace-session"], startWords: form => ["pi", "--mode", form] }, display: PI_DISPLAY,
132
132
  },
133
133
  {
134
134
  projection: { roleRank: 3, helpRank: 7, plannerEligible: false },
135
- api: 1, kind: "local", title: "hub", displayName: "Local worker", family: "local", transport: "hub-native",
135
+ api: 1, kind: "local", questionWaitMs: 300000, title: "hub", displayName: "Local worker", family: "local", transport: "hub-native",
136
136
  roles: implementerRoles, defaultOnRoster: true, trust: { onPrem: true, hubSandboxed: true },
137
137
  start: { create: createLocal, reconfigureWhileAttached: true, refreshTools: true, daemonPort: "local-worker", forms: headlessForms, defaultForm: "headless", conductorStartable: true, upStart: { allowed: true }, hubSwitches: [], ownedFlags: [] },
138
138
  identity: { mcp: "env" }, instances: { mode: "headless" },
139
139
  // Approval enforcement belongs to the verified native transport and host permission gate.
140
140
  permission: { grants: { ...hubGrants } },
141
141
  session: { idField: "sessionId", currentId: sessionId("sessionId"), freshOnRestore: true, launchKind: "local", ...LOCAL_RECOVERY_SESSION, crashPlan: localCrashPlan },
142
- turnEnd: "adapter", budget: { account: "local", quotaExempt: true, instrumented: true, noQuotaReason: "on-prem gateway has no provider quota" }, configuration: { schema: localConfigSchema, routingSchema: localRoutingSchema, restartReasons: LOCAL_RESTART_REASONS },
142
+ turnEnd: "adapter", settle: { signal: "route-outcome" }, budget: { account: "local", quotaExempt: true, instrumented: true, noQuotaReason: "on-prem gateway has no provider quota" }, configuration: { schema: localConfigSchema, routingSchema: localRoutingSchema, restartReasons: LOCAL_RESTART_REASONS },
143
143
  routing: { localAllowedRefusal: "local_allowed = false for this class", longContext: "skip-owner", preferredTier: true, handoffPriority: 1, trace: "route" },
144
144
  cli: { command: "local", entry: { lifecycleAdmission: false, execute: runLocalEntry }, usage: [["ahub local [--route <id> | --model <id>]", "start the hub-native worker on the self-hosted models (routing.toml)"]], launchConfigKeys: ["local.deny", "local.bash_network", "local.network_allow", "local.max_steps", "local.read_allow", "routing.local"], startWords: () => ["local"] }, display: LOCAL_DISPLAY,
145
145
  },
146
146
  {
147
147
  projection: { roleRank: 6, startRank: 3, helpRank: 3, plannerEligible: true },
148
- api: 1, kind: "jcode", displayName: "Jcode", family: "jcode", transport: "acp",
148
+ api: 1, kind: "jcode", questionWaitMs: 45000, displayName: "Jcode", family: "jcode", transport: "acp",
149
149
  roles: implementerRoles, optional: true, defaultOnRoster: false, trust: { onPrem: false, hubSandboxed: false },
150
150
  start: { create: createJcode, preflight: jcodeStartPreflight, daemonPort: "owned-acp", forms: dualForms, defaultForm: "tui", explicitHeadlessMode: true, conductorStartable: true, upStart: { allowed: true }, hubSwitches: [], ownedFlags: [] },
151
151
  // #685: an extra Jcode is a headless instance with its own native home, socket and state (instances.<id>.jcode_home).
152
152
  identity: { mcp: "project-file" }, instances: { mode: "headless", nativeHome: true },
153
153
  permission: { unmanaged: "Jcode native policy is unmanaged; no hub mode mapping", grants: {
154
154
  ask: "no verified hub mode mapping; native policy only", "ask-when-needed": "no verified hub mode mapping; native policy only", "never-ask": "no verified hub mode mapping; native policy only" } },
155
- session: { idField: "sessionId", currentId: sessionId("sessionId"), launchKind: "jcode", ...JCODE_RECOVERY_SESSION, crashPlan: jcodeCrashPlan }, turnEnd: "adapter", budget: { account: "jcode", context: { source: "jcode_usage_update", read: jcodeContextValue } },
155
+ session: { idField: "sessionId", currentId: sessionId("sessionId"), launchKind: "jcode", ...JCODE_RECOVERY_SESSION, crashPlan: jcodeCrashPlan }, turnEnd: "adapter", settle: { signal: "prompt-response" }, budget: { account: "jcode", context: { source: "jcode_usage_update", read: jcodeContextValue } },
156
156
  // Jcode has no dedicated CSS swatch today: retain peer-palette.ts's existing 'other' fallback.
157
157
  cli: { command: "jcode", entry: { lifecycleAdmission: true, execute: runJcodeEntry }, preview: previewJcode, usage: [["ahub jcode [--mode tui|headless] [--model <alias>] [--provider-profile <name>] [--session-id <id>] [--print-command]", "optional Jcode (roster.jcode, default off): its TUI on the hub-owned ACP session, opened once that session is saved; --mode headless for ACP only; native policy unmanaged, no hub sandbox"]], authority: "person", launchConfigKeys: ["jcode_cmd", "jcode_home", "jcode_provider_profile"], startWords: mode => ["jcode", "--mode", mode] }, display: JCODE_DISPLAY, hooks: { recallPlatform: "jcode" },
158
158
  },
@@ -71,6 +71,7 @@ export const QWEN_DEFINITION: AdapterDefinition = {
71
71
  },
72
72
  },
73
73
  turnEnd: "adapter",
74
+ settle: { signal: "message-stop" },
74
75
  budget: { account: "qwen" },
75
76
  capabilities: { watch: true },
76
77
  cli: { command: "qwen", entry: { lifecycleAdmission: true, execute: runQwenEntry }, preview: previewQwen, usage: [["ahub qwen [--mode headless|tui] [--model <alias>] [--session-id <id>] [--print-command]", `start optional Qwen Code ACP (${describe("qwen")}; roster.qwen, default off); --mode tui runs Qwen's own TUI in this terminal, driven through its dual-output files (${describe("qwen-dual")}); a hub deny ends the turn without an answer and a stalled turn is stopped with Esc in the Qwen terminal`]], launchConfigKeys: ["qwen_cmd"], startWords: () => ["qwen"] },
@@ -1,3 +1,4 @@
1
+ import { isReviewObserverExcluded, reviewCaptureExcluded } from "../hub/memory-policy.ts";
1
2
  import { HubRouteRuntime, type RouteEvent, type AdvisorEvent } from "../models/route/runtime.ts";
2
3
  import type { HubRoute } from "../models/route/config.ts";
3
4
  import type { RouteLabelEvent, RouteTurnOutcome } from "../models/route/labels.ts";
@@ -17,6 +18,7 @@ import type { ChatMessage, ChatResult, OmniRoute, ToolCall } from "../omniroute/
17
18
  import { safeModelLabel } from "../omniroute/usage.ts";
18
19
  import { effectiveLocalRoute } from "../hub/routing.ts";
19
20
  import type { ExecutionBudgetDecision } from "../hub/execution-budget.ts";
21
+ import type { OwnedInputContext, OwnedInputObserver } from "./claude-print-child.ts";
20
22
 
21
23
  export interface LocalOptions {
22
24
  profile?: import("../hub/profiles.ts").ProfileSelection;
@@ -36,6 +38,11 @@ export interface LocalOptions {
36
38
  onRoute?: (event: RouteEvent) => void;
37
39
  onAdvisor?: (event: AdvisorEvent) => void;
38
40
  onRouteOutcome?: (event: RouteLabelEvent) => void;
41
+ /**
42
+ * #1054: the worker ended its turn at a ceiling it enforces itself, as a closed fact and never its text: `steps` (it ran out of `maxSteps`, or the review asked for more at the last
43
+ * step) or `budget` (an execution budget stopped it). The daemon records it as the run's `limit` evidence; a turn that reports none is `done`.
44
+ */
45
+ onLimit?: (kind: "steps" | "budget") => void;
39
46
  /** The daemon turn id created synchronously on busy, shared with turn_start/end. */
40
47
  turnId?: () => string | undefined;
41
48
  onApprovalStop?: (reason: string) => void;
@@ -43,7 +50,11 @@ export interface LocalOptions {
43
50
  tools: { docRead?: DocReadPort; webFetch?: WebFetchPort; deny: string[]; permit: ToolContext["permit"]; shellExecutionCheck?: ToolContext["shellExecutionCheck"]; bashNetwork?: SandboxNetwork; readAllow?: string[]; readOnly?: string[] };
44
51
  capture?: Capture;
45
52
  /** Runs a hub task tool (hub_task_*, hub_review, hub_remember) as this peer. Absent = the tools are not offered. */
46
- taskTool?: (name: string, args: Record<string, unknown>, turn: { pii: boolean }) => Promise<string>;
53
+ taskTool?: (name: string, args: Record<string, unknown>, turn: { pii: boolean; signal?: AbortSignal }) => Promise<string>;
54
+ /** Final per-turn input binding after the Bus pre-handoff hold; never a native-session authority port. */
55
+ beforeInput?: OwnedInputObserver;
56
+ /** Actual local model handoff metadata; never a budget, native-session or task-authority port. */
57
+ inputReleased?: OwnedInputObserver;
47
58
  /** Successful provider responses only; usage may be absent when the gateway omits it. Never includes prompt data. */
48
59
  onUsage?: (record: { id: string; at: string; usage?: ChatResult["usage"]; requestedModel: string; servedModel?: string; provider?: string; task?: number }) => void;
49
60
  /** Atomic task/run admission immediately before every model request or tool execution. */
@@ -87,6 +98,8 @@ export class LocalPeer extends BasePeer {
87
98
  private title = "";
88
99
  get sessionTitle(): string { return this.title; }
89
100
  get titleSession(): string { return this.sessionId; }
101
+ /** Privacy-only identity for review-observer provenance; intentionally not PeerAdapter.nativeSession. */
102
+ get observerSessionId(): string { return this.sessionId; }
90
103
  async setSessionTitle(title: string, onlyIfUnnamed: boolean, current?: () => boolean): Promise<SessionTitleResult> {
91
104
  if (typeof title !== "string" || !title.trim() || [...title].length > 80 || typeof onlyIfUnnamed !== "boolean") return { ok: false, reason: "invalid", error: "Session title must contain 1-80 normalized characters and use a conditional-write flag; correct the title request and try again." };
92
105
  if (this.state === "offline") return { ok: false, reason: "invalid", error: "Local worker session is offline; start the peer and check ahub status before naming it." };
@@ -113,6 +126,8 @@ export class LocalPeer extends BasePeer {
113
126
  private approvalAnswerEpoch: number | undefined;
114
127
  private approvalStopReason = "";
115
128
  private activeDeliveryId: string | undefined;
129
+ private currentObserverInputContext: OwnedInputContext | undefined;
130
+ private observerInputReleasedId: string | undefined;
116
131
  private readonly sandboxProfile: string; // built once: profile() spawns git and must stay off the per-call path
117
132
  /** What served the last call, for `ahub status`. */
118
133
  lastServedBy = "";
@@ -130,6 +145,7 @@ export class LocalPeer extends BasePeer {
130
145
  if (generation !== this.turn) throw new Error("route belongs to an ended turn");
131
146
  if (signal.aborted) throw new Error("turn cancelled before model request");
132
147
  const tools = judge ? undefined : [...this.toolSchemas(), ...(this.opts.taskTool ? [...profileTaskTools(this.opts.profile, this.opts.sdlcStages, this.opts.sdlcCriteria).filter(tool => tool.name !== "hub_delivery_retry"), ...CONDUCTOR_TOOLS].concat(SESSION_TITLE_TOOL).map(asFunction) : [])];
148
+ this.releaseObserverInput(this.currentObserverInputContext, envs);
133
149
  const result = await this.opts.omni.chat({ model, messages, ...(tools ? { tools } : {}), ...(judge ? { max_tokens: maxTokens ?? 2048 } : {}) }, { signal, ...(pii ? { onCampusOnly: true } : {}) });
134
150
  this.recordUsage(result, model, task);
135
151
  if (generation !== this.turn) throw new Error("route belongs to an ended turn");
@@ -171,6 +187,14 @@ export class LocalPeer extends BasePeer {
171
187
  if (deliveryId) this.delivery({ id: deliveryId, state: "failed_safe", reason: `${this.id} is ${this.state}` });
172
188
  throw new Error(`${this.id} is ${this.state}`);
173
189
  }
190
+ const inputContext: OwnedInputContext = Object.freeze({ sessionId: this.observerSessionId, inputId: randomUUID() });
191
+ try { this.opts.beforeInput?.(inputContext, envs); }
192
+ catch {
193
+ if (deliveryId) this.delivery({ id: deliveryId, state: "failed_safe", reason: "review input provenance unavailable" });
194
+ throw new Error("review input provenance unavailable");
195
+ }
196
+ this.currentObserverInputContext = inputContext;
197
+ this.observerInputReleasedId = undefined;
174
198
  const turn = ++this.turn;
175
199
  this.budgetStopReason = "";
176
200
  this.approvalExpiries = 0; this.approvalAnswerEpoch = undefined; this.approvalStopReason = "";
@@ -186,16 +210,17 @@ export class LocalPeer extends BasePeer {
186
210
  const requestedPolicy = this.opts.turnPolicy?.(envs);
187
211
  const policy = envs.some(env => env.private) ? { ...requestedPolicy, pii: true } : requestedPolicy;
188
212
  const remoteTurn = envs.some(remoteEnvelope);
213
+ const reviewScope = { excluded: envs.some(isReviewObserverExcluded) };
189
214
  const preserveHistory = !policy?.pii && !remoteTurn;
190
215
  const labelTurn = this.beginRouteTurn(turn, !!policy?.pii || envs.some(env => env.private), policy?.task);
191
216
  let completed = false;
192
217
  // A PII turn speaks to the console user only, and privately: its answer must not be broadcast to cloud-hosted peers.
193
218
  // The task id travels with it, so the board can keep the text (`ahub task show`) while tail and log show a stub.
194
219
  const reply: EnvelopeOpts = { inReplyTo: replyParent(envs), to: replyAudience(envs), ...(policy?.pii ? { to: [USER], private: true, priority: "important" as const, ...(policy.task ? { refs: { task: policy.task } } : {}) } : {}) };
195
- this.run(envs, turn, msgs, progress, policy, reply)
220
+ this.run(envs, turn, msgs, progress, policy, reply, reviewScope)
196
221
  .then((answer) => {
197
222
  if (turn !== this.turn) return;
198
- if (preserveHistory) this.commit(msgs); // Private and remote turns never enter later local capture through model history.
223
+ if (preserveHistory && !reviewScope.excluded) this.commit(msgs); // Private, remote and review turns never enter later local capture through model history.
199
224
  if (answer) this.onMessage?.(answer, reply);
200
225
  if (deliveryId && this.activeDeliveryId === deliveryId) this.delivery({ id: deliveryId, state: "completed" });
201
226
  completed = true;
@@ -203,19 +228,19 @@ export class LocalPeer extends BasePeer {
203
228
  .catch((e: Error) => {
204
229
  if (turn !== this.turn) return; // aborted by the watchdog or stop(): nothing to report, nothing was committed
205
230
  if (e instanceof ApprovalWaitStop) {
206
- const message = remoteTurn ? "remote turn failed" : e.message;
231
+ const message = remoteTurn ? "remote turn failed" : reviewScope.excluded ? "review turn failed" : e.message;
207
232
  this.completeMissingToolResults(msgs, "not run: turn ended after unanswered approvals");
208
233
  msgs.push({ role: "assistant", content: message });
209
- if (preserveHistory) this.commit(msgs);
234
+ if (preserveHistory && !reviewScope.excluded) this.commit(msgs);
210
235
  this.onMessage?.(message, reply);
211
236
  if (deliveryId && this.activeDeliveryId === deliveryId) this.delivery({ id: deliveryId, state: "completed", reason: message });
212
237
  return; // even without effects, an unanswered approval must not trigger safe replay
213
238
  }
214
239
  if (this.budgetStopReason && !(e instanceof ExecutionBudgetStop)) e = new ExecutionBudgetStop(this.budgetStopReason);
215
- const message = remoteTurn ? "remote turn failed" : e.message;
240
+ const message = remoteTurn ? "remote turn failed" : reviewScope.excluded ? "review turn failed" : e.message;
216
241
  this.opts.log?.(`[${this.id}] turn failed: ${message}`);
217
242
  if (e instanceof ExecutionBudgetStop) {
218
- this.reportBudgetStop(e, msgs, progress, reply, !preserveHistory, deliveryId, remoteTurn);
243
+ this.reportBudgetStop(e, msgs, progress, reply, !preserveHistory || reviewScope.excluded, deliveryId, remoteTurn, reviewScope.excluded);
219
244
  return;
220
245
  }
221
246
  if (!progress.sideEffects) {
@@ -225,9 +250,9 @@ export class LocalPeer extends BasePeer {
225
250
  }
226
251
  // Tools already changed things. Redelivering would redo approved writes and commits, so report instead.
227
252
  // A model call is the only thing that throws here, and every tool call before it has its result: msgs is consistent.
228
- const note = remoteTurn ? message : `(turn failed after ${progress.sideEffects} tool call(s) with side effects: ${message.slice(0, 200)}. The work may be partial; check before repeating it.) ${progress.last}`.trim();
253
+ const note = remoteTurn || reviewScope.excluded ? message : `(turn failed after ${progress.sideEffects} tool call(s) with side effects: ${message.slice(0, 200)}. The work may be partial; check before repeating it.) ${progress.last}`.trim();
229
254
  msgs.push({ role: "assistant", content: note });
230
- if (preserveHistory) this.commit(msgs);
255
+ if (preserveHistory && !reviewScope.excluded) this.commit(msgs);
231
256
  this.onMessage?.(note, reply);
232
257
  if (deliveryId && this.activeDeliveryId === deliveryId) this.delivery({ id: deliveryId, state: "needs_review", reason: message });
233
258
  })
@@ -241,7 +266,7 @@ export class LocalPeer extends BasePeer {
241
266
 
242
267
  protected override onWatchdog(): void {
243
268
  this.endRouteTurn(this.labelTurnId, "failed");
244
- if (this.activeDeliveryId) this.delivery({ id: this.activeDeliveryId, state: "needs_review", reason: "turn watchdog timeout" });
269
+ if (this.activeDeliveryId) this.delivery({ id: this.activeDeliveryId, state: "needs_review", reason: "turn watchdog timeout", failureCause: "timeout" });
245
270
  this.activeDeliveryId = undefined;
246
271
  this.turn++;
247
272
  clearTimeout(this.budgetTimer); this.budgetTimer = undefined;
@@ -256,6 +281,7 @@ export class LocalPeer extends BasePeer {
256
281
  progress: { sideEffects: number; last: string },
257
282
  policy: { route?: string; fixedModel?: string; pii: boolean; task?: string } | undefined,
258
283
  reply: EnvelopeOpts,
284
+ reviewScope: { excluded: boolean },
259
285
  ): Promise<string> {
260
286
  const { maxSteps = 30 } = this.opts;
261
287
  const turnSignal = this.abort!.signal;
@@ -282,9 +308,10 @@ export class LocalPeer extends BasePeer {
282
308
  const concluded = await this.conclude(msgs, policy, turn, step, maxSteps, progress.last, turnSignal, res.routeDecision);
283
309
  if (turn !== this.turn) return "";
284
310
  if (concluded === undefined) continue;
285
- if (usedTools) capture?.summarize(concluded);
311
+ if (usedTools && !reviewScope.excluded) capture?.summarize(concluded);
286
312
  return concluded;
287
313
  }
314
+ if (res.message.tool_calls.some(call => call.function.name.replace(/^mcp__[^ ]+__/, "") === "hub_review" && reviewCaptureExcluded(call.function.name, call.function.arguments))) reviewScope.excluded = true;
288
315
  const results: ChatMessage[] = [];
289
316
  try {
290
317
  for (const call of res.message.tool_calls) {
@@ -295,7 +322,13 @@ export class LocalPeer extends BasePeer {
295
322
  await this.requireBudget(envs, "tool_calls");
296
323
  if (turnSignal.aborted) throw new ExecutionBudgetStop(this.budgetStopReason || "turn cancelled before tool execution");
297
324
  } catch (error) { clearInterval(alive); throw error; }
298
- const running = (TASK_TOOL_NAMES.has(name) || CONDUCTOR_TOOL_NAMES.has(name)) && this.opts.taskTool ? this.opts.taskTool(name, safeParse(call.function.arguments), { pii: !!policy?.pii }).catch((e: Error) => `error: ${e.message}`) : runTool(name, call.function.arguments, ctx);
325
+ const taskToolName = name.replace(/^mcp__[^ ]+__/, "");
326
+ const reviewNoteWrite = taskToolName === "hub_remember" || taskToolName === "hub_task_propose";
327
+ const running = reviewScope.excluded && reviewNoteWrite
328
+ ? Promise.resolve("error: anchored review in this batch; save notes in a later turn")
329
+ : (TASK_TOOL_NAMES.has(name) || CONDUCTOR_TOOL_NAMES.has(name)) && this.opts.taskTool
330
+ ? this.opts.taskTool(name, safeParse(call.function.arguments), { pii: !!policy?.pii, signal: turnSignal }).catch((e: Error) => `error: ${e.message}`)
331
+ : runTool(name, call.function.arguments, ctx);
299
332
  const output = await running.finally(() => clearInterval(alive));
300
333
  if (turn !== this.turn) return "";
301
334
  this.touch();
@@ -304,15 +337,16 @@ export class LocalPeer extends BasePeer {
304
337
  const result: ChatMessage = { role: "tool", tool_call_id: call.id, content: output, is_error: toolResultFailed(name, output) };
305
338
  msgs.push(result); results.push(result);
306
339
  if (this.approvalStopReason) { this.abort?.abort(); throw new ApprovalWaitStop(this.approvalStopReason); }
307
- if (!remoteTurn && name !== "hub_set_title") this.observeTool(call, output, policy, `${this.sessionId}.${turn}.${step}`);
340
+ if (!remoteTurn && !reviewScope.excluded && name !== "hub_set_title" && name !== "hub_ask" && !reviewCaptureExcluded(name, call.function.arguments)) this.observeTool(call, output, policy, `${this.sessionId}.${turn}.${step}`);
308
341
  // Hub tool arguments/results are not capture evidence; Tasks owns its screened memory writes.
309
342
  // doc_read text is converted document content (a converter can surface hidden text), never memory evidence: not captured, whatever the turn.
310
343
  // web_fetch text is a third party's page and its URL is the model's own request: neither is memory evidence either (#515b).
311
- if (name !== "hub_send" && name !== "doc_read" && name !== "web_fetch" && !TASK_TOOL_NAMES.has(name) && !CONDUCTOR_TOOL_NAMES.has(name)) capture?.observe({ tool: call.function.name, args: call.function.arguments, output, id: call.id, sourceId: `${this.sessionId}.${turn}.${step}.${captureInvocation++}`, paths: touchedPaths(call.function.name, safeParse(call.function.arguments)) });
344
+ if (!reviewScope.excluded && name !== "hub_send" && name !== "doc_read" && name !== "web_fetch" && !TASK_TOOL_NAMES.has(name) && !CONDUCTOR_TOOL_NAMES.has(name)) capture?.observe({ tool: call.function.name, args: call.function.arguments, output, id: call.id, sourceId: `${this.sessionId}.${turn}.${step}.${captureInvocation++}`, paths: touchedPaths(call.function.name, safeParse(call.function.arguments)) });
312
345
  }
313
346
  } finally { if (results.length) this.routes.observeResults(res.routeDecision, res.message, results); }
314
347
  }
315
- if (usedTools) capture?.summarize(progress.last);
348
+ if (usedTools && !reviewScope.excluded) capture?.summarize(progress.last);
349
+ this.opts.onLimit?.("steps");
316
350
  return `(stopped after ${maxSteps} steps) ${progress.last}`.trim();
317
351
  }
318
352
 
@@ -369,10 +403,11 @@ export class LocalPeer extends BasePeer {
369
403
  };
370
404
  }
371
405
 
372
- private reportBudgetStop(e: Error, msgs: ChatMessage[], progress: { sideEffects: number; last: string }, reply: EnvelopeOpts, privateHistory: boolean, deliveryId?: string, remoteTurn = false): void {
373
- const message = remoteTurn ? "remote turn failed" : e.message;
406
+ private reportBudgetStop(e: Error, msgs: ChatMessage[], progress: { sideEffects: number; last: string }, reply: EnvelopeOpts, privateHistory: boolean, deliveryId?: string, remoteTurn = false, reviewTurn = false): void {
407
+ this.opts.onLimit?.("budget");
408
+ const message = remoteTurn ? "remote turn failed" : reviewTurn ? "review turn failed" : e.message;
374
409
  this.completeMissingToolResults(msgs, `not run: ${message}`);
375
- const detail = remoteTurn ? message : progress.sideEffects
410
+ const detail = remoteTurn || reviewTurn ? message : progress.sideEffects
376
411
  ? `(execution budget stopped after ${progress.sideEffects} tool call(s) with side effects; partial work may exist and needs review) ${progress.last}`.trim()
377
412
  : `(execution budget stopped before any tool side effects; no work was repeated) ${progress.last}`.trim();
378
413
  msgs.push({ role: "assistant", content: detail });
@@ -423,12 +458,19 @@ export class LocalPeer extends BasePeer {
423
458
  const routed = await this.hubCall(route, messages, policy, signal);
424
459
  if (routed) return routed;
425
460
  await this.requireBudget(envs, "model_calls");
461
+ this.releaseObserverInput(this.currentObserverInputContext, envs);
426
462
  const res = await omni.chat({ model: fixedModel, messages, tools }, { signal, ...(policy?.pii ? { onCampusOnly: true } : {}) });
427
463
  this.lastServedBy = `omniroute ${fixedModel} (provider ${res.provider ?? "?"})`;
428
464
  this.recordUsage(res, fixedModel, task);
429
465
  return res;
430
466
  }
431
467
 
468
+ private releaseObserverInput(context: OwnedInputContext | undefined, envs: Envelope[]): void {
469
+ if (!context || this.currentObserverInputContext !== context || this.observerInputReleasedId === context.inputId) return;
470
+ this.observerInputReleasedId = context.inputId;
471
+ try { this.opts.inputReleased?.(context, envs); } catch { /* observer metadata never changes the local model result */ }
472
+ }
473
+
432
474
  private beginRouteTurn(generation: number, pii: boolean, task?: string): string {
433
475
  let id: string | undefined;
434
476
  try { id = this.opts.turnId?.(); } catch { /* optional host metadata */ }
@@ -473,6 +515,7 @@ export class LocalPeer extends BasePeer {
473
515
  const feedback = await this.routes.review(route, config, messages, this.routeScope(!!policy?.pii, turn), !!policy?.pii, signal, decisionId);
474
516
  if (turn !== this.turn || !feedback) return answer;
475
517
  msgs.push({ role: "user", content: feedback });
518
+ if (step + 1 >= maxSteps) this.opts.onLimit?.("steps");
476
519
  return step + 1 < maxSteps ? undefined : `(review requested changes; step limit reached) ${answer}`;
477
520
  }
478
521
 
@@ -5,6 +5,7 @@ import { join, resolve } from "node:path";
5
5
  import { spawn, type ChildProcess, type ChildProcessWithoutNullStreams } from "node:child_process";
6
6
  import { renderDigest, remoteEnvelope, replyAudience, replyParent, type Envelope, type PeerId } from "../hub/envelope.ts";
7
7
  import { BasePeer, type SessionTitleResult } from "../hub/peers.ts";
8
+ import type { DeliveryFailureCause } from "../hub/delivery-failure.ts";
8
9
  import { peerChildEnv, stopOwnedProcess, trackGroup } from "../hub/child-process.ts";
9
10
  import { assessVersion, COMPAT, type CompatVerdict } from "./compat.ts";
10
11
  import { processLiveness, processSignature } from "../pi/process-signature.ts";
@@ -12,6 +13,7 @@ import { PiSessionBookmarkMismatch, validatePiSession } from "../pi/session.ts";
12
13
  import { ApprovalWaitStop, toolResultFailed, type ApprovalProvenance } from "../local/tools.ts";
13
14
  import { piToolStepCeiling, type PiToolStepCeiling } from "../pi/ceiling.ts";
14
15
  import type { ExecutionBudgetDecision, ExecutionUnit } from "../hub/execution-budget.ts";
16
+ import { QuestionWaitDetached } from "../hub/question-detach.ts";
15
17
  import type { StartForm } from "./registry-types.ts";
16
18
 
17
19
  export interface PiModelDescriptor { id: string; name?: string; contextWindow?: number; maxTokens?: number; reasoning?: boolean; }
@@ -329,6 +331,35 @@ export class PiPeer extends BasePeer {
329
331
  this.starting = this.startImpl();
330
332
  try { await this.starting; } finally { this.starting = undefined; }
331
333
  }
334
+ /** The keepalive period of a long tool reply; well under every timer between the hub and Pi's runtime (60 s Bun idle, 300 s undici). A test shortens it. */
335
+ toolKeepaliveMs = 15_000;
336
+ /**
337
+ * A tool reply that may take minutes (`hub_ask`): the status line and headers go out at once, a space is written every `toolKeepaliveMs`, and the JSON body
338
+ * follows when the call settles. Whitespace before a JSON value is valid JSON, so the extension's `response.json()` reads it unchanged. When the client goes away
339
+ * the stream is cancelled and the call is aborted through its own signal with `QuestionWaitDetached`, so the wait ends `pending` and the row stays open for the late envelope;
340
+ * nothing else about `handleToolRequest` changes.
341
+ */
342
+ private streamedToolResponse(body: any, clientGone: AbortSignal): Response {
343
+ const encoder = new TextEncoder();
344
+ let timer: ReturnType<typeof setInterval> | undefined, closed = false;
345
+ const stop = () => { closed = true; if (timer) clearInterval(timer); timer = undefined; };
346
+ return new Response(new ReadableStream({
347
+ start: controller => {
348
+ timer = setInterval(() => { if (!closed) try { controller.enqueue(encoder.encode(" ")); } catch { stop(); } }, this.toolKeepaliveMs);
349
+ timer.unref?.();
350
+ const abort = () => { stop(); this.pendingToolGenerations.get(String(body?.toolCallId ?? ""))?.abort.abort(new QuestionWaitDetached()); };
351
+ clientGone.addEventListener("abort", abort, { once: true });
352
+ void this.handleToolRequest(body).then(async response => {
353
+ const text = await response.text();
354
+ if (closed) return;
355
+ stop();
356
+ try { controller.enqueue(encoder.encode(text)); controller.close(); } catch { /* the client already left */ }
357
+ }, () => { if (closed) return; stop(); try { controller.enqueue(encoder.encode(JSON.stringify({ text: "error: Pi tool call failed", failed: true }))); controller.close(); } catch { /* the client already left */ } });
358
+ },
359
+ cancel: () => { stop(); this.pendingToolGenerations.get(String(body?.toolCallId ?? ""))?.abort.abort(new QuestionWaitDetached()); },
360
+ }), { headers: { "content-type": "application/json" } });
361
+ }
362
+
332
363
  private async handleToolRequest(body: any): Promise<Response> {
333
364
  if (!this.acceptingTools || this.state === "offline") return Response.json({ text: this.rosterDetached ? "error: Pi is off roster" : this.rosterUnownedRun ? "error: Pi native turn is pending" : "error: Pi owner is stopped" }, { status: 409 });
334
365
  if (!this.verifiedOwner() || typeof body.sessionId !== "string" || !body.sessionId || body.sessionId !== this.sessionId || !Number.isSafeInteger(body.generation) || body.generation !== this.budgetGeneration) {
@@ -494,7 +525,18 @@ export class PiPeer extends BasePeer {
494
525
  : boundRefusal && refusal.kind === "prompt_uncertain" ? new PiPromptUncertain("Pi native prompt insertion is uncertain; inspect its retained terminal before retrying") : new Error(String(body.error ?? "Pi TUI command failed")));
495
526
  return Response.json({ ok: true });
496
527
  }
497
- if (url.pathname === "/tool") return this.handleToolRequest(body);
528
+ if (url.pathname === "/tool") {
529
+ // #1053: `hub_ask` holds this request open for the person's wait window. Two timers would cut it: Bun's 60 s bridge idle timeout (lifted for this one call with
530
+ // `timeout(request, 0)`) and the extension's own fetch, whose undici dispatcher Pi installs with headersTimeout and bodyTimeout of 300 s (Pi 1.4.0
531
+ // `configureHttpDispatcher`, measured: UND_ERR_HEADERS_TIMEOUT). So the reply is streamed: headers go out at once and a space every 15 s keeps both timers
532
+ // quiet, then the JSON follows (`response.json()` ignores leading whitespace). A client that gives up anyway cancels the stream, which aborts the call; the
533
+ // question row stays open and its answer takes the late-envelope path.
534
+ if (body?.name === "hub_ask") {
535
+ bridge.timeout(request, 0);
536
+ return this.streamedToolResponse(body, request.signal);
537
+ }
538
+ return this.handleToolRequest(body);
539
+ }
498
540
  if (url.pathname === "/budget") return this.handleBudgetRequest(body);
499
541
  return new Response("not found", { status: 404 });
500
542
  } });
@@ -621,12 +663,12 @@ export class PiPeer extends BasePeer {
621
663
  if (!snapshot.idle) this.clearWatchdog();
622
664
  }
623
665
 
624
- async stop(reason?: string): Promise<void> {
666
+ async stop(reason?: string, failureCause?: DeliveryFailureCause): Promise<void> {
625
667
  if (reason !== undefined) this.requestedStopReason = reason;
626
668
  this.rosterEpoch++;
627
669
  this.terminalOwnerObservation = undefined;
628
670
  this.stopping = true; // fence new calls before withdrawing both turn and idle-shell execution
629
- for (const id of this.activeDeliveryIds) this.delivery({ id, state: "needs_review", reason: reason ?? "Pi session stopped before settlement" });
671
+ for (const id of this.activeDeliveryIds) this.delivery({ id, state: "needs_review", reason: reason ?? "Pi session stopped before settlement", ...(failureCause ? { failureCause } : {}) });
630
672
  this.activeDeliveryIds.clear();
631
673
  const settling = this.abortTools();
632
674
  let unsettled: unknown;
@@ -1047,11 +1089,11 @@ export class PiPeer extends BasePeer {
1047
1089
  await this.terminateFailedTurn(new ApprovalWaitStop(reason));
1048
1090
  }
1049
1091
 
1050
- private async terminateFailedTurn(error: Error): Promise<void> {
1092
+ private async terminateFailedTurn(error: Error, failureCause?: DeliveryFailureCause): Promise<void> {
1051
1093
  const envs = this.activeEnvs.slice(), reply = this.currentReply;
1052
1094
  this.stopping = true;
1053
1095
  try {
1054
- await this.stop();
1096
+ await this.stop(undefined, failureCause);
1055
1097
  // The process exit handler may already have reported this failure.
1056
1098
  if (this.activeEnvs.length) this.fail(error);
1057
1099
  } catch (stopError) {
@@ -1069,7 +1111,7 @@ export class PiPeer extends BasePeer {
1069
1111
  protected override onWatchdog(): void {
1070
1112
  if (this.stopping || this.rosterDetached || this.rosterUnownedRun) return;
1071
1113
  if (this.activeTools) { this.touch(); return; }
1072
- void this.terminateFailedTurn(new Error("Pi run became inactive before agent_settled"));
1114
+ void this.terminateFailedTurn(new Error("Pi run became inactive before agent_settled"), "timeout");
1073
1115
  }
1074
1116
  }
1075
1117
 
@@ -81,6 +81,10 @@ export interface CreateContext<Ports extends DaemonPorts = DaemonPorts> {
81
81
  daemon: Ports;
82
82
  }
83
83
 
84
+ /** `native-stop`: Claude's transcript-verified Stop. `turn-completed`: Codex's `turn/completed` with no active turn. `agent-settled`: Pi's own settled event. `route-outcome`: the local worker's per-decision outcome. `prompt-response`: an ACP `session/prompt` response. `message-stop`: Qwen's TUI `message_stop`. */
85
+ export type SettleSignal = "native-stop" | "turn-completed" | "agent-settled" | "route-outcome" | "prompt-response" | "message-stop";
86
+ export const SETTLE_SIGNALS: readonly SettleSignal[] = Object.freeze(["native-stop", "turn-completed", "agent-settled", "route-outcome", "prompt-response", "message-stop"]);
87
+
84
88
  export interface StartRequest {
85
89
  instance: string;
86
90
  form?: StartForm;
@@ -208,6 +212,14 @@ export interface AdapterDefinition {
208
212
  title?: "native" | "hub";
209
213
  /** Fixed builtins only: the title slot survives native-session restoration. Absent means ephemeral. */
210
214
  titlePersistence?: "native";
215
+ /** #1053: how long a `hub_ask` call waits for the person before it answers `pending` (milliseconds). MCP peers 45 s, hub-native peers 300 s; config `questions.wait_ms.<kind>` overrides it. Fixed builtins only. */
216
+ questionWaitMs?: number;
217
+ /**
218
+ * #1054: the code-verified signal that ends a peer's logical run, declared per definition. ABSENT means the adapter creates no completion: nothing is guessed (owner
219
+ * decision 2026-11-04). Fixed builtins only (`assertNativeCapabilityScope`): a project or external definition cannot claim one. A definition with a pull-only form (Kimi's TUI)
220
+ * declares it for its headless form only, because the daemon starts no run for a peer that attached pull-only.
221
+ */
222
+ settle?: { signal: SettleSignal };
211
223
  /** External module data-only opt-in. The verified loader derives the internal instances policy from it (#754). */
212
224
  headlessInstances?: { form: "headless"; roles: readonly ("implementer" | "verifier" | "reviewer")[] };
213
225
  /** Declaration admission only; execution-root and runtime owner proof remain host responsibilities. */