@staix/agent-hub 0.34.1 → 0.35.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 (159) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +35 -6
  3. package/docs/adding-an-ai-tool.md +2 -2
  4. package/docs/addons/claude-mem.md +4 -4
  5. package/docs/addons/open-code-review.md +1 -1
  6. package/docs/agent-notes/README.md +6 -0
  7. package/docs/agent-notes/adapters.md +9 -5
  8. package/docs/agent-notes/budget.md +1 -1
  9. package/docs/agent-notes/bus.md +1 -1
  10. package/docs/agent-notes/daemon.md +4 -3
  11. package/docs/agent-notes/local-worker.md +1 -1
  12. package/docs/agent-notes/models.md +2 -0
  13. package/docs/agent-notes/tasks.md +1 -1
  14. package/docs/agent-notes/tests.md +5 -0
  15. package/docs/design/adapters.md +110 -2
  16. package/docs/design/agent-hub-architecture.md +63 -1
  17. package/docs/design/agent-hub.md +6 -0
  18. package/docs/design/bus.md +18 -0
  19. package/docs/design/execution-reference.md +146 -0
  20. package/docs/design/task-board.md +10 -0
  21. package/docs/dogfood.md +33 -12
  22. package/docs/events.md +38 -20
  23. package/docs/memory.md +21 -7
  24. package/docs/operations.md +539 -24
  25. package/docs/quickstart.md +12 -4
  26. package/docs/research.md +71 -13
  27. package/docs/roles.md +1 -1
  28. package/docs/sdlc.md +9 -7
  29. package/docs/security.md +87 -11
  30. package/docs/smoke.md +14 -0
  31. package/docs/specs/2026-09-19-agent-hub-design.md +8 -3
  32. package/docs/verification/2026-11-07-compat-python-startup-1248.md +51 -0
  33. package/docs/verification/2026-11-07-compat-receipt-harness-1248.md +105 -0
  34. package/docs/verification/2026-11-07-compat-receipt-producers-1248.md +96 -0
  35. package/docs/verification/2026-11-07-compat-workflow-1248.md +94 -0
  36. package/docs/verification/2026-11-07-daemon-startup-1232.md +169 -0
  37. package/docs/verification/2026-11-07-jcode-receipt-reconstruction-1248.md +118 -0
  38. package/docs/verification/2026-11-07-kimi-delayed-usage.md +34 -0
  39. package/docs/verification/2026-11-07-kimi-review-watch-1284.md +115 -0
  40. package/docs/verification/2026-11-08-agy-1.5.3.md +96 -0
  41. package/docs/verification/2026-11-08-agy-acp-t0.md +56 -0
  42. package/docs/verification/2026-11-08-hub-visual-1058.md +161 -0
  43. package/docs/verification/2026-11-08-jcode-0991-model-setter-1317.md +143 -0
  44. package/docs/verification/2026-11-08-kimi-252-model-setter-1317.md +137 -0
  45. package/docs/verification/2026-11-08-kimi-tui-completion-1282.md +45 -0
  46. package/docs/verification/2026-11-08-kimi-wire-yield-1283.md +31 -0
  47. package/docs/verification/2026-11-08-live-model-override-1317.md +250 -0
  48. package/docs/verification/2026-11-08-qwen-0269-acp.md +138 -0
  49. package/docs/verification/2026-11-08-qwen-0269-model-setter-1317.md +91 -0
  50. package/docs/verification/2026-11-09-0.35.0.md +129 -0
  51. package/docs/verification/2026-11-09-headless-records-1320.md +157 -0
  52. package/docs/verification/claude-permission-transport-native.py +10 -3
  53. package/docs/verification/claude-shell-wrapper-native.py +8 -8
  54. package/docs/verified.json +245 -15
  55. package/package.json +3 -3
  56. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  57. package/plugins/agent-hub/kimi-hook.js +20397 -0
  58. package/plugins/agent-hub/kimi-review-policy-worker.js +217 -0
  59. package/plugins/agent-hub/server.js +2701 -664
  60. package/scripts/check-docs.mjs +12 -1
  61. package/src/adapters/acp-model-policy.ts +56 -0
  62. package/src/adapters/acp.ts +175 -5
  63. package/src/adapters/agy/entry.ts +8 -5
  64. package/src/adapters/agy/hooks.ts +65 -15
  65. package/src/adapters/channel-peer.ts +33 -1
  66. package/src/adapters/claude-implementer.ts +16 -3
  67. package/src/adapters/codex-appserver.ts +118 -8
  68. package/src/adapters/codex-review-isolation.ts +3 -1
  69. package/src/adapters/compat.ts +11 -6
  70. package/src/adapters/definitions/builtin-metadata.ts +8 -1
  71. package/src/adapters/definitions/native-cli-entry-kimi.ts +76 -7
  72. package/src/adapters/definitions/native-cli-preview-common.ts +2 -2
  73. package/src/adapters/definitions/native-cli-preview-kimi.ts +9 -2
  74. package/src/adapters/definitions/reviewer-variants.ts +3 -1
  75. package/src/adapters/kimi-review-policy-worker.js +2 -0
  76. package/src/adapters/kimi-review-policy-worker.ts +85 -0
  77. package/src/adapters/kimi-review-policy.ts +68 -0
  78. package/src/adapters/kimi-review.ts +41 -41
  79. package/src/adapters/kimi-tui-facts.ts +153 -0
  80. package/src/adapters/kimi-wire-usage.ts +33 -8
  81. package/src/adapters/local-worker.ts +59 -20
  82. package/src/adapters/model-token.ts +3 -0
  83. package/src/adapters/pi.ts +27 -5
  84. package/src/adapters/registry-types.ts +34 -3
  85. package/src/adapters/registry.ts +1 -0
  86. package/src/adapters/reviewer-policy-watch.ts +86 -0
  87. package/src/adapters/reviewer-print.ts +80 -18
  88. package/src/cli/agents.ts +1 -1
  89. package/src/cli/facts-hook.ts +4 -2
  90. package/src/cli/help.ts +3 -2
  91. package/src/cli/identity.ts +3 -0
  92. package/src/cli/kimi-facts-hook.ts +11 -0
  93. package/src/cli/kimi-plugin.ts +157 -0
  94. package/src/cli/main.ts +48 -9
  95. package/src/cli/model.ts +52 -0
  96. package/src/cli/output.ts +23 -1
  97. package/src/cli/reset.ts +1 -1
  98. package/src/cli/status-lines.ts +8 -4
  99. package/src/hub/budget.ts +1 -1
  100. package/src/hub/bus.ts +40 -2
  101. package/src/hub/code-facts.ts +10 -3
  102. package/src/hub/completions.ts +3 -1
  103. package/src/hub/config-apply.ts +2 -1
  104. package/src/hub/control-client.ts +1 -1
  105. package/src/hub/daemon.ts +1050 -106
  106. package/src/hub/deliverable-files.ts +38 -5
  107. package/src/hub/dogfood-profile.ts +1 -1
  108. package/src/hub/envelope.ts +1 -1
  109. package/src/hub/events.ts +28 -18
  110. package/src/hub/execution-lineage.ts +120 -0
  111. package/src/hub/execution-reference.ts +36 -0
  112. package/src/hub/hub-tools.ts +21 -2
  113. package/src/hub/instances.ts +4 -3
  114. package/src/hub/kimi-events.ts +46 -0
  115. package/src/hub/kimi-usage.ts +132 -29
  116. package/src/hub/lifecycle.ts +31 -15
  117. package/src/hub/limits.ts +13 -4
  118. package/src/hub/manager.ts +15 -2
  119. package/src/hub/memory-policy.ts +2 -1
  120. package/src/hub/memory-sync-attestation.ts +54 -0
  121. package/src/hub/memory-sync-state.ts +20 -5
  122. package/src/hub/model-overrides.ts +115 -0
  123. package/src/hub/native-hook-launch.ts +12 -3
  124. package/src/hub/operational-view.ts +2 -2
  125. package/src/hub/peers.ts +27 -0
  126. package/src/hub/permission-mode.ts +1 -1
  127. package/src/hub/person-figures.ts +88 -0
  128. package/src/hub/report.ts +38 -4
  129. package/src/hub/research-dataset.ts +158 -0
  130. package/src/hub/research-memory.ts +68 -0
  131. package/src/hub/research-quality.ts +118 -0
  132. package/src/hub/research-record.ts +279 -0
  133. package/src/hub/research.ts +126 -27
  134. package/src/hub/restart.ts +14 -0
  135. package/src/hub/settings.ts +3 -0
  136. package/src/hub/startup-event-seeds.ts +51 -0
  137. package/src/hub/startup-readiness.ts +65 -0
  138. package/src/hub/startup-timing.ts +38 -0
  139. package/src/hub/tasks.ts +29 -9
  140. package/src/hub/ui.ts +9 -5
  141. package/src/hub/visual-assets.ts +50 -0
  142. package/src/hub/visual.ts +257 -0
  143. package/src/memory/brief.ts +169 -14
  144. package/src/memory/capture.ts +2 -1
  145. package/src/memory/recall.ts +46 -5
  146. package/src/memory/record-provenance.ts +82 -0
  147. package/src/memory/records.ts +116 -0
  148. package/src/models/route/adec.ts +190 -0
  149. package/src/models/route/config.ts +37 -3
  150. package/src/models/route/decision-evidence.ts +83 -0
  151. package/src/models/route/decision.ts +143 -0
  152. package/src/models/route/runtime.ts +13 -6
  153. package/src/ui/index.html +134 -6
  154. package/src/ui/plugin.ts +65 -4
  155. package/templates/AGENTS.block.md +1 -1
  156. package/templates/config.json +1 -1
  157. package/templates/profiles/general.json +1 -1
  158. package/templates/profiles/research.json +1 -1
  159. package/templates/profiles/writing.json +1 -1
@@ -1,4 +1,4 @@
1
- import { closeSync, fstatSync, lstatSync, openSync, readSync, readdirSync, statSync } from "node:fs";
1
+ import { closeSync, fstatSync, lstatSync, openSync, readSync as fsReadSync, readdirSync, statSync } from "node:fs";
2
2
  import { createHash } from "node:crypto";
3
3
  import { join, sep } from "node:path";
4
4
  import { realPath } from "../hub/project.ts";
@@ -43,6 +43,10 @@ export type KimiWireRead =
43
43
  | { status: "ok"; rows: KimiWireRow[]; /** bytes remain unread because of the per-call bound: read again */ more: boolean }
44
44
  | { status: "unknown"; reason: KimiWireReason };
45
45
 
46
+ export type KimiWireSlice =
47
+ | { status: "ok"; rows: KimiWireRow[]; more: boolean; commit(): void }
48
+ | { status: "unknown"; reason: KimiWireReason };
49
+
46
50
  /** What counts against `budget.kimi_tokens_5h`: new input, cache writes and output. Cache reads are recorded and shown, never counted. */
47
51
  export function kimiCountedTokens(row: Pick<KimiWireRow, "inputOther" | "inputCacheCreation" | "output">): number {
48
52
  return row.inputOther + row.inputCacheCreation + row.output;
@@ -88,6 +92,8 @@ export class KimiWireReader {
88
92
  private dir: string | undefined;
89
93
  private files = new Map<string, FileState>();
90
94
  private broken: KimiWireReason | undefined;
95
+ private bytes = 0;
96
+ private discovery = 0;
91
97
 
92
98
  constructor(options: KimiWireOptions) {
93
99
  this.home = options.home;
@@ -95,15 +101,34 @@ export class KimiWireReader {
95
101
  this.chunk = Math.max(LINE_CAP * 2, options.maxBytes ?? DEFAULT_CHUNK);
96
102
  }
97
103
 
104
+ private readBytes(fd: number, buffer: Buffer, offset: number, length: number, position: number): number {
105
+ const read = fsReadSync(fd, buffer, offset, length, position);
106
+ this.bytes += read;
107
+ return read;
108
+ }
109
+
98
110
  /** The rows written since the last successful read, sub-agents included. */
99
111
  read(): KimiWireRead {
112
+ const slice = this.readSlice();
113
+ if (slice.status === "unknown") return slice;
114
+ const { commit, ...read } = slice;
115
+ commit();
116
+ return read;
117
+ }
118
+
119
+ /** Numeric I/O observations for deterministic benchmarks; no path, row or native text. */
120
+ stats(): { bytes: number; discovery: number } { return { bytes: this.bytes, discovery: this.discovery }; }
121
+
122
+ /** A successful slice commits its offsets only after the caller has committed every row it returned. */
123
+ readSlice(): KimiWireSlice {
100
124
  if (this.broken) return { status: "unknown", reason: this.broken };
125
+ this.bytes = 0; this.discovery = 0;
101
126
  try {
102
- const dir = this.resolve();
127
+ const dir = this.resolve(); this.discovery++;
103
128
  const next = new Map<string, FileState>();
104
129
  const rows: KimiWireRow[] = [];
105
130
  let more = false, budget = this.chunk;
106
- const agents = this.agents(dir);
131
+ const agents = this.agents(dir); this.discovery++;
107
132
  for (const name of this.files.keys()) if (!agents.has(name)) throw new Unknown("missing");
108
133
  for (const [name, wire] of agents) {
109
134
  const previous = this.files.get(name);
@@ -114,8 +139,8 @@ export class KimiWireReader {
114
139
  budget -= result.read;
115
140
  more ||= result.more;
116
141
  }
117
- this.files = next;
118
- return { status: "ok", rows, more };
142
+ let committed = false;
143
+ return { status: "ok", rows, more, commit: () => { if (!committed) { this.files = next; committed = true; } } };
119
144
  } catch (error) {
120
145
  const reason: KimiWireReason = error instanceof Unknown ? error.reason : "io-error";
121
146
  if (!TRANSIENT.has(reason)) this.broken = reason; // the log no longer matches what was counted: stays unknown for this session
@@ -161,7 +186,7 @@ export class KimiWireReader {
161
186
  const buffer = Buffer.allocUnsafe(stat.size);
162
187
  let got = 0;
163
188
  while (got < stat.size) {
164
- const n = readSync(fd, buffer, got, stat.size - got, got);
189
+ const n = this.readBytes(fd, buffer, got, stat.size - got, got);
165
190
  if (n <= 0) break;
166
191
  got += n;
167
192
  }
@@ -206,7 +231,7 @@ export class KimiWireReader {
206
231
  let got = 0;
207
232
  while (got < length) {
208
233
  let n: number;
209
- try { n = readSync(fd, buffer, got, length - got, start + got); } catch { throw new Unknown("io-error"); }
234
+ try { n = this.readBytes(fd, buffer, got, length - got, start + got); } catch { throw new Unknown("io-error"); }
210
235
  if (n <= 0) throw new Unknown("shrunk");
211
236
  got += n;
212
237
  }
@@ -234,7 +259,7 @@ export class KimiWireReader {
234
259
  let got = 0;
235
260
  while (got < length) {
236
261
  let n: number;
237
- try { n = readSync(fd, buffer, got, length - got, state.offset + got); } catch { throw new Unknown("io-error"); }
262
+ try { n = this.readBytes(fd, buffer, got, length - got, state.offset + got); } catch { throw new Unknown("io-error"); }
238
263
  if (n <= 0) break;
239
264
  got += n;
240
265
  }
@@ -1,4 +1,7 @@
1
+ import type { ModelOverrideSelection } from "./registry-types.ts";
2
+ import { isNativeModelToken } from "./model-token.ts";
1
3
  import { isReviewObserverExcluded, reviewCaptureExcluded } from "../hub/memory-policy.ts";
4
+ import { DecisionPolicyChangedError, type DecisionHost } from "../models/route/decision.ts";
2
5
  import { HubRouteRuntime, type RouteEvent, type AdvisorEvent } from "../models/route/runtime.ts";
3
6
  import type { HubRoute } from "../models/route/config.ts";
4
7
  import type { RouteLabelEvent, RouteTurnOutcome } from "../models/route/labels.ts";
@@ -21,6 +24,8 @@ import type { ExecutionBudgetDecision } from "../hub/execution-budget.ts";
21
24
  import type { OwnedInputContext, OwnedInputObserver } from "./claude-print-child.ts";
22
25
 
23
26
  export interface LocalOptions {
27
+ /** Host-only choice sampled once at the next turn/review boundary. */
28
+ getModelOverride?: () => ModelOverrideSelection;
24
29
  profile?: import("../hub/profiles.ts").ProfileSelection;
25
30
  sdlcStages?: readonly string[];
26
31
  sdlcCriteria?: boolean;
@@ -33,6 +38,9 @@ export interface LocalOptions {
33
38
  /** Model alias sent straight to OmniRoute when no hub route is active or its call fails. */
34
39
  fixedModel: string;
35
40
  hubRoutes?: () => Record<string, HubRoute>;
41
+ /** Current host public-view screen for bounded decision evidence; absent means no text is released. */
42
+ routeEvidencePublic?: (text: string) => boolean;
43
+ routeDecisionContext?: DecisionHost["decisionContext"];
36
44
  /** routing.toml `stay_switch` for hub stage routes (#197). */
37
45
  staySwitch?: () => StaySwitchPolicy | undefined;
38
46
  onRoute?: (event: RouteEvent) => void;
@@ -116,6 +124,7 @@ export class LocalPeer extends BasePeer {
116
124
  private readonly warnedLegacyRoutes = new Set<string>();
117
125
  private routeEnvs: Envelope[] = [];
118
126
  private routePii = false;
127
+ private routeReviewScope: { excluded: boolean } | undefined;
119
128
  private routeTask?: number;
120
129
  private labelTurnId?: string;
121
130
  private turn = 0; // generation guard, as in acp.ts: a turn aborted by the watchdog must not touch the next one
@@ -153,6 +162,10 @@ export class LocalPeer extends BasePeer {
153
162
  return result;
154
163
  },
155
164
  onCampus: () => this.opts.omni.onCampus(),
165
+ decisionContext: () => this.decisionContext(),
166
+ publicDecisionText: text => this.opts.routeEvidencePublic?.(text) === true,
167
+ admitDecision: () => this.requireBudget(this.routeEnvs, "model_calls"),
168
+ onDecisionIssue: reason => this.opts.log?.(`decision route observation ${reason}; using the original heuristic; check adec serve and routing.toml policy settings, then retry`),
156
169
  staySwitch: () => this.opts.staySwitch?.(),
157
170
  onRoute: event => this.opts.onRoute?.(event),
158
171
  onAdvisor: event => this.opts.onAdvisor?.(event),
@@ -161,6 +174,13 @@ export class LocalPeer extends BasePeer {
161
174
  this.sandboxProfile = profile(opts.cwd, opts.tools.bashNetwork ?? false, opts.tools.readAllow, opts.tools.deny, opts.tools.readOnly);
162
175
  }
163
176
 
177
+ private decisionContext(): { pii: boolean; private: boolean; excluded: boolean } {
178
+ const current = this.opts.routeDecisionContext?.();
179
+ if (this.opts.routeDecisionContext && (!current || typeof current.private !== "boolean" || typeof current.excluded !== "boolean" || current.pii !== undefined && typeof current.pii !== "boolean")) throw new DecisionPolicyChangedError();
180
+ return { pii: this.routePii || current?.pii === true, private: this.routeEnvs.some(env => env.private === true) || current?.private === true,
181
+ excluded: this.routeEnvs.some(env => remoteEnvelope(env) || isReviewObserverExcluded(env)) || this.routeReviewScope?.excluded === true || current?.excluded === true };
182
+ }
183
+
164
184
  recoveryMetadata(): Record<string, unknown> {
165
185
  return { launch: { kind: "local", cwd: this.opts.cwd, model: this.opts.fixedModel, ...(this.opts.route ? { route: this.opts.route } : {}) }, sessionId: this.sessionId };
166
186
  }
@@ -202,11 +222,22 @@ export class LocalPeer extends BasePeer {
202
222
  this.activeDeliveryId = deliveryId;
203
223
  this.abort = new AbortController();
204
224
  this.setState("busy");
225
+ let selected: ModelOverrideSelection | undefined, modelFailure: Error | undefined;
226
+ try {
227
+ selected = this.opts.getModelOverride?.();
228
+ if (selected?.model !== undefined && !isNativeModelToken(selected.model)) throw new Error("invalid model");
229
+ } catch { modelFailure = new Error("runtime model selection unavailable; check the peer model setting and retry"); }
230
+ let modelApplied = false;
231
+ const captured = selected;
232
+ const selection = captured && { model: captured.model, applied: () => {
233
+ if (modelApplied || turn !== this.turn) return;
234
+ modelApplied = true; try { captured.applied(); } catch { /* Status acknowledgement is not model authority. */ }
235
+ } };
205
236
  if (deliveryId) this.delivery({ id: deliveryId, state: "accepted" });
206
237
  // The turn works on its own message list and joins the history only as a whole, so a failed or aborted turn can
207
238
  // never leave a tool call without its result (strict servers reject that history forever after).
208
239
  const msgs: ChatMessage[] = [{ role: "user", content: [envs.some(env => env.private || remoteEnvelope(env)) ? undefined : this.opts.titlePreface?.(), renderDigest(envs, true)].filter(Boolean).join("\n") }];
209
- const progress = { sideEffects: 0, last: "" };
240
+ const progress = { sideEffects: 0, last: "", answerComplete: false };
210
241
  const requestedPolicy = this.opts.turnPolicy?.(envs);
211
242
  const policy = envs.some(env => env.private) ? { ...requestedPolicy, pii: true } : requestedPolicy;
212
243
  const remoteTurn = envs.some(remoteEnvelope);
@@ -217,11 +248,11 @@ export class LocalPeer extends BasePeer {
217
248
  // A PII turn speaks to the console user only, and privately: its answer must not be broadcast to cloud-hosted peers.
218
249
  // The task id travels with it, so the board can keep the text (`ahub task show`) while tail and log show a stub.
219
250
  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 } } : {}) } : {}) };
220
- this.run(envs, turn, msgs, progress, policy, reply, reviewScope)
251
+ this.run(envs, turn, msgs, progress, policy, reply, reviewScope, selection, modelFailure)
221
252
  .then((answer) => {
222
253
  if (turn !== this.turn) return;
223
254
  if (preserveHistory && !reviewScope.excluded) this.commit(msgs); // Private, remote and review turns never enter later local capture through model history.
224
- if (answer) this.onMessage?.(answer, reply);
255
+ if (answer) this.finalAnswer(answer, reply, progress.answerComplete);
225
256
  if (deliveryId && this.activeDeliveryId === deliveryId) this.delivery({ id: deliveryId, state: "completed" });
226
257
  completed = true;
227
258
  })
@@ -278,11 +309,14 @@ export class LocalPeer extends BasePeer {
278
309
  envs: Envelope[],
279
310
  turn: number,
280
311
  msgs: ChatMessage[],
281
- progress: { sideEffects: number; last: string },
312
+ progress: { sideEffects: number; last: string; answerComplete: boolean },
282
313
  policy: { route?: string; fixedModel?: string; pii: boolean; task?: string } | undefined,
283
314
  reply: EnvelopeOpts,
284
315
  reviewScope: { excluded: boolean },
316
+ selection?: ModelOverrideSelection,
317
+ modelFailure?: Error,
285
318
  ): Promise<string> {
319
+ if (modelFailure) throw modelFailure;
286
320
  const { maxSteps = 30 } = this.opts;
287
321
  const turnSignal = this.abort!.signal;
288
322
  // Remote text/tool output is never memory evidence, even beside ordinary local work in the same turn.
@@ -294,22 +328,26 @@ export class LocalPeer extends BasePeer {
294
328
  }
295
329
  this.routeEnvs = envs;
296
330
  this.routePii = !!policy?.pii;
331
+ this.routeReviewScope = reviewScope;
332
+ if (turn !== this.turn || turnSignal.aborted) return "";
333
+ const fixedPrompt = this.turnRecordPrompt(system(this.opts.cwd, this.opts.preamble));
297
334
  const ctx = this.toolContext(envs, turnSignal, !!policy?.pii, reply);
298
335
  let usedTools = false;
299
336
  let captureInvocation = 0;
300
337
  for (let step = 0; step < maxSteps; step++) {
301
338
  this.elide(msgs);
302
- const res = await this.call(msgs, policy, envs);
339
+ const res = await this.call(msgs, policy, envs, selection, fixedPrompt);
303
340
  if (turn !== this.turn) return "";
304
341
  this.touch();
305
342
  msgs.push(res.message);
306
343
  progress.last = res.message.content?.trim() || progress.last;
307
344
  if (!res.message.tool_calls?.length) {
308
- const concluded = await this.conclude(msgs, policy, turn, step, maxSteps, progress.last, turnSignal, res.routeDecision);
345
+ const concluded = await this.conclude(msgs, policy, turn, step, maxSteps, progress.last, turnSignal, res.routeDecision, selection, fixedPrompt);
309
346
  if (turn !== this.turn) return "";
310
347
  if (concluded === undefined) continue;
311
- if (usedTools && !reviewScope.excluded) capture?.summarize(concluded);
312
- return concluded;
348
+ if (usedTools && !reviewScope.excluded) capture?.summarize(concluded.answer);
349
+ progress.answerComplete = concluded.complete;
350
+ return concluded.answer;
313
351
  }
314
352
  if (res.message.tool_calls.some(call => call.function.name.replace(/^mcp__[^ ]+__/, "") === "hub_review" && reviewCaptureExcluded(call.function.name, call.function.arguments))) reviewScope.excluded = true;
315
353
  const results: ChatMessage[] = [];
@@ -443,23 +481,24 @@ export class LocalPeer extends BasePeer {
443
481
  }
444
482
 
445
483
  /** Hub-owned in-process policy, with a fixed OmniRoute alias as fallback. */
446
- private async call(turnMsgs: ChatMessage[], policy: { route?: string; fixedModel?: string; pii?: boolean; task?: string } | undefined, envs: Envelope[]): Promise<ChatResult> {
484
+ private async call(turnMsgs: ChatMessage[], policy: { route?: string; fixedModel?: string; pii?: boolean; task?: string } | undefined, envs: Envelope[], selection?: ModelOverrideSelection, fixedPrompt = system(this.opts.cwd, this.opts.preamble)): Promise<ChatResult> {
447
485
  const { omni } = this.opts;
448
- const route = effectiveLocalRoute(this.opts.route, policy?.route, policy?.fixedModel);
486
+ const route = selection?.model === undefined ? effectiveLocalRoute(this.opts.route, policy?.route, policy?.fixedModel) : undefined;
449
487
  if (route && !route.startsWith("hub/") && !this.warnedLegacyRoutes.has(route)) {
450
488
  this.warnedLegacyRoutes.add(route);
451
489
  this.opts.log?.(`legacy route ${route} is deprecated and ignored; using fixed model`);
452
490
  }
453
- const fixedModel = policy?.fixedModel ?? this.opts.fixedModel;
491
+ const fixedModel = selection?.model ?? policy?.fixedModel ?? this.opts.fixedModel;
454
492
  const task = policy?.task === undefined ? undefined : Number(policy.task);
455
493
  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) : [])];
456
494
  const signal = this.abort!.signal;
457
- const messages: ChatMessage[] = [{ role: "system", content: system(this.opts.cwd, this.opts.preamble) }, ...this.history, ...turnMsgs];
495
+ const messages: ChatMessage[] = [{ role: "system", content: fixedPrompt }, ...this.history, ...turnMsgs];
458
496
  const routed = await this.hubCall(route, messages, policy, signal);
459
- if (routed) return routed;
497
+ if (routed) { selection?.applied(); return routed; }
460
498
  await this.requireBudget(envs, "model_calls");
461
499
  this.releaseObserverInput(this.currentObserverInputContext, envs);
462
500
  const res = await omni.chat({ model: fixedModel, messages, tools }, { signal, ...(policy?.pii ? { onCampusOnly: true } : {}) });
501
+ selection?.applied();
463
502
  this.lastServedBy = `omniroute ${fixedModel} (provider ${res.provider ?? "?"})`;
464
503
  this.recordUsage(res, fixedModel, task);
465
504
  return res;
@@ -502,21 +541,21 @@ export class LocalPeer extends BasePeer {
502
541
  if (!route || !config) return undefined;
503
542
  try { return await this.routes.call(route, config, messages, this.routeScope(!!policy?.pii), !!policy?.pii, signal); }
504
543
  catch (error) {
505
- if (signal.aborted || error instanceof ExecutionBudgetStop) throw error;
544
+ if (signal.aborted || error instanceof ExecutionBudgetStop || error instanceof DecisionPolicyChangedError) throw error;
506
545
  this.opts.log?.(`hub route ${route} unavailable; using fixed model`);
507
546
  return undefined;
508
547
  }
509
548
  }
510
549
 
511
- private async conclude(msgs: ChatMessage[], policy: { route?: string; fixedModel?: string; pii: boolean } | undefined, turn: number, step: number, maxSteps: number, answer: string, signal: AbortSignal, decisionId?: string): Promise<string | undefined> {
512
- const route = effectiveLocalRoute(this.opts.route, policy?.route, policy?.fixedModel), config = this.hubConfig(route);
513
- if (!route || !config) return answer;
514
- const messages: ChatMessage[] = [{ role: "system", content: system(this.opts.cwd, this.opts.preamble) }, ...this.history, ...msgs];
550
+ private async conclude(msgs: ChatMessage[], policy: { route?: string; fixedModel?: string; pii: boolean } | undefined, turn: number, step: number, maxSteps: number, answer: string, signal: AbortSignal, decisionId?: string, selection?: ModelOverrideSelection, fixedPrompt = system(this.opts.cwd, this.opts.preamble)): Promise<{ answer: string; complete: boolean } | undefined> {
551
+ const route = selection?.model === undefined ? effectiveLocalRoute(this.opts.route, policy?.route, policy?.fixedModel) : undefined, config = this.hubConfig(route);
552
+ if (!route || !config) return { answer, complete: true };
553
+ const messages: ChatMessage[] = [{ role: "system", content: fixedPrompt }, ...this.history, ...msgs];
515
554
  const feedback = await this.routes.review(route, config, messages, this.routeScope(!!policy?.pii, turn), !!policy?.pii, signal, decisionId);
516
- if (turn !== this.turn || !feedback) return answer;
555
+ if (turn !== this.turn || !feedback) return { answer, complete: true };
517
556
  msgs.push({ role: "user", content: feedback });
518
557
  if (step + 1 >= maxSteps) this.opts.onLimit?.("steps");
519
- return step + 1 < maxSteps ? undefined : `(review requested changes; step limit reached) ${answer}`;
558
+ return step + 1 < maxSteps ? undefined : { answer: `(review requested changes; step limit reached) ${answer}`, complete: false };
520
559
  }
521
560
 
522
561
  private observeTool(call: ToolCall, output: string, policy: { pii: boolean; task?: string } | undefined, nativeTurn: string): void {
@@ -0,0 +1,3 @@
1
+ /** #844 native model word: no spaces or leading dash, at most 128 ASCII bytes. */
2
+ const NATIVE_WORD = /^[A-Za-z0-9][A-Za-z0-9._:/@+-]{0,127}$/;
3
+ export function isNativeModelToken(value: unknown): value is string { return typeof value === "string" && NATIVE_WORD.test(value); }
@@ -1,3 +1,5 @@
1
+ import type { ModelOverrideSelection } from "./registry-types.ts";
2
+ import { isNativeModelToken } from "./model-token.ts";
1
3
  import { buildPiLaunch } from "../pi/launch.ts";
2
4
  import { randomUUID } from "node:crypto";
3
5
  import { existsSync, mkdirSync } from "node:fs";
@@ -41,6 +43,8 @@ export interface PiOptions {
41
43
  hubStateDir?: string;
42
44
  cwd: string; stateDir: string; cmd?: string[]; mode: "headless" | "tui"; backend: "auto" | "dgx" | "mlx"; sessionFile?: string; sessionId?: string;
43
45
  model?: string;
46
+ /** Host-only choice sampled once at the next turn/review boundary. */
47
+ getModelOverride?: () => ModelOverrideSelection;
44
48
  relay: PiRelay; executeTool: (name: string, args: unknown, toolCallId: string, sessionId?: string, signal?: AbortSignal, context?: PiToolContext) => Promise<string | PiToolResult>; tools: PiToolSchema[];
45
49
  preamble?: string;
46
50
  /** Host-owned conditional instruction, sampled once per delivered turn. */
@@ -205,6 +209,8 @@ export class PiPeer extends BasePeer {
205
209
  private owner = randomUUID();
206
210
  private _tuiLaunch?: PiTuiLaunch;
207
211
  private requestedModel = "";
212
+ private baselineRequestedModel = "";
213
+ private runtimeModelSelected = false;
208
214
  private starting?: Promise<void>;
209
215
  private ownerClaimed = false;
210
216
  private ownerToken = "";
@@ -233,7 +239,7 @@ export class PiPeer extends BasePeer {
233
239
  private tuiWaiters: Array<{ resolve: (command: Record<string, unknown> | undefined) => void; timer: ReturnType<typeof setTimeout> }> = [];
234
240
 
235
241
  /** The fixed production factory supplies preflight separately, preserving transport option identity. */
236
- constructor(id: PeerId, private readonly opts: PiOptions, private readonly preflight?: () => Promise<void>) { super(id, opts.watchdogMs); }
242
+ constructor(id: PeerId, private readonly opts: PiOptions, private readonly preflight?: () => Promise<void>) { super(id, opts.watchdogMs); this.baselineRequestedModel = opts.model ?? ""; }
237
243
  get tuiLaunch(): PiTuiLaunch | undefined { return this._tuiLaunch; }
238
244
  get pendingResume(): { sessionId?: string; sessionFile?: string } {
239
245
  if (this.canReuseVerifiedEmptyResume()) return { sessionId: this.verifiedEmptyResume!.sessionId };
@@ -754,14 +760,23 @@ export class PiPeer extends BasePeer {
754
760
  let attempted = false;
755
761
  let promptId: string | undefined;
756
762
  try {
757
- const requested = await this.opts.selectModel?.(envs);
763
+ const selection = this.opts.getModelOverride?.();
764
+ if (selection?.model !== undefined && !isNativeModelToken(selection.model)) throw new Error("runtime model must be one bounded native token");
765
+ const baseline = await this.opts.selectModel?.(envs);
766
+ const requested = selection?.model ?? baseline ?? (this.baselineRequestedModel || undefined);
767
+ if (selection && selection.model === undefined && this.runtimeModelSelected && !requested) throw new Error(`baseline model unavailable; restore the peer model configuration and run ahub restart ${this.id} before retrying`);
758
768
  if (rosterEpoch !== this.rosterEpoch || !this.acceptingTools) throw new Error("Pi is off roster or stopped");
759
- if (requested) await this.setRequestedModel(requested);
769
+ if (requested) {
770
+ if (selection?.model !== undefined) await this.applyRequestedModel(requested);
771
+ else await this.setRequestedModel(requested);
772
+ this.runtimeModelSelected = selection?.model !== undefined;
773
+ }
760
774
  if (rosterEpoch !== this.rosterEpoch || !this.acceptingTools) throw new Error("Pi is off roster or stopped");
761
775
  this.noteActivity();
762
776
  attempted = true;
763
777
  if (deliveryId) this.activeDeliveryIds.add(deliveryId);
764
- const message = [envs.some(env => env.private || remoteEnvelope(env)) ? undefined : this.opts.titlePreface?.(), renderDigest(envs, true)].filter(Boolean).join("\n");
778
+ const recordPrompt = this.opts.mode === "tui" ? "" : this.turnRecordPrompt("");
779
+ const message = [recordPrompt, envs.some(env => env.private || remoteEnvelope(env)) ? undefined : this.opts.titlePreface?.(), renderDigest(envs, true)].filter(Boolean).join("\n");
765
780
  if (this.opts.mode === "tui") { const pending = this.sendTui({ type: "prompt", message }); promptId = this.pendingPromptId; await pending; }
766
781
  else await this.sendRpc({ type: "prompt", message });
767
782
  if (promptId !== undefined && promptId !== this.pendingPromptId) return;
@@ -770,6 +785,8 @@ export class PiPeer extends BasePeer {
770
785
  this.setState("offline");
771
786
  return;
772
787
  }
788
+ if (rosterEpoch !== this.rosterEpoch || !this.acceptingTools) throw new Error("Pi is off roster or stopped");
789
+ try { selection?.applied(); } catch { /* Only prompt acceptance proves this turn used the selected model or unchanged native default. */ }
773
790
  if (deliveryId && this.activeDeliveryIds.has(deliveryId)) this.delivery({ id: deliveryId, state: "accepted" });
774
791
  } catch (error) {
775
792
  if (promptId !== undefined && promptId !== this.pendingPromptId) return;
@@ -831,7 +848,12 @@ export class PiPeer extends BasePeer {
831
848
  }
832
849
 
833
850
  getRequestedModel(): string { return this.requestedModel; }
851
+ getBaselineRequestedModel(): string { return this.baselineRequestedModel; }
834
852
  async setRequestedModel(model: string): Promise<void> {
853
+ await this.applyRequestedModel(model);
854
+ this.baselineRequestedModel = model;
855
+ }
856
+ private async applyRequestedModel(model: string): Promise<void> {
835
857
  if (!this.acceptingTools) throw new Error("Pi is off roster or stopped");
836
858
  const slash = model.indexOf("/");
837
859
  const provider = "agent-hub-local";
@@ -971,7 +993,7 @@ export class PiPeer extends BasePeer {
971
993
  else if (error?.startsWith("execution budget")) this.onMessage?.(`Pi stopped at the execution budget: ${error}. Inspect partial work before continuing.`, reply);
972
994
  else if (approvalStopReason) this.onMessage?.(approvalStopReason, reply);
973
995
  else if (error) void this.opts.onTurnFailure?.(this.activeEnvs, error, ceiling);
974
- else if (text) this.onMessage?.(text, reply);
996
+ else if (text) this.finalAnswer(text, reply);
975
997
  for (const id of this.activeDeliveryIds) this.delivery({ id, state: approvalStopReason ? "completed" : error || cancelled ? "needs_review" : "completed", ...(error || cancelled ? { reason: error || "Pi turn cancelled; partial effects are possible" } : {}) });
976
998
  this.activeDeliveryIds.clear();
977
999
  this.currentReply = undefined; this.activeEnvs = [];
@@ -1,3 +1,11 @@
1
+ /** One host-latched runtime choice; applied binds the captured revision and boundary, never a later value. */
2
+ export interface ModelOverrideSelection {
3
+ readonly model: string | undefined;
4
+ applied(nativeModel?: string): void;
5
+ /** Retire only this still-unacknowledged capture after a definitive native pre-start rejection. */
6
+ rejected?(): void;
7
+ }
8
+
1
9
  import type { ClaudeImplementerOptions } from "./claude-implementer.ts";
2
10
  import type { ReviewerBackend, ReviewerPrintOptions } from "./reviewer-print.ts";
3
11
  import type { SettingDef } from "../hub/settings.ts";
@@ -126,7 +134,7 @@ export interface ContextReader {
126
134
  export type { Capability, CapabilityManifest, SideEffect } from "../hub/capabilities.ts";
127
135
 
128
136
  export interface NativeFactsInput {
129
- event: "SessionStart" | "PreInvocation" | "PostInvocation" | "PreToolUse" | "PostToolUse" | "Stop";
137
+ event: "SessionStart" | "PreInvocation" | "PostInvocation" | "PreToolUse" | "PostToolUse" | "Stop" | "TurnStarted" | "StopFailure" | "Interrupt" | "SessionEnd" | "TaskStarted" | "Notification";
130
138
  phase: "session" | "start" | "pre" | "post" | "stop";
131
139
  sessionId: string;
132
140
  transcriptPath: string;
@@ -138,15 +146,36 @@ export interface NativeFactsInput {
138
146
  fullyIdle?: boolean;
139
147
  terminationReason?: string;
140
148
  }
149
+ export interface NativeFactsReadOptions {
150
+ completion?: boolean;
151
+ sinceOffset?: number;
152
+ unchanged?: { size: number; mtimeMs: number };
153
+ }
154
+ export interface NativeFactsUsage {
155
+ id: string;
156
+ index: number;
157
+ inputTokens: number;
158
+ outputTokens: number;
159
+ cacheReadTokens: number;
160
+ totalTokens: number;
161
+ at?: string;
162
+ }
141
163
  export interface NativeFactsTranscript {
142
- completion?: { id: string; index: number };
164
+ completion?: { id: string; index: number; reason?: "completed" | "failed" | "cancelled" };
165
+ started?: { id: string; index: number; origin: string };
166
+ background?: number;
143
167
  factIds: string[];
168
+ usage?: NativeFactsUsage[];
169
+ size?: number;
170
+ mtimeMs?: number;
171
+ scannedFrom?: number;
172
+ unchanged?: boolean;
144
173
  }
145
174
  /** Fixed native codec and bounded readback, never supplied by project configuration. */
146
175
  export interface NativeFactsPort {
147
176
  decode(event: string, input: unknown): NativeFactsInput | undefined;
148
177
  encode(event: string, text?: string): Record<string, unknown> | undefined;
149
- read(session: string, transcript: string): NativeFactsTranscript;
178
+ read(session: string, transcript: string, options?: NativeFactsReadOptions): NativeFactsTranscript;
150
179
  }
151
180
 
152
181
  /** Only the frozen native seams are extensible; a definition cannot add an arbitrary daemon callback. */
@@ -300,6 +329,8 @@ export interface AdapterDefinition {
300
329
  validateEffective?(raw: Record<string, unknown>, fail: (key: string, reason: string) => never): void;
301
330
  };
302
331
  capabilities?: { manifest?(peer: PeerAdapter): Promise<CapabilityManifest>; watch?: boolean };
332
+ /** Fixed factory support only; the host still checks the actual native surface and form. */
333
+ modelOverride?: { readonly boundary: "turn"; readonly acp?: import("./acp-model-policy.ts").AcpModelPolicy };
303
334
  /** Stable host projection order and planner eligibility, independent of default task roles. */
304
335
  projection?: { roleRank?: number; startRank?: number; helpRank?: number; plannerEligible?: boolean };
305
336
  cli: {
@@ -243,6 +243,7 @@ export function assertNativeCapabilityScope(definition: AdapterDefinition): void
243
243
  ["titlePersistence", definition.titlePersistence !== undefined],
244
244
  ["questionWaitMs", definition.questionWaitMs !== undefined],
245
245
  ["settle", definition.settle !== undefined],
246
+ ["modelOverride", definition.modelOverride !== undefined],
246
247
  ["start.acpCommand", declared(definition.start, "acpCommand")],
247
248
  ["attach.admission", declared(definition.attach, "admission")],
248
249
  ["budget.resetSoftTokensOnResume", declared(definition.budget, "resetSoftTokensOnResume")],
@@ -0,0 +1,86 @@
1
+ /** A fixed host producer's full policy check, never a verdict-authority cache. */
2
+ export interface ReviewerPolicyProbe {
3
+ /** Boot/protocol failures revoke even when a child exits before its first periodic poll. */
4
+ readonly failure?: Promise<void>;
5
+ check(): Promise<boolean>;
6
+ stop(): Promise<void>;
7
+ }
8
+ export interface ReviewerPolicyWatch {
9
+ /** The caller attaches ownership before any timer or notification can run. */
10
+ start(): void;
11
+ /** Only an explicit owner retry may repeat a failed join. */
12
+ stop(retry?: boolean): Promise<void>;
13
+ }
14
+ export interface ReviewerPolicyWatchClock {
15
+ now(): number;
16
+ schedule(delayMs: number, run: () => void): () => void;
17
+ }
18
+ const POLICY_POLL_MS = 100;
19
+
20
+ /** Serialized absolute cadence: an overdue check runs now, coalescing missed slots at its start. */
21
+ export function createReviewerPolicyWatch(probe: ReviewerPolicyProbe, changed: () => void,
22
+ clock: Partial<ReviewerPolicyWatchClock> = {}): ReviewerPolicyWatch {
23
+ const now = clock.now ?? (() => performance.now());
24
+ const schedule = clock.schedule ?? ((delayMs: number, run: () => void) => {
25
+ const timer = setTimeout(run, delayMs); timer.unref?.();
26
+ return () => clearTimeout(timer);
27
+ });
28
+ let started = false, stopped = false, due = 0;
29
+ let cancelTimer: (() => void) | undefined, flight: Promise<void> | undefined;
30
+ let stopping: Promise<void> | undefined, stopFailed = false;
31
+ const sample = () => {
32
+ const value = now();
33
+ if (!Number.isFinite(value)) throw new Error("review policy clock unavailable");
34
+ return value;
35
+ };
36
+ const stop = (retry = false): Promise<void> => {
37
+ stopped = true;
38
+ cancelTimer?.(); cancelTimer = undefined;
39
+ if (retry && stopFailed) { stopping = undefined; stopFailed = false; }
40
+ if (!stopping) {
41
+ const pending = flight;
42
+ // Termination must settle a blocked check before joining that check; the reverse order can deadlock.
43
+ stopping = Promise.resolve().then(() => probe.stop()).then(async () => { await pending; });
44
+ void stopping.catch(() => { stopFailed = true; });
45
+ }
46
+ return stopping;
47
+ };
48
+ const fail = () => {
49
+ if (stopped) return;
50
+ void stop().catch(() => {}); // The owner retains and observes the same failed join.
51
+ try { changed(); } catch { /* The probe is already stopping; a notification cannot rearm it. */ }
52
+ };
53
+ const arm = () => {
54
+ if (stopped) return;
55
+ try { cancelTimer = schedule(Math.max(0, due - sample()), tick); }
56
+ catch { fail(); }
57
+ };
58
+ const tick = () => {
59
+ cancelTimer = undefined;
60
+ if (stopped) return;
61
+ if (flight) { fail(); return; }
62
+ try {
63
+ const at = sample();
64
+ if (at > due) due += Math.floor((at - due) / POLICY_POLL_MS) * POLICY_POLL_MS;
65
+ } catch { fail(); return; }
66
+ const pending = (async () => {
67
+ let current = false;
68
+ try { current = await probe.check() === true; } catch { /* A failed check is policy drift. */ }
69
+ if (stopped) return;
70
+ if (!current) { fail(); return; }
71
+ due += POLICY_POLL_MS;
72
+ arm();
73
+ })();
74
+ flight = pending;
75
+ void pending.finally(() => { if (flight === pending) flight = undefined; }).catch(() => {});
76
+ };
77
+ return {
78
+ start: () => {
79
+ if (started || stopped) return;
80
+ started = true;
81
+ if (probe.failure) void probe.failure.then(fail, fail);
82
+ try { due = sample() + POLICY_POLL_MS; arm(); } catch { fail(); }
83
+ },
84
+ stop,
85
+ };
86
+ }