@staix/agent-hub 0.29.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 (154) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.md +15 -7
  3. package/docs/agent-notes/README.md +13 -0
  4. package/docs/agent-notes/adapters.md +7 -3
  5. package/docs/agent-notes/budget.md +1 -0
  6. package/docs/agent-notes/bus.md +10 -4
  7. package/docs/agent-notes/daemon.md +16 -8
  8. package/docs/agent-notes/local-worker.md +1 -0
  9. package/docs/agent-notes/tasks.md +10 -4
  10. package/docs/agent-notes/tests.md +8 -4
  11. package/docs/design/adapters.md +108 -0
  12. package/docs/design/agent-hub-architecture.md +58 -0
  13. package/docs/design/bus.md +147 -1
  14. package/docs/design/task-board.md +100 -0
  15. package/docs/dogfood.md +29 -8
  16. package/docs/events.md +3 -0
  17. package/docs/memory.md +17 -1
  18. package/docs/operations.md +410 -86
  19. package/docs/profiles.md +35 -0
  20. package/docs/quickstart.md +25 -5
  21. package/docs/sdlc.md +1 -1
  22. package/docs/security.md +182 -62
  23. package/docs/smoke.md +13 -2
  24. package/docs/specs/2026-09-19-agent-hub-design.md +84 -14
  25. package/docs/specs/2026-09-19-multi-project-design.md +7 -2
  26. package/docs/specs/2026-10-12-office-view-design.md +86 -3
  27. package/docs/verification/2026-11-03-0.30.0.md +54 -0
  28. package/docs/verification/2026-11-03-agy-1.4.12-launcher-nonce.md +42 -0
  29. package/docs/verification/2026-11-03-agy-1.4.12.md +90 -0
  30. package/docs/verification/2026-11-03-agy-tui-close.md +49 -0
  31. package/docs/verification/2026-11-03-grok-1.0.62-t0.md +150 -0
  32. package/docs/verification/2026-11-03-pi-150-compat.md +40 -0
  33. package/docs/verification/2026-11-03-pi-title-api.md +26 -0
  34. package/docs/verification/2026-11-03-qwen-0265-acp.md +55 -0
  35. package/docs/verification/2026-11-03-qwen-0265-dual-output.md +40 -0
  36. package/docs/verification/2026-11-04-1171-channel-client-evidence.md +31 -0
  37. package/docs/verification/2026-11-05-0.31.0.md +182 -0
  38. package/docs/verification/2026-11-05-claude-implementer-2.1.318.md +70 -0
  39. package/docs/verification/2026-11-05-hardcoded-values-classification-rules.md +257 -0
  40. package/docs/verification/2026-11-05-hardcoded-values-ratchet.md +55 -0
  41. package/docs/verification/2026-11-06-rooms-presence-cost.md +152 -0
  42. package/docs/verification/claude-shell-wrapper-native.py +1 -1
  43. package/docs/verification/dogfood/2026-11-03.md +3095 -0
  44. package/docs/verification/pi-150-model-free.ts +57 -0
  45. package/docs/verification/pi-150-native.mjs +21 -0
  46. package/docs/verification/pi-150-title-api.mjs +31 -0
  47. package/docs/verified.json +14 -14
  48. package/package.json +3 -4
  49. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  50. package/plugins/agent-hub/server.js +4565 -3617
  51. package/src/adapters/acp.ts +5 -4
  52. package/src/adapters/channel-peer.ts +72 -3
  53. package/src/adapters/claude-channel-evidence.ts +52 -0
  54. package/src/adapters/claude-channel.ts +61 -18
  55. package/src/adapters/claude-implementer.ts +29 -7
  56. package/src/adapters/claude-print-child.ts +12 -2
  57. package/src/adapters/codex-appserver.ts +13 -12
  58. package/src/adapters/compat.ts +13 -13
  59. package/src/adapters/definitions/builtin-metadata.ts +13 -13
  60. package/src/adapters/definitions/qwen.ts +1 -0
  61. package/src/adapters/local-worker.ts +82 -23
  62. package/src/adapters/pi.ts +140 -16
  63. package/src/adapters/registry-types.ts +16 -0
  64. package/src/adapters/registry.ts +4 -0
  65. package/src/adapters/reviewer-print.ts +50 -11
  66. package/src/broker/server.ts +44 -13
  67. package/src/broker/store.ts +38 -8
  68. package/src/cli/answer-args.ts +50 -0
  69. package/src/cli/attention.ts +17 -2
  70. package/src/cli/autonomy.ts +66 -1
  71. package/src/cli/backlog-return-args.ts +37 -0
  72. package/src/cli/console-question.ts +110 -0
  73. package/src/cli/console-state.ts +82 -17
  74. package/src/cli/console.ts +54 -1
  75. package/src/cli/handoff-args.ts +15 -5
  76. package/src/cli/handoff-output.ts +15 -2
  77. package/src/cli/help.ts +10 -5
  78. package/src/cli/identity.ts +21 -4
  79. package/src/cli/main.ts +167 -16
  80. package/src/cli/memory.ts +107 -3
  81. package/src/cli/output.ts +29 -6
  82. package/src/cli/pet.ts +4 -4
  83. package/src/cli/results-args.ts +30 -0
  84. package/src/cli/runtime-version-warning.ts +50 -0
  85. package/src/cli/status-lines.ts +41 -1
  86. package/src/cli/tail-render.ts +1 -0
  87. package/src/cli/upgrade-interactive.ts +14 -2
  88. package/src/cli/worktree-tidy.ts +158 -0
  89. package/src/cli/worktrees-output.ts +43 -0
  90. package/src/hub/action-class.ts +9 -1
  91. package/src/hub/board.ts +396 -20
  92. package/src/hub/bus.ts +497 -57
  93. package/src/hub/completion-detail.ts +50 -0
  94. package/src/hub/completions-store.ts +168 -0
  95. package/src/hub/completions.ts +120 -0
  96. package/src/hub/conductor.ts +4 -3
  97. package/src/hub/config-apply.ts +8 -5
  98. package/src/hub/config-reload.ts +10 -1
  99. package/src/hub/control-client.ts +40 -4
  100. package/src/hub/daemon.ts +1795 -142
  101. package/src/hub/delivery-failure.ts +14 -0
  102. package/src/hub/delivery-hints.ts +14 -4
  103. package/src/hub/delivery-journal.ts +751 -42
  104. package/src/hub/delivery-superseded.ts +4 -1
  105. package/src/hub/dogfood-read.ts +62 -9
  106. package/src/hub/envelope.ts +3 -3
  107. package/src/hub/events.ts +6 -0
  108. package/src/hub/handoff.ts +145 -6
  109. package/src/hub/hub-tools.ts +66 -11
  110. package/src/hub/manager.ts +67 -3
  111. package/src/hub/memory-policy.ts +44 -1
  112. package/src/hub/memory-sync-policy.ts +109 -0
  113. package/src/hub/memory-sync-state.ts +145 -0
  114. package/src/hub/no-writes.ts +1 -1
  115. package/src/hub/operational-settings.ts +11 -2
  116. package/src/hub/operational-view.ts +12 -1
  117. package/src/hub/peer-doing.ts +33 -3
  118. package/src/hub/peers.ts +32 -2
  119. package/src/hub/planning.ts +6 -1
  120. package/src/hub/question-desk.ts +201 -0
  121. package/src/hub/question-detach.ts +6 -0
  122. package/src/hub/questions.ts +260 -0
  123. package/src/hub/research-evidence.ts +106 -0
  124. package/src/hub/restart.ts +14 -0
  125. package/src/hub/review-anchor.ts +181 -0
  126. package/src/hub/review-brief.ts +13 -0
  127. package/src/hub/review-comments.ts +115 -0
  128. package/src/hub/review-findings.ts +7 -2
  129. package/src/hub/review-material.ts +76 -0
  130. package/src/hub/routing.ts +3 -1
  131. package/src/hub/settings.ts +3 -0
  132. package/src/hub/task-sweep.ts +2 -0
  133. package/src/hub/tasks.ts +607 -71
  134. package/src/hub/ui.ts +2 -0
  135. package/src/hub/worktree-prs.ts +100 -0
  136. package/src/hub/worktree-shelf.ts +246 -0
  137. package/src/memory/capture.ts +5 -2
  138. package/src/pet/config.ts +25 -13
  139. package/src/pi/extension.ts +43 -3
  140. package/src/rooms/link.ts +209 -21
  141. package/src/rooms/remote-peer.ts +186 -19
  142. package/src/rooms/service.ts +338 -22
  143. package/src/rooms/store.ts +437 -65
  144. package/src/rooms/wire.ts +24 -6
  145. package/src/ui/index.html +271 -17
  146. package/src/ui/needs.ts +10 -4
  147. package/src/ui/office-model.ts +64 -8
  148. package/src/ui/office-space.ts +1 -0
  149. package/src/ui/plugin-questions.ts +56 -0
  150. package/src/ui/plugin-results.ts +81 -0
  151. package/src/ui/plugin.ts +219 -28
  152. package/src/pet/window.html +0 -114
  153. package/src/pet/window.jxa.js +0 -122
  154. 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
 
@@ -29,16 +29,16 @@ export interface CompatPolicy {
29
29
 
30
30
  const plain = (text: string) => text.length <= 64 && /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(text) ? text : undefined;
31
31
  export const COMPAT = {
32
- // Receipt: docs/verification/2026-10-26-pi-140-compat.md
33
- pi: { label: "Pi", verified: ["1.4.0"], gate: "contract", probe: ["pi", "--version"], config: "pi.cmd",
32
+ // Receipts: docs/verification/2026-10-26-pi-140-compat.md (1.4.0); docs/verification/2026-11-03-pi-150-compat.md (1.5.0)
33
+ pi: { label: "Pi", verified: ["1.4.0", "1.5.0"], gate: "contract", probe: ["pi", "--version"], config: "pi.cmd",
34
34
  parse: out => plain(out.trim()),
35
- trustNote: "The model-free extension load, RPC session identity and managed tool receipt path were checked on 1.4.0. Permissions remain host-owned; account-backed inference and another version's extension contract are unverified.",
36
- receipt: "docs/verification/2026-10-26-pi-140-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"],
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
+ 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; 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 /, "")),
@@ -53,14 +53,14 @@ export const COMPAT = {
53
53
  parse: out => { try { const v = JSON.parse(out).semver; return typeof v === "string" ? plain(v) : undefined; } catch { return undefined; } },
54
54
  trustNote: "The ACP, serve and MCP contract was verified on 0.98.1 and 0.99.1 and steering on 0.99.0 and 0.99.1; an untested version may change them.",
55
55
  receipt: "docs/verification/2026-10-26-jcode-0.99.1.md" },
56
- agy: { label: "agy", verified: ["1.4.2", "1.4.7"], line: { min: "1.4.0", below: "1.5.0" }, gate: "contract", probe: ["agy", "--version"],
56
+ agy: { label: "agy", verified: ["1.4.2", "1.4.7", "1.4.12"], line: { min: "1.4.0", below: "1.5.0" }, gate: "contract", probe: ["agy", "--version"],
57
57
  parse: out => plain(out.trim()),
58
- trustNote: "Only 1.4.2 and 1.4.7 are T0-verified; the plugin MCP and hook contract of another version is unverified.",
59
- receipt: "docs/verification/2026-10-26-agy-1.4.7.md" },
60
- qwen: { label: "Qwen Code", verified: ["0.25.0", "0.26.0", "0.26.1", "0.26.4"], gate: "contract", probe: ["qwen", "--version"], config: "qwen_cmd",
58
+ trustNote: "Only 1.4.2, 1.4.7 and 1.4.12 are T0-verified; the plugin MCP and hook contract of another version is unverified.",
59
+ receipt: "docs/verification/2026-11-03-agy-1.4.12-launcher-nonce.md" },
60
+ qwen: { label: "Qwen Code", verified: ["0.25.0", "0.26.0", "0.26.1", "0.26.4", "0.26.5"], gate: "contract", probe: ["qwen", "--version"], config: "qwen_cmd",
61
61
  parse: out => plain(out.trim()),
62
- trustNote: "Native ACP identity, mode ids and permission callbacks were re-checked without inference on 0.26.4; real model turns remain unverified. Another version may change those contracts.",
63
- receipt: "docs/verification/2026-10-31-qwen-0264-acp.md" },
62
+ trustNote: "Native ACP identity, mode ids and permission callbacks were re-checked without inference on 0.26.4 and 0.26.5; real model turns remain unverified. Another version may change those contracts.",
63
+ receipt: "docs/verification/2026-11-03-qwen-0265-acp.md" },
64
64
  // Receipt: docs/verification/2026-10-25-qwen-dual-output-t0.md. The dual-output contract (input file, event file, can_use_tool control requests) is its own entry:
65
65
  // the ACP receipts of the headless `qwen` entry say nothing about it, so that entry is not widened and this one carries no `line`.
66
66
  "qwen-dual": { label: "Qwen Code dual-output TUI", verified: ["0.26.1"], gate: "contract", probe: ["qwen", "--version"], config: "qwen_cmd",
@@ -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", 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", 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";
@@ -5,8 +6,8 @@ import type { StaySwitchPolicy } from "../models/route/stage.ts";
5
6
  import type { ToolObservation } from "../models/route/signals.ts";
6
7
  import { randomUUID } from "node:crypto";
7
8
  import { MAX_HOP, remoteEnvelope, renderDigest, replyAudience, replyParent, STANDING_INSTRUCTION, USER, type Envelope, type EnvelopeOpts, type PeerId } from "../hub/envelope.ts";
8
- import { CONDUCTOR_TOOL_NAMES, CONDUCTOR_TOOLS, TASK_TOOL_NAMES, TASK_TOOLS, profileTaskTools } from "../hub/hub-tools.ts";
9
- import { BasePeer } from "../hub/peers.ts";
9
+ import { CONDUCTOR_TOOL_NAMES, CONDUCTOR_TOOLS, TASK_TOOL_NAMES, TASK_TOOLS, profileTaskTools, SESSION_TITLE_TOOL } from "../hub/hub-tools.ts";
10
+ import { BasePeer, type SessionTitleResult } from "../hub/peers.ts";
10
11
  import type { PublishResult } from "../hub/bus.ts";
11
12
  import { profile, proxyEnv, type SandboxNetwork } from "../local/sandbox.ts";
12
13
  import type { DocReadPort } from "../local/doc-read.ts";
@@ -17,11 +18,14 @@ 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;
23
25
  sdlcStages?: readonly string[];
24
26
  sdlcCriteria?: boolean;
27
+ /** Host-owned conditional instruction, sampled once per delivered turn. */
28
+ titlePreface?: () => string | undefined;
25
29
  cwd: string;
26
30
  omni: OmniRoute;
27
31
  /** In-process hub route id; legacy routes use the fixed model. */
@@ -34,6 +38,11 @@ export interface LocalOptions {
34
38
  onRoute?: (event: RouteEvent) => void;
35
39
  onAdvisor?: (event: AdvisorEvent) => void;
36
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;
37
46
  /** The daemon turn id created synchronously on busy, shared with turn_start/end. */
38
47
  turnId?: () => string | undefined;
39
48
  onApprovalStop?: (reason: string) => void;
@@ -41,7 +50,11 @@ export interface LocalOptions {
41
50
  tools: { docRead?: DocReadPort; webFetch?: WebFetchPort; deny: string[]; permit: ToolContext["permit"]; shellExecutionCheck?: ToolContext["shellExecutionCheck"]; bashNetwork?: SandboxNetwork; readAllow?: string[]; readOnly?: string[] };
42
51
  capture?: Capture;
43
52
  /** Runs a hub task tool (hub_task_*, hub_review, hub_remember) as this peer. Absent = the tools are not offered. */
44
- 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;
45
58
  /** Successful provider responses only; usage may be absent when the gateway omits it. Never includes prompt data. */
46
59
  onUsage?: (record: { id: string; at: string; usage?: ChatResult["usage"]; requestedModel: string; servedModel?: string; provider?: string; task?: number }) => void;
47
60
  /** Atomic task/run admission immediately before every model request or tool execution. */
@@ -82,6 +95,22 @@ export class LocalPeer extends BasePeer {
82
95
  readonly deliveryOnly: boolean = true;
83
96
  readonly hubOwned: boolean = true;
84
97
  private readonly history: ChatMessage[] = [];
98
+ private title = "";
99
+ get sessionTitle(): string { return this.title; }
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; }
103
+ async setSessionTitle(title: string, onlyIfUnnamed: boolean, current?: () => boolean): Promise<SessionTitleResult> {
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." };
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." };
106
+ let admitted = false;
107
+ try { admitted = current?.() !== false; } catch { /* host admission failure is a refusal */ }
108
+ if (!admitted) return { ok: false, reason: "invalid", error: "Session title admission changed before the write; check ahub status and retry only in the current public session." };
109
+ if (onlyIfUnnamed && this.title) return { ok: false, reason: "already_named", error: "This session already has a title; keep it or ask the person to rename it with ahub title." };
110
+ this.title = title;
111
+ try { this.onDoing?.("title", title); } catch { /* observation failure cannot undo the saved local title */ }
112
+ return { ok: true };
113
+ }
85
114
  private readonly sessionId = `agent-hub-local-${randomUUID()}`;
86
115
  private readonly routes: HubRouteRuntime;
87
116
  private readonly warnedLegacyRoutes = new Set<string>();
@@ -97,6 +126,8 @@ export class LocalPeer extends BasePeer {
97
126
  private approvalAnswerEpoch: number | undefined;
98
127
  private approvalStopReason = "";
99
128
  private activeDeliveryId: string | undefined;
129
+ private currentObserverInputContext: OwnedInputContext | undefined;
130
+ private observerInputReleasedId: string | undefined;
100
131
  private readonly sandboxProfile: string; // built once: profile() spawns git and must stay off the per-call path
101
132
  /** What served the last call, for `ahub status`. */
102
133
  lastServedBy = "";
@@ -113,7 +144,8 @@ export class LocalPeer extends BasePeer {
113
144
  await this.requireBudget(envs, "model_calls");
114
145
  if (generation !== this.turn) throw new Error("route belongs to an ended turn");
115
146
  if (signal.aborted) throw new Error("turn cancelled before model request");
116
- 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].map(asFunction) : [])];
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);
117
149
  const result = await this.opts.omni.chat({ model, messages, ...(tools ? { tools } : {}), ...(judge ? { max_tokens: maxTokens ?? 2048 } : {}) }, { signal, ...(pii ? { onCampusOnly: true } : {}) });
118
150
  this.recordUsage(result, model, task);
119
151
  if (generation !== this.turn) throw new Error("route belongs to an ended turn");
@@ -155,6 +187,14 @@ export class LocalPeer extends BasePeer {
155
187
  if (deliveryId) this.delivery({ id: deliveryId, state: "failed_safe", reason: `${this.id} is ${this.state}` });
156
188
  throw new Error(`${this.id} is ${this.state}`);
157
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;
158
198
  const turn = ++this.turn;
159
199
  this.budgetStopReason = "";
160
200
  this.approvalExpiries = 0; this.approvalAnswerEpoch = undefined; this.approvalStopReason = "";
@@ -165,21 +205,22 @@ export class LocalPeer extends BasePeer {
165
205
  if (deliveryId) this.delivery({ id: deliveryId, state: "accepted" });
166
206
  // The turn works on its own message list and joins the history only as a whole, so a failed or aborted turn can
167
207
  // never leave a tool call without its result (strict servers reject that history forever after).
168
- const msgs: ChatMessage[] = [{ role: "user", content: renderDigest(envs, true) }];
208
+ const msgs: ChatMessage[] = [{ role: "user", content: [envs.some(env => env.private || remoteEnvelope(env)) ? undefined : this.opts.titlePreface?.(), renderDigest(envs, true)].filter(Boolean).join("\n") }];
169
209
  const progress = { sideEffects: 0, last: "" };
170
210
  const requestedPolicy = this.opts.turnPolicy?.(envs);
171
211
  const policy = envs.some(env => env.private) ? { ...requestedPolicy, pii: true } : requestedPolicy;
172
212
  const remoteTurn = envs.some(remoteEnvelope);
213
+ const reviewScope = { excluded: envs.some(isReviewObserverExcluded) };
173
214
  const preserveHistory = !policy?.pii && !remoteTurn;
174
215
  const labelTurn = this.beginRouteTurn(turn, !!policy?.pii || envs.some(env => env.private), policy?.task);
175
216
  let completed = false;
176
217
  // A PII turn speaks to the console user only, and privately: its answer must not be broadcast to cloud-hosted peers.
177
218
  // The task id travels with it, so the board can keep the text (`ahub task show`) while tail and log show a stub.
178
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 } } : {}) } : {}) };
179
- this.run(envs, turn, msgs, progress, policy, reply)
220
+ this.run(envs, turn, msgs, progress, policy, reply, reviewScope)
180
221
  .then((answer) => {
181
222
  if (turn !== this.turn) return;
182
- 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.
183
224
  if (answer) this.onMessage?.(answer, reply);
184
225
  if (deliveryId && this.activeDeliveryId === deliveryId) this.delivery({ id: deliveryId, state: "completed" });
185
226
  completed = true;
@@ -187,19 +228,19 @@ export class LocalPeer extends BasePeer {
187
228
  .catch((e: Error) => {
188
229
  if (turn !== this.turn) return; // aborted by the watchdog or stop(): nothing to report, nothing was committed
189
230
  if (e instanceof ApprovalWaitStop) {
190
- const message = remoteTurn ? "remote turn failed" : e.message;
231
+ const message = remoteTurn ? "remote turn failed" : reviewScope.excluded ? "review turn failed" : e.message;
191
232
  this.completeMissingToolResults(msgs, "not run: turn ended after unanswered approvals");
192
233
  msgs.push({ role: "assistant", content: message });
193
- if (preserveHistory) this.commit(msgs);
234
+ if (preserveHistory && !reviewScope.excluded) this.commit(msgs);
194
235
  this.onMessage?.(message, reply);
195
236
  if (deliveryId && this.activeDeliveryId === deliveryId) this.delivery({ id: deliveryId, state: "completed", reason: message });
196
237
  return; // even without effects, an unanswered approval must not trigger safe replay
197
238
  }
198
239
  if (this.budgetStopReason && !(e instanceof ExecutionBudgetStop)) e = new ExecutionBudgetStop(this.budgetStopReason);
199
- const message = remoteTurn ? "remote turn failed" : e.message;
240
+ const message = remoteTurn ? "remote turn failed" : reviewScope.excluded ? "review turn failed" : e.message;
200
241
  this.opts.log?.(`[${this.id}] turn failed: ${message}`);
201
242
  if (e instanceof ExecutionBudgetStop) {
202
- this.reportBudgetStop(e, msgs, progress, reply, !preserveHistory, deliveryId, remoteTurn);
243
+ this.reportBudgetStop(e, msgs, progress, reply, !preserveHistory || reviewScope.excluded, deliveryId, remoteTurn, reviewScope.excluded);
203
244
  return;
204
245
  }
205
246
  if (!progress.sideEffects) {
@@ -209,9 +250,9 @@ export class LocalPeer extends BasePeer {
209
250
  }
210
251
  // Tools already changed things. Redelivering would redo approved writes and commits, so report instead.
211
252
  // A model call is the only thing that throws here, and every tool call before it has its result: msgs is consistent.
212
- 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();
213
254
  msgs.push({ role: "assistant", content: note });
214
- if (preserveHistory) this.commit(msgs);
255
+ if (preserveHistory && !reviewScope.excluded) this.commit(msgs);
215
256
  this.onMessage?.(note, reply);
216
257
  if (deliveryId && this.activeDeliveryId === deliveryId) this.delivery({ id: deliveryId, state: "needs_review", reason: message });
217
258
  })
@@ -225,7 +266,7 @@ export class LocalPeer extends BasePeer {
225
266
 
226
267
  protected override onWatchdog(): void {
227
268
  this.endRouteTurn(this.labelTurnId, "failed");
228
- 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" });
229
270
  this.activeDeliveryId = undefined;
230
271
  this.turn++;
231
272
  clearTimeout(this.budgetTimer); this.budgetTimer = undefined;
@@ -240,6 +281,7 @@ export class LocalPeer extends BasePeer {
240
281
  progress: { sideEffects: number; last: string },
241
282
  policy: { route?: string; fixedModel?: string; pii: boolean; task?: string } | undefined,
242
283
  reply: EnvelopeOpts,
284
+ reviewScope: { excluded: boolean },
243
285
  ): Promise<string> {
244
286
  const { maxSteps = 30 } = this.opts;
245
287
  const turnSignal = this.abort!.signal;
@@ -266,9 +308,10 @@ export class LocalPeer extends BasePeer {
266
308
  const concluded = await this.conclude(msgs, policy, turn, step, maxSteps, progress.last, turnSignal, res.routeDecision);
267
309
  if (turn !== this.turn) return "";
268
310
  if (concluded === undefined) continue;
269
- if (usedTools) capture?.summarize(concluded);
311
+ if (usedTools && !reviewScope.excluded) capture?.summarize(concluded);
270
312
  return concluded;
271
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;
272
315
  const results: ChatMessage[] = [];
273
316
  try {
274
317
  for (const call of res.message.tool_calls) {
@@ -279,7 +322,13 @@ export class LocalPeer extends BasePeer {
279
322
  await this.requireBudget(envs, "tool_calls");
280
323
  if (turnSignal.aborted) throw new ExecutionBudgetStop(this.budgetStopReason || "turn cancelled before tool execution");
281
324
  } catch (error) { clearInterval(alive); throw error; }
282
- 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);
283
332
  const output = await running.finally(() => clearInterval(alive));
284
333
  if (turn !== this.turn) return "";
285
334
  this.touch();
@@ -288,15 +337,16 @@ export class LocalPeer extends BasePeer {
288
337
  const result: ChatMessage = { role: "tool", tool_call_id: call.id, content: output, is_error: toolResultFailed(name, output) };
289
338
  msgs.push(result); results.push(result);
290
339
  if (this.approvalStopReason) { this.abort?.abort(); throw new ApprovalWaitStop(this.approvalStopReason); }
291
- if (!remoteTurn) 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}`);
292
341
  // Hub tool arguments/results are not capture evidence; Tasks owns its screened memory writes.
293
342
  // doc_read text is converted document content (a converter can surface hidden text), never memory evidence: not captured, whatever the turn.
294
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).
295
- 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)) });
296
345
  }
297
346
  } finally { if (results.length) this.routes.observeResults(res.routeDecision, res.message, results); }
298
347
  }
299
- if (usedTools) capture?.summarize(progress.last);
348
+ if (usedTools && !reviewScope.excluded) capture?.summarize(progress.last);
349
+ this.opts.onLimit?.("steps");
300
350
  return `(stopped after ${maxSteps} steps) ${progress.last}`.trim();
301
351
  }
302
352
 
@@ -353,10 +403,11 @@ export class LocalPeer extends BasePeer {
353
403
  };
354
404
  }
355
405
 
356
- private reportBudgetStop(e: Error, msgs: ChatMessage[], progress: { sideEffects: number; last: string }, reply: EnvelopeOpts, privateHistory: boolean, deliveryId?: string, remoteTurn = false): void {
357
- 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;
358
409
  this.completeMissingToolResults(msgs, `not run: ${message}`);
359
- const detail = remoteTurn ? message : progress.sideEffects
410
+ const detail = remoteTurn || reviewTurn ? message : progress.sideEffects
360
411
  ? `(execution budget stopped after ${progress.sideEffects} tool call(s) with side effects; partial work may exist and needs review) ${progress.last}`.trim()
361
412
  : `(execution budget stopped before any tool side effects; no work was repeated) ${progress.last}`.trim();
362
413
  msgs.push({ role: "assistant", content: detail });
@@ -401,18 +452,25 @@ export class LocalPeer extends BasePeer {
401
452
  }
402
453
  const fixedModel = policy?.fixedModel ?? this.opts.fixedModel;
403
454
  const task = policy?.task === undefined ? undefined : Number(policy.task);
404
- const tools = [...this.toolSchemas(), ...(this.opts.taskTool ? [...profileTaskTools(this.opts.profile, this.opts.sdlcStages, this.opts.sdlcCriteria).filter(tool => tool.name !== "hub_delivery_retry"), ...CONDUCTOR_TOOLS].map(asFunction) : [])];
455
+ const tools = [...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) : [])];
405
456
  const signal = this.abort!.signal;
406
457
  const messages: ChatMessage[] = [{ role: "system", content: system(this.opts.cwd, this.opts.preamble) }, ...this.history, ...turnMsgs];
407
458
  const routed = await this.hubCall(route, messages, policy, signal);
408
459
  if (routed) return routed;
409
460
  await this.requireBudget(envs, "model_calls");
461
+ this.releaseObserverInput(this.currentObserverInputContext, envs);
410
462
  const res = await omni.chat({ model: fixedModel, messages, tools }, { signal, ...(policy?.pii ? { onCampusOnly: true } : {}) });
411
463
  this.lastServedBy = `omniroute ${fixedModel} (provider ${res.provider ?? "?"})`;
412
464
  this.recordUsage(res, fixedModel, task);
413
465
  return res;
414
466
  }
415
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
+
416
474
  private beginRouteTurn(generation: number, pii: boolean, task?: string): string {
417
475
  let id: string | undefined;
418
476
  try { id = this.opts.turnId?.(); } catch { /* optional host metadata */ }
@@ -457,6 +515,7 @@ export class LocalPeer extends BasePeer {
457
515
  const feedback = await this.routes.review(route, config, messages, this.routeScope(!!policy?.pii, turn), !!policy?.pii, signal, decisionId);
458
516
  if (turn !== this.turn || !feedback) return answer;
459
517
  msgs.push({ role: "user", content: feedback });
518
+ if (step + 1 >= maxSteps) this.opts.onLimit?.("steps");
460
519
  return step + 1 < maxSteps ? undefined : `(review requested changes; step limit reached) ${answer}`;
461
520
  }
462
521