@staix/agent-hub 0.34.0 → 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 (161) hide show
  1. package/CHANGELOG.md +51 -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 +18 -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-0.34.1.md +54 -0
  33. package/docs/verification/2026-11-07-compat-python-startup-1248.md +51 -0
  34. package/docs/verification/2026-11-07-compat-receipt-harness-1248.md +105 -0
  35. package/docs/verification/2026-11-07-compat-receipt-producers-1248.md +96 -0
  36. package/docs/verification/2026-11-07-compat-workflow-1248.md +94 -0
  37. package/docs/verification/2026-11-07-daemon-startup-1232.md +169 -0
  38. package/docs/verification/2026-11-07-jcode-receipt-reconstruction-1248.md +118 -0
  39. package/docs/verification/2026-11-07-kimi-delayed-usage.md +34 -0
  40. package/docs/verification/2026-11-07-kimi-review-watch-1284.md +115 -0
  41. package/docs/verification/2026-11-08-agy-1.5.3.md +96 -0
  42. package/docs/verification/2026-11-08-agy-acp-t0.md +56 -0
  43. package/docs/verification/2026-11-08-hub-visual-1058.md +161 -0
  44. package/docs/verification/2026-11-08-jcode-0991-model-setter-1317.md +143 -0
  45. package/docs/verification/2026-11-08-kimi-252-model-setter-1317.md +137 -0
  46. package/docs/verification/2026-11-08-kimi-tui-completion-1282.md +45 -0
  47. package/docs/verification/2026-11-08-kimi-wire-yield-1283.md +31 -0
  48. package/docs/verification/2026-11-08-live-model-override-1317.md +250 -0
  49. package/docs/verification/2026-11-08-qwen-0269-acp.md +138 -0
  50. package/docs/verification/2026-11-08-qwen-0269-model-setter-1317.md +91 -0
  51. package/docs/verification/2026-11-09-0.35.0.md +129 -0
  52. package/docs/verification/2026-11-09-headless-records-1320.md +157 -0
  53. package/docs/verification/claude-permission-transport-native.py +10 -3
  54. package/docs/verification/claude-shell-wrapper-native.py +8 -8
  55. package/docs/verified.json +245 -15
  56. package/package.json +3 -3
  57. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  58. package/plugins/agent-hub/kimi-hook.js +20397 -0
  59. package/plugins/agent-hub/kimi-review-policy-worker.js +217 -0
  60. package/plugins/agent-hub/server.js +2701 -664
  61. package/scripts/check-docs.mjs +12 -1
  62. package/src/adapters/acp-model-policy.ts +56 -0
  63. package/src/adapters/acp.ts +175 -5
  64. package/src/adapters/agy/entry.ts +8 -5
  65. package/src/adapters/agy/hooks.ts +65 -15
  66. package/src/adapters/channel-peer.ts +33 -1
  67. package/src/adapters/claude-implementer.ts +16 -3
  68. package/src/adapters/codex-appserver.ts +118 -8
  69. package/src/adapters/codex-review-isolation.ts +3 -1
  70. package/src/adapters/compat.ts +11 -6
  71. package/src/adapters/definitions/builtin-metadata.ts +8 -1
  72. package/src/adapters/definitions/native-cli-entry-kimi.ts +76 -7
  73. package/src/adapters/definitions/native-cli-preview-common.ts +2 -2
  74. package/src/adapters/definitions/native-cli-preview-kimi.ts +9 -2
  75. package/src/adapters/definitions/reviewer-variants.ts +3 -1
  76. package/src/adapters/kimi-review-policy-worker.js +2 -0
  77. package/src/adapters/kimi-review-policy-worker.ts +85 -0
  78. package/src/adapters/kimi-review-policy.ts +68 -0
  79. package/src/adapters/kimi-review.ts +41 -41
  80. package/src/adapters/kimi-tui-facts.ts +153 -0
  81. package/src/adapters/kimi-wire-usage.ts +33 -8
  82. package/src/adapters/local-worker.ts +59 -20
  83. package/src/adapters/model-token.ts +3 -0
  84. package/src/adapters/pi.ts +27 -5
  85. package/src/adapters/registry-types.ts +34 -3
  86. package/src/adapters/registry.ts +1 -0
  87. package/src/adapters/reviewer-policy-watch.ts +86 -0
  88. package/src/adapters/reviewer-print.ts +80 -18
  89. package/src/cli/agents.ts +1 -1
  90. package/src/cli/facts-hook.ts +4 -2
  91. package/src/cli/help.ts +3 -2
  92. package/src/cli/identity.ts +3 -0
  93. package/src/cli/kimi-facts-hook.ts +11 -0
  94. package/src/cli/kimi-plugin.ts +157 -0
  95. package/src/cli/main.ts +48 -9
  96. package/src/cli/model.ts +52 -0
  97. package/src/cli/output.ts +23 -1
  98. package/src/cli/recovery-package.ts +15 -7
  99. package/src/cli/reset.ts +1 -1
  100. package/src/cli/status-lines.ts +8 -4
  101. package/src/hub/budget.ts +1 -1
  102. package/src/hub/bus.ts +40 -2
  103. package/src/hub/code-facts.ts +10 -3
  104. package/src/hub/completions.ts +3 -1
  105. package/src/hub/config-apply.ts +2 -1
  106. package/src/hub/control-client.ts +1 -1
  107. package/src/hub/daemon.ts +1050 -106
  108. package/src/hub/deliverable-files.ts +38 -5
  109. package/src/hub/dogfood-profile.ts +1 -1
  110. package/src/hub/envelope.ts +1 -1
  111. package/src/hub/events.ts +28 -18
  112. package/src/hub/execution-lineage.ts +120 -0
  113. package/src/hub/execution-reference.ts +36 -0
  114. package/src/hub/hub-tools.ts +21 -2
  115. package/src/hub/instances.ts +4 -3
  116. package/src/hub/kimi-events.ts +46 -0
  117. package/src/hub/kimi-usage.ts +132 -29
  118. package/src/hub/lifecycle.ts +31 -15
  119. package/src/hub/limits.ts +13 -4
  120. package/src/hub/manager.ts +15 -2
  121. package/src/hub/memory-policy.ts +2 -1
  122. package/src/hub/memory-sync-attestation.ts +54 -0
  123. package/src/hub/memory-sync-state.ts +20 -5
  124. package/src/hub/model-overrides.ts +115 -0
  125. package/src/hub/native-hook-launch.ts +12 -3
  126. package/src/hub/operational-view.ts +2 -2
  127. package/src/hub/peers.ts +27 -0
  128. package/src/hub/permission-mode.ts +1 -1
  129. package/src/hub/person-figures.ts +88 -0
  130. package/src/hub/report.ts +38 -4
  131. package/src/hub/research-dataset.ts +158 -0
  132. package/src/hub/research-memory.ts +68 -0
  133. package/src/hub/research-quality.ts +118 -0
  134. package/src/hub/research-record.ts +279 -0
  135. package/src/hub/research.ts +126 -27
  136. package/src/hub/restart.ts +14 -0
  137. package/src/hub/settings.ts +3 -0
  138. package/src/hub/startup-event-seeds.ts +51 -0
  139. package/src/hub/startup-readiness.ts +65 -0
  140. package/src/hub/startup-timing.ts +38 -0
  141. package/src/hub/tasks.ts +29 -9
  142. package/src/hub/ui.ts +9 -5
  143. package/src/hub/visual-assets.ts +50 -0
  144. package/src/hub/visual.ts +257 -0
  145. package/src/memory/brief.ts +169 -14
  146. package/src/memory/capture.ts +2 -1
  147. package/src/memory/recall.ts +46 -5
  148. package/src/memory/record-provenance.ts +82 -0
  149. package/src/memory/records.ts +116 -0
  150. package/src/models/route/adec.ts +190 -0
  151. package/src/models/route/config.ts +37 -3
  152. package/src/models/route/decision-evidence.ts +83 -0
  153. package/src/models/route/decision.ts +143 -0
  154. package/src/models/route/runtime.ts +13 -6
  155. package/src/ui/index.html +134 -6
  156. package/src/ui/plugin.ts +65 -4
  157. package/templates/AGENTS.block.md +1 -1
  158. package/templates/config.json +1 -1
  159. package/templates/profiles/general.json +1 -1
  160. package/templates/profiles/research.json +1 -1
  161. package/templates/profiles/writing.json +1 -1
@@ -1,4 +1,4 @@
1
- import { BasePeer, type PeerAdapter, type PeerStandbySession, type PeerTakeoverClaim } from "../hub/peers.ts";
1
+ import { BasePeer, type PeerAdapter, type PeerOwnerAttachment, type PeerStandbySession, type PeerTakeoverClaim } from "../hub/peers.ts";
2
2
  import type { Envelope } from "../hub/envelope.ts";
3
3
  import { definitionOf } from "./registry.ts";
4
4
  import type { StartForm } from "./registry-types.ts";
@@ -12,6 +12,12 @@ export interface ChannelSocket {
12
12
  close(code?: number, reason?: string): void;
13
13
  }
14
14
 
15
+ const MAX_PID = 2_147_483_647;
16
+ /** A client-reported pid, bounded the way the daemon bounds it at hello; anything else is unknown, never a guess. */
17
+ const boundedPid = (value: unknown): number | undefined => Number.isSafeInteger(value) && (value as number) > 0 && (value as number) <= MAX_PID ? value as number : undefined;
18
+ /** A native session id that is shown only when it is a short plain token (the agy decoder accepts UUIDs); a longer or odd value is not shown at all, never trimmed into one. */
19
+ const NATIVE_SESSION_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
20
+
15
21
  /** A peer that lives in another process and attaches over the control WS (the Claude channel plugin). */
16
22
  export class WsPeer extends BasePeer {
17
23
  private static readonly MAX_STANDBY_SESSIONS = 8;
@@ -31,6 +37,8 @@ export class WsPeer extends BasePeer {
31
37
  private readonly standbys = new Map<ChannelSocket, PeerStandbySession>();
32
38
  private nativeTurnSession: string | undefined;
33
39
  private nativeActive = false;
40
+ /** The owner facts of the current socket, written once when its attach completes and dropped with that socket (#1310). The native session is read live from `nativeSession`, which the same events clear. */
41
+ private ownerFacts: { socket: ChannelSocket; ownerPid?: number; attachedAt: number } | undefined;
34
42
  /** The attached session said it cannot show channel pushes (issue #205): the bus keeps its queue for `inbox`. */
35
43
  pullOnly = false;
36
44
  attachMode: "channel" | "tools-only" = "tools-only";
@@ -68,6 +76,18 @@ export class WsPeer extends BasePeer {
68
76
  return [...this.standbys.values()].map(session => ({ ...session }));
69
77
  }
70
78
  get managedLaunchId(): string | undefined { return this.attached ? this.sock?.data.launchId : undefined; }
79
+ /**
80
+ * Who holds this peer now, for display only (#1310). Absent while a newer hello is claiming (the peer reads as offline until its preface ends), while offline and after a detach, so a claimant or a
81
+ * previous owner is never shown as attached. `ownerPid` is the current socket's bounded client pid, `attachedAt` is when its attach completed (not the hello), and `nativeSessionId` appears only
82
+ * after the host bound a verified native session (`bindNativeSession`, from the daemon's checked native-facts path) and only when it is a short plain token.
83
+ */
84
+ get ownerAttachment(): PeerOwnerAttachment | undefined {
85
+ const facts = this.ownerFacts;
86
+ if (!facts || facts.socket !== this.sock || !this.attached) return undefined;
87
+ const native = (this as { nativeSession?: string }).nativeSession;
88
+ return { ...(facts.ownerPid !== undefined ? { ownerPid: facts.ownerPid } : {}), attachedAt: facts.attachedAt,
89
+ ...(typeof native === "string" && NATIVE_SESSION_ID.test(native) ? { nativeSessionId: native } : {}) };
90
+ }
71
91
 
72
92
  /** A session that shows pushes holds the peer or is arriving: a session without them must not take it (issue #205). */
73
93
  get channelHeld(): boolean {
@@ -127,9 +147,12 @@ export class WsPeer extends BasePeer {
127
147
  if (this.sock) this.setState("offline"); // unresolved work belongs to the previous connection
128
148
  this.sock?.close(4000, "replaced");
129
149
  this.sock = sock;
150
+ // Replaced in the same synchronous step as the socket, so a previous owner's pid or time can never be read beside the new socket (#1310).
151
+ this.ownerFacts = { socket: sock, ...(boundedPid(sock.data.clientPid) !== undefined ? { ownerPid: boundedPid(sock.data.clientPid) } : {}), attachedAt: Date.now() };
130
152
  this.generation = crypto.randomUUID();
131
153
  this.delivered.clear();
132
154
  this.nativeTurnSession = undefined;
155
+ delete (this as { nativeSession?: string }).nativeSession;
133
156
  this.nativeActive = false;
134
157
  this.pullOnly = this.claimedPullOnly; // before idle: the idle transition drains
135
158
  this.attachMode = this.pullOnly ? "tools-only" : "channel";
@@ -153,7 +176,9 @@ export class WsPeer extends BasePeer {
153
176
  if (this.claimed === sock) { this.claimed = undefined; this.settleAttachmentWaiters(false); }
154
177
  if (this.sock !== sock) return;
155
178
  this.sock = undefined;
179
+ this.ownerFacts = undefined;
156
180
  this.steer = undefined;
181
+ delete (this as { nativeSession?: string }).nativeSession;
157
182
  this.nativeActive = false;
158
183
  this.pullOnly = false;
159
184
  this.setState("offline");
@@ -166,7 +191,9 @@ export class WsPeer extends BasePeer {
166
191
  this.standbys.clear();
167
192
  this.claimed = undefined;
168
193
  this.sock = undefined;
194
+ this.ownerFacts = undefined;
169
195
  this.steer = undefined;
196
+ delete (this as { nativeSession?: string }).nativeSession;
170
197
  this.nativeActive = false;
171
198
  this.pullOnly = false;
172
199
  this.setState("offline");
@@ -183,6 +210,10 @@ export class WsPeer extends BasePeer {
183
210
  }
184
211
  owns(sock: ChannelSocket): boolean { return this.sock === sock; }
185
212
  ownsSocket(socket: object): boolean { return this.sock === socket; }
213
+ bindNativeSession(session: string): void {
214
+ if (!this.sock || !session) return;
215
+ Object.defineProperty(this, "nativeSession", { value: session, writable: true, configurable: true, enumerable: true });
216
+ }
186
217
  nativeStarted(session: string): void {
187
218
  if (!this.sock) return;
188
219
  this.nativeTurnSession = session;
@@ -205,6 +236,7 @@ export class WsPeer extends BasePeer {
205
236
  async start(): Promise<void> {}
206
237
  async stop(): Promise<void> {
207
238
  this.settleAttachmentWaiters(false);
239
+ this.ownerFacts = undefined;
208
240
  this.sock?.close();
209
241
  for (const socket of this.standbys.keys()) socket.close();
210
242
  this.standbys.clear();
@@ -1,3 +1,5 @@
1
+ import type { ModelOverrideSelection } from "./registry-types.ts";
2
+ import { isNativeModelToken } from "./model-token.ts";
1
3
  import { randomUUID } from "node:crypto";
2
4
  import { lstatSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
5
  import { tmpdir } from "node:os";
@@ -38,6 +40,8 @@ export interface ClaudeImplementerOptions {
38
40
  watchdogMs: number;
39
41
  /** Declared `model` of this instance (#844): one validated word passed as `--model`; unset leaves the native default and the argv unchanged. */
40
42
  model?: string;
43
+ /** Host-only choice sampled once at the next turn/review boundary. */
44
+ getModelOverride?: () => ModelOverrideSelection;
41
45
  mode?: "ask" | "ask-when-needed";
42
46
  /** Exact host-reported transcript evidence only; unknown/read errors retain the recorded UUID. */
43
47
  transcriptPersisted?(task: number, sessionId: string): boolean | "unknown";
@@ -188,16 +192,24 @@ export class ClaudeImplementerPeer extends BasePeer {
188
192
  }
189
193
  const generation = this.generation; this.deliveryId = deliveryId; this.inputReleased = false;
190
194
  this.abort = new AbortController(); this.setState("busy");
195
+ let selection: ModelOverrideSelection | undefined, modelFailure: Error | undefined;
196
+ try {
197
+ selection = this.options.getModelOverride?.();
198
+ if (selection?.model !== undefined && !isNativeModelToken(selection.model)) throw new Error("invalid model");
199
+ } catch { modelFailure = new Error("runtime model selection unavailable; check the peer model setting and retry"); }
191
200
  if (deliveryId) this.delivery({ id: deliveryId, state: "accepted" });
192
201
  // Register the run identity before any synchronous failure can publish idle.
193
- const running: Promise<void> = Promise.resolve().then(() => this.run(groups, generation, running));
202
+ const running: Promise<void> = Promise.resolve().then(() => this.run(groups, generation, running, selection, modelFailure));
194
203
  this.running = running;
195
204
  void running.finally(() => { if (this.running === running) this.running = undefined; }).catch(() => {});
196
205
  }
197
- private async run(groups: { task?: number; envs: Envelope[] }[], generation: number, running: Promise<void>): Promise<void> {
206
+ private async run(groups: { task?: number; envs: Envelope[] }[], generation: number, running: Promise<void>, selection?: ModelOverrideSelection, modelFailure?: Error): Promise<void> {
198
207
  const current = () => generation === this.generation && !this.abort?.signal.aborted;
199
208
  let failureReason: string | undefined;
200
209
  try {
210
+ if (modelFailure) throw modelFailure;
211
+ const model = selection?.model ?? this.options.model;
212
+ let modelApplied = false;
201
213
  for (let group of groups) {
202
214
  failureReason = undefined;
203
215
  if (!current() || !this.binding?.()) throw new Error("Claude implementer admission changed");
@@ -224,7 +236,7 @@ export class ClaudeImplementerPeer extends BasePeer {
224
236
  AGENTHUB_INSTANCE_ID: this.options.instanceId, AGENTHUB_LAUNCH_ID: launchId, AGENTHUB_HOOK_PURPOSE: "permission", AGENTHUB_PERMISSION_TOOL: "1" };
225
237
  const mcp = JSON.stringify({ mcpServers: { "agent-hub": { command: process.execPath, args: ["run", this.options.server], env } } });
226
238
  let result = false, failed = false;
227
- const child = new OwnedPrintChild({ peer: this.id, command: this.command!, args: claudeImplementerArgs(turn, mcp, settings, this.options.systemPrompt, this.options.model), cwd: this.options.cwd,
239
+ const child = new OwnedPrintChild({ peer: this.id, command: this.command!, args: claudeImplementerArgs(turn, mcp, settings, this.options.systemPrompt, model), cwd: this.options.cwd,
228
240
  input: group.envs.map(frame).join("\n\n"), env, current, stop: this.options.stopChild,
229
241
  onSpawn: () => {
230
242
  try {
@@ -234,6 +246,7 @@ export class ClaudeImplementerPeer extends BasePeer {
234
246
  void this.cancel("Claude implementer admission changed before child start", "idle").catch(() => {});
235
247
  return;
236
248
  }
249
+ if (!modelApplied) { modelApplied = true; try { selection?.applied(); } catch { /* Status acknowledgement never grants child authority. */ } }
237
250
  this.childAbort = new AbortController();
238
251
  const launch: OwnedToolsLaunch = Object.freeze({ permissionHook: true, launchId, generation: `${generation}:${launchId}`, sessionId: turn.sessionId, signal: this.childAbort.signal, ...(group.task !== undefined ? { task: group.task } : {}) });
239
252
  this.active = launch;
@@ -1,3 +1,5 @@
1
+ import type { ModelOverrideSelection } from "./registry-types.ts";
2
+ import { isNativeModelToken } from "./model-token.ts";
1
3
  import { spawn, type ChildProcess } from "node:child_process";
2
4
  import type { Server, ServerWebSocket } from "bun";
3
5
  import { renderDigest, replyAudience, replyParent, type Envelope, type PeerId } from "../hub/envelope.ts";
@@ -25,6 +27,10 @@ function confirmsReadOnlySandbox(value: unknown): boolean {
25
27
  /** A counter Codex reported: a finite non-negative number, otherwise 0 (an absent kind is not a negative delta). */
26
28
  const counter = (value: unknown): number => { const n = Number(value); return Number.isFinite(n) && n >= 0 ? n : 0; };
27
29
  export interface CodexOptions {
30
+ /** Host-only choice sampled once at the next turn/review boundary. */
31
+ getModelOverride?: () => ModelOverrideSelection;
32
+ /** Successful explicit native-client choice; never acknowledgement of a hub override revision. */
33
+ onNativeModelApplied?: (model: string) => void;
28
34
  /** Port the TUI attaches to: `codex --enable tui_app_server --remote ws://127.0.0.1:<proxyPort>`. */
29
35
  proxyPort: number;
30
36
  permissionMode?: PermissionMode;
@@ -78,12 +84,18 @@ export interface CodexOptions {
78
84
  log?: (line: string) => void;
79
85
  }
80
86
 
87
+ interface ModelStartOrder { thread: string; sequence: number; changing: boolean }
81
88
  interface Link {
82
89
  launchId?: string;
83
90
  tui: ServerWebSocket<Link>;
84
91
  up: WebSocket;
85
92
  backlog: string[]; // TUI frames that arrived before the upstream socket opened
86
93
  approvalIds: Set<string | number>; // intercepted native ids stay tombstoned for this socket lifetime
94
+ modelStarts: Map<string | number, ModelStartOrder & { epoch: number; nativeStarted?: true; selection?: ModelOverrideSelection; explicit?: string; clientModel: boolean } | null>;
95
+ /** One drained rejection candidate, never an unbounded history while another request hangs. */
96
+ rejectedModelSelection?: { selection: ModelOverrideSelection; epoch: number; thread: string };
97
+ /** Untracked/reused requests cannot prove a sticky default until a later explicit model ACK. */
98
+ modelUnknownAfter?: number;
87
99
  tracked: Map<string | number, string>; // request id -> method, for thread/start and thread/resume
88
100
  /** Correlated native initial policy, retained even before this instance gains the planner role. */
89
101
  readOnlyThread?: string;
@@ -106,6 +118,8 @@ export class CodexPeer extends BasePeer {
106
118
  get actionGated(): boolean { return this.opts.actionGate !== undefined; }
107
119
  private readonly nativeApprovalPolicies = new Map<string, unknown>();
108
120
  private readonly overriddenApprovals = new Set<string>();
121
+ private readonly nativeModels = new Map<string, { sequence: number; model?: string }>();
122
+ private modelStartSequence = 0;
109
123
  private readonly restoringApprovals = new Map<string | number, string>();
110
124
  private proc: ChildProcess | undefined;
111
125
  private server: Server<Link> | undefined;
@@ -136,7 +150,7 @@ export class CodexPeer extends BasePeer {
136
150
  }
137
151
  private unboundAccepted = false;
138
152
  private nextId = -1;
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 }>();
153
+ private readonly pending = new Map<number, { resolve: (result?: any) => void; reject: (e: Error, failureCause?: DeliveryFailureCause) => void; deliveryId?: string; kind?: "deliver" | "steer"; modelStart?: ModelStartOrder; expectedTurnId?: string; afterTurn?: () => void; invalidate?: (reason: string, failureCause?: DeliveryFailureCause) => void }>();
140
154
  private usageTimer: ReturnType<typeof setInterval> | undefined;
141
155
  private injected: Envelope | undefined; // the hub envelope that started the current turn, if any
142
156
  private answering: Envelope[] = []; // everything delivered or steered into the current turn: who its answer is for
@@ -189,6 +203,7 @@ export class CodexPeer extends BasePeer {
189
203
  }
190
204
 
191
205
  async start(): Promise<void> {
206
+ this.nativeModels.clear(); this.modelStartSequence = 0;
192
207
  const upstream = this.opts.upstreamUrl ?? (await this.spawnAppServer());
193
208
  try {
194
209
  this.server = Bun.serve<Link>({
@@ -255,10 +270,19 @@ export class CodexPeer extends BasePeer {
255
270
  if (deliveryId) this.delivery({ id: deliveryId, state: "failed_safe", reason: `${this.id} is not injectable` });
256
271
  return Promise.reject(new Error(`${this.id} is not injectable`));
257
272
  }
258
- const text = this.render(envs);
259
273
  const id = this.nextId--, epoch = this.deliveryEpoch, session = this.contextEpoch;
260
274
  this.setState("busy"); // claim the turn now so the bus stops draining
261
275
  return new Promise<void>((resolve, reject) => {
276
+ let selection: ModelOverrideSelection | undefined;
277
+ try {
278
+ selection = this.opts.getModelOverride?.();
279
+ if (selection?.model !== undefined && !isNativeModelToken(selection.model)) throw new Error("runtime model must be one bounded native token");
280
+ } catch (error) {
281
+ this.setState("idle");
282
+ if (deliveryId) this.delivery({ id: deliveryId, state: "failed_safe", reason: "runtime model selection unavailable" });
283
+ reject(error); return;
284
+ }
285
+ const modelStart: ModelStartOrder = { thread: this.threadId, sequence: ++this.modelStartSequence, changing: selection?.model !== undefined };
262
286
  let invalidated = false;
263
287
  const retiredAtCancel = new Set<string>();
264
288
  const invalidate = (reason: string, failureCause?: DeliveryFailureCause) => {
@@ -270,7 +294,7 @@ export class CodexPeer extends BasePeer {
270
294
  reject(new Error(reason));
271
295
  };
272
296
  this.pending.set(id, {
273
- deliveryId, kind: "deliver", invalidate,
297
+ deliveryId, kind: "deliver", modelStart, invalidate,
274
298
  resolve: (result) => {
275
299
  const nativeTurn = typeof result?.turn?.id === "string" ? result.turn.id : undefined;
276
300
  if (invalidated || epoch !== this.deliveryEpoch || session !== this.contextEpoch || link !== this.link) {
@@ -279,6 +303,8 @@ export class CodexPeer extends BasePeer {
279
303
  (!retiredAtCancel.has(nativeTurn) || this.retiredTurns.has(nativeTurn))) this.retireTurn(nativeTurn);
280
304
  return;
281
305
  }
306
+ const acceptedModel = this.acceptModelStart(modelStart, selection?.model, true);
307
+ if (acceptedModel) try { selection?.applied(acceptedModel.model); } catch { /* Native acceptance remains authoritative. */ }
282
308
  this.primed = true;
283
309
  this.injected = replyParent(envs);
284
310
  this.answering = [...envs];
@@ -307,14 +333,17 @@ export class CodexPeer extends BasePeer {
307
333
  const pending = this.pending.get(id); this.pending.delete(id);
308
334
  pending?.reject(new Error(`${this.id} is not injectable`)); return;
309
335
  }
336
+ const text = this.render(envs, true);
310
337
  link.up.send(
311
- JSON.stringify({ method: "turn/start", id, params: this.applyPermissionPolicy({ threadId: this.threadId, input: [{ type: "text", text }] }, id) }),
338
+ JSON.stringify({ method: "turn/start", id, params: this.applyPermissionPolicy({ threadId: this.threadId, input: [{ type: "text", text }], ...(selection?.model === undefined ? {} : { model: selection.model }) }, id) }),
312
339
  );
313
340
  });
314
341
  }
315
342
 
316
- private render(envs: Envelope[]): string {
317
- return this.primed || !this.opts.preamble ? renderDigest(envs, this.primed) : `${this.opts.preamble}\n\n${renderDigest(envs, false)}`;
343
+ private render(envs: Envelope[], record = false): string {
344
+ const base = this.primed ? "" : this.opts.preamble ?? "";
345
+ const preamble = record ? this.turnRecordPrompt(base) : base;
346
+ return preamble ? `${preamble}\n\n${renderDigest(envs, this.primed)}` : renderDigest(envs, this.primed);
318
347
  }
319
348
 
320
349
  /** `important` while a turn runs: feed it into that turn. Rejects when there is no steerable turn or app-server refuses. */
@@ -494,7 +523,7 @@ export class CodexPeer extends BasePeer {
494
523
 
495
524
  private attach(tui: ServerWebSocket<Link>, upstream: string): void {
496
525
  const up = new WebSocket(upstream);
497
- const link: Link = { tui, up, backlog: [], tracked: new Map(), approvalIds: new Set(), ...(tui.data.launchId ? { launchId: tui.data.launchId } : {}) };
526
+ const link: Link = { tui, up, backlog: [], tracked: new Map(), modelStarts: new Map(), approvalIds: new Set(), ...(tui.data.launchId ? { launchId: tui.data.launchId } : {}) };
498
527
  this.claimedTui = link;
499
528
  tui.data = link;
500
529
  up.onopen = () => {
@@ -505,6 +534,7 @@ export class CodexPeer extends BasePeer {
505
534
  }
506
535
 
507
536
  private detach(link: Link): void {
537
+ link.rejectedModelSelection = undefined;
508
538
  this.abortActionWaits(link);
509
539
  link.up.close();
510
540
  if (this.claimedTui === link) this.claimedTui = undefined;
@@ -588,6 +618,35 @@ export class CodexPeer extends BasePeer {
588
618
  if (thread !== undefined && !failed && this.permissionMode === "ask") this.overriddenApprovals.delete(thread);
589
619
  }
590
620
 
621
+ private nativeModelHint(thread: string): string | undefined {
622
+ const known = this.nativeModels.get(thread);
623
+ if (!known?.model || (this.link?.modelUnknownAfter ?? 0) >= known.sequence) return undefined;
624
+ const unsettled = [...this.pending.values()].flatMap(value => value.modelStart ? [value.modelStart] : []);
625
+ for (const value of this.link?.modelStarts.values() ?? []) if (value) unsettled.push(value);
626
+ if (unsettled.some(value => value.thread === thread && value.changing && value.sequence > known.sequence)) return undefined;
627
+ return known.model;
628
+ }
629
+
630
+ /** Shared by hub and forwarded starts: response arrival order never rewrites a later accepted start. */
631
+ private acceptModelStart(start: ModelStartOrder, model: string | undefined, inherit: boolean): { model?: string } | undefined {
632
+ // Even an older successful start protects its shared capture from pre-start retirement.
633
+ if (this.link) this.link.rejectedModelSelection = undefined;
634
+ if ((this.nativeModels.get(start.thread)?.sequence ?? 0) >= start.sequence) return undefined;
635
+ const actual = model ?? (inherit ? this.nativeModelHint(start.thread) : undefined);
636
+ this.nativeModels.set(start.thread, { sequence: start.sequence, ...(actual === undefined ? {} : { model: actual }) });
637
+ // ponytail: keep at most 128 thread receipts; older defaults are unknown until native model readback is added.
638
+ if (this.nativeModels.size > 128) this.nativeModels.delete(this.nativeModels.keys().next().value!);
639
+ return actual === undefined ? {} : { model: actual };
640
+ }
641
+
642
+ private retireRejectedModelSelection(link: Link): void {
643
+ const candidate = link.rejectedModelSelection;
644
+ if (!candidate || link !== this.link || candidate.epoch !== this.contextEpoch || candidate.thread !== this.threadId ||
645
+ link.modelStarts.size || [...this.pending.values()].some(value => value.modelStart)) return;
646
+ link.rejectedModelSelection = undefined;
647
+ try { candidate.selection.rejected?.(); } catch { /* Retirement never changes the native rejection. */ }
648
+ }
649
+
591
650
  private fromTui(link: Link, raw: string): void {
592
651
  const msg = parse(raw);
593
652
  if (msg?.id !== undefined && !msg.method && link.approvalIds.has(msg.id)) return;
@@ -597,6 +656,30 @@ export class CodexPeer extends BasePeer {
597
656
  if (msg?.method === "turn/interrupt" && link === this.link) this.abortActionWaits(link, typeof msg.params?.turnId === "string" ? msg.params.turnId : undefined);
598
657
  if (msg?.method === "turn/start" && msg.params && link === this.link) {
599
658
  const thread = typeof msg.params.threadId === "string" ? msg.params.threadId : this.threadId;
659
+ // An explicit native choice wins. A missing client model may use the already captured host override.
660
+ let selection: ModelOverrideSelection | undefined;
661
+ const explicit = msg.params.model !== undefined;
662
+ if (!explicit) {
663
+ try {
664
+ selection = this.opts.getModelOverride?.();
665
+ if (selection?.model !== undefined && !isNativeModelToken(selection.model)) throw new Error("invalid model");
666
+ } catch {
667
+ if (msg.id !== undefined) link.tui.send(JSON.stringify({ id: msg.id, error: { code: -32000, message: "runtime model selection unavailable; check the peer model setting and retry" } }));
668
+ return;
669
+ }
670
+ if (selection?.model !== undefined) { msg.params = { ...msg.params, model: selection.model }; raw = JSON.stringify(msg); }
671
+ }
672
+ const modelStart: ModelStartOrder = { thread, sequence: ++this.modelStartSequence, changing: explicit || selection?.model !== undefined };
673
+ if (typeof msg.id === "string" || typeof msg.id === "number") {
674
+ if (link.modelStarts.has(msg.id)) {
675
+ link.modelStarts.set(msg.id, null); // reused outstanding id cannot identify an accepted model revision
676
+ link.modelUnknownAfter = modelStart.sequence;
677
+ }
678
+ // ponytail: at most 64 in-flight ACKs per link; overflow keeps defaults unknown until a later explicit model ACK.
679
+ else if (link.modelStarts.size < 64) link.modelStarts.set(msg.id, { ...modelStart, epoch: this.contextEpoch, clientModel: explicit,
680
+ ...(selection ? { selection } : {}), ...(explicit && isNativeModelToken(msg.params.model) ? { explicit: msg.params.model } : {}) });
681
+ else link.modelUnknownAfter = modelStart.sequence;
682
+ } else link.modelUnknownAfter = modelStart.sequence;
600
683
  // A resumed TUI can echo the sticky hub overlay. The restoring turn must use the kept baseline.
601
684
  const restoring = this.overriddenApprovals.has(thread) && this.nativeApprovalPolicies.has(thread);
602
685
  if (msg.params.approvalPolicy != null && !restoring) this.nativeApprovalPolicies.set(thread, msg.params.approvalPolicy);
@@ -632,8 +715,30 @@ export class CodexPeer extends BasePeer {
632
715
  this.pending.delete(msg.id);
633
716
  if (msg.error) p?.reject(new Error(msg.error.message ?? "app-server rejected the request"));
634
717
  else p?.resolve(msg.result);
718
+ if (msg.error) this.retireRejectedModelSelection(link);
635
719
  return; // ours: the TUI never asked for it
636
720
  }
721
+ if (msg.id !== undefined && !msg.method && link.modelStarts.has(msg.id)) {
722
+ const selected = link.modelStarts.get(msg.id);
723
+ // A reused id has no trustworthy response correlation. Keep its tombstone until reattach;
724
+ // the first reply cannot prove that all duplicate requests rejected before native execution.
725
+ if (selected !== null) link.modelStarts.delete(msg.id);
726
+ if (selected && link === this.link && selected.epoch === this.contextEpoch && selected.thread === this.threadId) {
727
+ if (msg.error) {
728
+ // The final rejecting request may have an explicit client model and no selection.
729
+ // Retain only one still-unaccepted candidate until the known outstanding receipts drain.
730
+ if (selected.selection && !selected.nativeStarted && (this.nativeModels.get(selected.thread)?.sequence ?? 0) < selected.sequence)
731
+ link.rejectedModelSelection = { selection: selected.selection, epoch: selected.epoch, thread: selected.thread };
732
+ } else {
733
+ const acceptedModel = this.acceptModelStart(selected, selected.explicit ?? selected.selection?.model, !selected.clientModel);
734
+ if (acceptedModel) try {
735
+ if (selected.explicit !== undefined) this.opts.onNativeModelApplied?.(selected.explicit);
736
+ else selected.selection?.applied(acceptedModel.model);
737
+ } catch { /* Native acceptance remains authoritative. */ }
738
+ }
739
+ }
740
+ if (msg.error) this.retireRejectedModelSelection(link);
741
+ }
637
742
  if (typeof msg.result?.userAgent === "string") this.version = /^[^/\s]+\/(\d+\.\d+\.\d+(?:-[\w.]+)?)/.exec(msg.result.userAgent)?.[1] ?? this.version;
638
743
  const tracked = msg.id !== undefined && !msg.method ? link.tracked.get(msg.id) : undefined;
639
744
  if (tracked && link.tracked.delete(msg.id)) {
@@ -743,6 +848,7 @@ export class CodexPeer extends BasePeer {
743
848
  this.turnDeliveries.clear(); this.unboundDeliveries.clear();
744
849
  this.tokenTotal = undefined; this.tokenKinds = undefined;
745
850
  this.freshThread = fresh;
851
+ link.rejectedModelSelection = undefined;
746
852
  this.link = link;
747
853
  this.threadId = threadId;
748
854
  this.onDoing?.("reset");
@@ -840,6 +946,10 @@ export class CodexPeer extends BasePeer {
840
946
  return;
841
947
  }
842
948
  if (method === "turn/started") {
949
+ link.rejectedModelSelection = undefined;
950
+ // The notification has no request id. Conservatively protect every outstanding capture on
951
+ // this bound thread rather than retiring a possibly accepted selection on a later RPC error.
952
+ for (const start of link.modelStarts.values()) if (start && start.thread === this.threadId) start.nativeStarted = true;
843
953
  const nativeTurn = params.turn?.id ?? `unknown:${Date.now()}`;
844
954
  this.unboundAccepted = false;
845
955
  this.activeTurns.add(nativeTurn);
@@ -890,7 +1000,7 @@ export class CodexPeer extends BasePeer {
890
1000
  this.deltas.clear();
891
1001
  // Addressed like every other adapter's answer (issue #3): each sender it answers, including one steered in;
892
1002
  // the bus maps a condensed delivery's `digest` back to the senders it replaced, a steer beside it or not.
893
- if (this.lastAnswer) this.onMessage?.(this.lastAnswer, inReplyTo ? { inReplyTo, to } : {});
1003
+ if (this.lastAnswer) this.finalAnswer(this.lastAnswer, inReplyTo ? { inReplyTo, to } : {}, params.turn?.status === "completed");
894
1004
  this.lastAnswer = "";
895
1005
  this.setState("idle");
896
1006
  }
@@ -1,3 +1,4 @@
1
+ import { isNativeModelToken } from "./model-token.ts";
1
2
  import { DENY_NAMES, DENY_SEGMENTS } from "@staix/agent-mem/platform";
2
3
  import { assessVersion, COMPAT } from "./compat.ts";
3
4
  import { createHash } from "node:crypto";
@@ -182,6 +183,7 @@ async function prepare(ctx: ReviewerPrepareContext): Promise<ReviewerLaunch | {
182
183
  // #722: the proof leg being run, so a refusal names a class and never text from the native CLI. `timedOut` is set by `run` only.
183
184
  let leg: ReviewerCauseClass = "binary", timedOut = false;
184
185
  try {
186
+ if (ctx.modelOverride !== undefined && !isNativeModelToken(ctx.modelOverride)) throw new Error("invalid runtime model token");
185
187
  const cwd = realPath(ctx.cwd), env = peerChildEnv(ctx.peer), bound = resolveCommand(ctx, cwd);
186
188
  const initialBinaryMetadata = bound.files.map(metadata).join(";");
187
189
  // Hash content once per receipt, then fence the identity with dev/ino/size/mtime/ctime metadata.
@@ -282,7 +284,7 @@ async function prepare(ctx: ReviewerPrepareContext): Promise<ReviewerLaunch | {
282
284
  if (!current()) throw new Error("binary or config changed during proof");
283
285
  const cleanupRoot = root;
284
286
  const launchProfile = digest(toml({ profile, servers, policy }));
285
- return { command: bound.command, args: ["exec", "--ephemeral", "--ignore-user-config", "--ignore-rules", "--json", "--color", "never", "-C", cwd, ...policy, ...permissions, ...mcp, "-c", `developer_instructions=${toml(ctx.systemPrompt)}`, "-"], env,
287
+ return { command: bound.command, args: ["exec", ...(ctx.modelOverride === undefined ? [] : ["-m", ctx.modelOverride]), "--ephemeral", "--ignore-user-config", "--ignore-rules", "--json", "--color", "never", "-C", cwd, ...policy, ...permissions, ...mcp, "-c", `developer_instructions=${toml(ctx.systemPrompt)}`, "-"], env,
286
288
  current: () => { try { return current(); } catch { return false; } },
287
289
  cleanup: () => rmSync(cleanupRoot, { recursive: true, force: true }),
288
290
  isolation: { backend: "codex", version, binary: `sha256:${binaryHash}`, profile: `sha256:${launchProfile}`, readOnly: true, strictMcp: true, ephemeral: true,
@@ -49,18 +49,23 @@ export const COMPAT = {
49
49
  parse: out => plain(out.trim()),
50
50
  trustNote: "Every launch still builds the private config, exact two-tool agent file and task-bound MCP; the native tool allowlist behaviour of an untested version is not re-proven.",
51
51
  receipt: "docs/verification/2026-11-06-kimi-251-reviewer-compat.md" },
52
+ // The interactive TUI's native-turn and background readbacks are a separate contract from headless ACP and reviewer isolation.
53
+ "kimi-tui": { label: "Kimi TUI completion facts", verified: ["2.5.2"], gate: "contract", probe: ["kimi", "--version"],
54
+ parse: out => plain(out.trim()),
55
+ trustNote: "Only 2.5.2 has a live receipt for turn.prompt/turn.ended/prompt completion rows, background task inventory and launcher-bound plugin hooks. Another version keeps completion receipts disabled.",
56
+ receipt: "docs/verification/2026-11-08-kimi-tui-completion-1282.md" },
52
57
  jcode: { label: "Jcode", verified: ["0.98.1", "0.99.0", "0.99.1"], line: { min: "0.98.0", below: "1.0.0" }, gate: "contract", probe: ["jcode", "--quiet", "version", "--json"], config: "jcode_cmd",
53
58
  parse: out => { try { const v = JSON.parse(out).semver; return typeof v === "string" ? plain(v) : undefined; } catch { return undefined; } },
54
59
  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
60
  receipt: "docs/verification/2026-10-26-jcode-0.99.1.md" },
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"],
61
+ agy: { label: "agy", verified: ["1.4.2", "1.4.7", "1.4.12", "1.5.3"], line: { min: "1.4.0", below: "1.5.0" }, gate: "contract", probe: ["agy", "--version"],
57
62
  parse: out => plain(out.trim()),
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", "0.26.8"], gate: "contract", probe: ["qwen", "--version"], config: "qwen_cmd",
63
+ trustNote: "Exact 1.5.3 repeats the account-free CLI/plugin MCP, hooks, normal close and actual-launcher nonce legs; existing 1.4.x verified receipts remain unchanged. Other 1.5.x versions, real model/account behavior, headless ACP and native permission mappings remain unverified.",
64
+ receipt: "docs/verification/2026-11-08-agy-1.5.3.md" },
65
+ qwen: { label: "Qwen Code", verified: ["0.25.0", "0.26.0", "0.26.1", "0.26.4", "0.26.5", "0.26.8", "0.26.9"], gate: "contract", probe: ["qwen", "--version"], config: "qwen_cmd",
61
66
  parse: out => plain(out.trim()),
62
- trustNote: "Native ACP identity, mode ids and permission callbacks were re-checked without inference on 0.26.4, 0.26.5 and 0.26.8 (macOS arm64 and Linux x86_64); real model turns remain unverified. Another version may change those contracts.",
63
- receipt: "docs/verification/2026-11-06-qwen-0268-acp.md" },
67
+ trustNote: "Native ACP identity, mode ids and permission callbacks were re-checked without inference on 0.26.4, 0.26.5 and 0.26.8 (macOS arm64 and Linux x86_64); exact 0.26.9 repeats those account-free probes on macOS arm64. Real model turns and Linux repetition of 0.26.9 remain unverified. Another version may change those contracts.",
68
+ receipt: "docs/verification/2026-11-08-qwen-0269-acp.md" },
64
69
  // 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
70
  // 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
71
  "qwen-dual": { label: "Qwen Code dual-output TUI", verified: ["0.26.1"], gate: "contract", probe: ["qwen", "--version"], config: "qwen_cmd",
@@ -23,6 +23,7 @@ import { CLAUDE_DISPLAY, CODEX_DISPLAY, KIMI_DISPLAY, PI_DISPLAY, LOCAL_DISPLAY,
23
23
  import { CLAUDE_RECOVERY_SESSION, CODEX_RECOVERY_SESSION, KIMI_RECOVERY_SESSION, PI_RECOVERY_SESSION, LOCAL_RECOVERY_SESSION, JCODE_RECOVERY_SESSION } from "./native-recovery.ts";
24
24
  import { claudeWindows, codexWindows } from "./native-quota.ts";
25
25
  import { claudeContext, codexContext, acpContextValue, jcodeContextValue } from "./native-context.ts";
26
+ import { KIMI_TUI_FACTS_HOOK } from "../kimi-tui-facts.ts";
26
27
  import type { AdapterDefinition } from "../registry-types.ts";
27
28
  import type { PeerAdapter } from "../../hub/peers.ts";
28
29
 
@@ -60,6 +61,7 @@ const CLAUDE_REVIEWER_VARIANT: AdapterDefinition = {
60
61
  /** #525 R5a: no idle child; the host binds an owned worktree and one native session per task. */
61
62
  const CLAUDE_IMPLEMENTER_VARIANT: AdapterDefinition = {
62
63
  api: 1, kind: "claude", displayName: "Claude Code implementer", family: "claude", transport: "hub-native",
64
+ modelOverride: { boundary: "turn" },
63
65
  roles: Object.freeze(["implementer"]), defaultOnRoster: true, trust: { onPrem: false, hubSandboxed: false },
64
66
  start: { create: createClaudeImplementer, daemonPort: "claude-implementer", forms: headlessForms, defaultForm: "headless", conductorStartable: true, hubSwitches: [], ownedFlags: [] },
65
67
  identity: { mcp: "env", currentChild: true }, instances: { mode: "headless", roles: ["implementer", "verifier"], modelSelect: true },
@@ -92,6 +94,7 @@ export const NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly AdapterDefinition
92
94
  reviewerVariant: CODEX_REVIEWER_VARIANT,
93
95
  projection: { roleRank: 1, startRank: 1, helpRank: 1, plannerEligible: true },
94
96
  api: 1, kind: "codex", questionWaitMs: 45000, displayName: "Codex", family: "codex", transport: "codex-appserver",
97
+ modelOverride: { boundary: "turn" },
95
98
  roles: Object.freeze(["implementer"]), defaultOnRoster: true, trust: { onPrem: false, hubSandboxed: false },
96
99
  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
100
  unattended: true, hubSwitches: switches, ownedFlags: Object.freeze(["--remote", "--remote-auth-token-env", "--yolo", "--dangerously-bypass-approvals-and-sandbox"]) },
@@ -105,6 +108,8 @@ export const NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly AdapterDefinition
105
108
  reviewerVariant: KIMI_REVIEWER_VARIANT,
106
109
  projection: { roleRank: 2, startRank: 2, helpRank: 2, plannerEligible: true },
107
110
  api: 1, kind: "kimi", questionWaitMs: 45000, displayName: "Kimi Code", family: "kimi", transport: "acp",
111
+ modelOverride: { boundary: "turn", acp: { admission: { nativeIdentity: true }, agentName: "Kimi Code CLI", version: "2.5.2",
112
+ setter: "set_config_option", modelUpdate: "config_option_update", receipt: "docs/verification/2026-11-08-kimi-252-model-setter-1317.md" } },
108
113
  roles: implementerRoles, defaultOnRoster: true, trust: { onPrem: false, hubSandboxed: false },
109
114
  start: { create: createKimi, acpCommand: kimiAcpCommand, explicitHeadlessMode: true, planCommand: () => "ahub kimi", daemonPort: "acp", forms: dualForms, defaultForm: "tui", conductorStartable: true, upStart: { allowed: true }, hubSwitches: [], ownedFlags: [] },
110
115
  // ACP sessions carry headless identity; the existing CLI alone owns the TUI's project MCP entry.
@@ -114,12 +119,13 @@ export const NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly AdapterDefinition
114
119
  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
120
  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
121
  session: { idField: "sessionId", currentId: sessionId("sessionId"), freshOnRestore: true, launchKind: "acp", ...KIMI_RECOVERY_SESSION, crashPlan: kimiCrashPlan },
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" },
122
+ turnEnd: "adapter", settle: { signal: "prompt-response" }, budget: { account: "kimi", resetSoftTokensOnResume: true, context: { source: "acp_usage_update", read: acpContextValue } }, capabilities: { watch: true }, hooks: { factsHook: KIMI_TUI_FACTS_HOOK, recallPlatform: "kimi", recallSource: "kimi" },
118
123
  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
124
  },
120
125
  {
121
126
  projection: { roleRank: 4, startRank: 0, helpRank: 6, plannerEligible: false },
122
127
  api: 1, kind: "pi", questionWaitMs: 300000, title: "hub", titlePersistence: "native", enabledSwitchStartOnly: true, displayName: "Pi", family: "pi", transport: "hub-native",
128
+ modelOverride: { boundary: "turn" },
123
129
  roles: implementerRoles, defaultOnRoster: false, trust: { onPrem: false, hubSandboxed: true },
124
130
  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
131
  identity: { mcp: "env" }, instances: { mode: "headless" },
@@ -133,6 +139,7 @@ export const NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly AdapterDefinition
133
139
  {
134
140
  projection: { roleRank: 3, helpRank: 7, plannerEligible: false },
135
141
  api: 1, kind: "local", questionWaitMs: 300000, title: "hub", displayName: "Local worker", family: "local", transport: "hub-native",
142
+ modelOverride: { boundary: "turn" },
136
143
  roles: implementerRoles, defaultOnRoster: true, trust: { onPrem: true, hubSandboxed: true },
137
144
  start: { create: createLocal, reconfigureWhileAttached: true, refreshTools: true, daemonPort: "local-worker", forms: headlessForms, defaultForm: "headless", conductorStartable: true, upStart: { allowed: true }, hubSwitches: [], ownedFlags: [] },
138
145
  identity: { mcp: "env" }, instances: { mode: "headless" },