@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.
- package/CHANGELOG.md +51 -0
- package/README.md +35 -6
- package/docs/adding-an-ai-tool.md +2 -2
- package/docs/addons/claude-mem.md +4 -4
- package/docs/addons/open-code-review.md +1 -1
- package/docs/agent-notes/README.md +6 -0
- package/docs/agent-notes/adapters.md +9 -5
- package/docs/agent-notes/budget.md +1 -1
- package/docs/agent-notes/bus.md +1 -1
- package/docs/agent-notes/daemon.md +4 -3
- package/docs/agent-notes/local-worker.md +1 -1
- package/docs/agent-notes/models.md +2 -0
- package/docs/agent-notes/tasks.md +1 -1
- package/docs/agent-notes/tests.md +5 -0
- package/docs/design/adapters.md +110 -2
- package/docs/design/agent-hub-architecture.md +63 -1
- package/docs/design/agent-hub.md +6 -0
- package/docs/design/bus.md +18 -0
- package/docs/design/execution-reference.md +146 -0
- package/docs/design/task-board.md +10 -0
- package/docs/dogfood.md +33 -12
- package/docs/events.md +38 -20
- package/docs/memory.md +21 -7
- package/docs/operations.md +539 -24
- package/docs/quickstart.md +18 -4
- package/docs/research.md +71 -13
- package/docs/roles.md +1 -1
- package/docs/sdlc.md +9 -7
- package/docs/security.md +87 -11
- package/docs/smoke.md +14 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +8 -3
- package/docs/verification/2026-11-07-0.34.1.md +54 -0
- package/docs/verification/2026-11-07-compat-python-startup-1248.md +51 -0
- package/docs/verification/2026-11-07-compat-receipt-harness-1248.md +105 -0
- package/docs/verification/2026-11-07-compat-receipt-producers-1248.md +96 -0
- package/docs/verification/2026-11-07-compat-workflow-1248.md +94 -0
- package/docs/verification/2026-11-07-daemon-startup-1232.md +169 -0
- package/docs/verification/2026-11-07-jcode-receipt-reconstruction-1248.md +118 -0
- package/docs/verification/2026-11-07-kimi-delayed-usage.md +34 -0
- package/docs/verification/2026-11-07-kimi-review-watch-1284.md +115 -0
- package/docs/verification/2026-11-08-agy-1.5.3.md +96 -0
- package/docs/verification/2026-11-08-agy-acp-t0.md +56 -0
- package/docs/verification/2026-11-08-hub-visual-1058.md +161 -0
- package/docs/verification/2026-11-08-jcode-0991-model-setter-1317.md +143 -0
- package/docs/verification/2026-11-08-kimi-252-model-setter-1317.md +137 -0
- package/docs/verification/2026-11-08-kimi-tui-completion-1282.md +45 -0
- package/docs/verification/2026-11-08-kimi-wire-yield-1283.md +31 -0
- package/docs/verification/2026-11-08-live-model-override-1317.md +250 -0
- package/docs/verification/2026-11-08-qwen-0269-acp.md +138 -0
- package/docs/verification/2026-11-08-qwen-0269-model-setter-1317.md +91 -0
- package/docs/verification/2026-11-09-0.35.0.md +129 -0
- package/docs/verification/2026-11-09-headless-records-1320.md +157 -0
- package/docs/verification/claude-permission-transport-native.py +10 -3
- package/docs/verification/claude-shell-wrapper-native.py +8 -8
- package/docs/verified.json +245 -15
- package/package.json +3 -3
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/kimi-hook.js +20397 -0
- package/plugins/agent-hub/kimi-review-policy-worker.js +217 -0
- package/plugins/agent-hub/server.js +2701 -664
- package/scripts/check-docs.mjs +12 -1
- package/src/adapters/acp-model-policy.ts +56 -0
- package/src/adapters/acp.ts +175 -5
- package/src/adapters/agy/entry.ts +8 -5
- package/src/adapters/agy/hooks.ts +65 -15
- package/src/adapters/channel-peer.ts +33 -1
- package/src/adapters/claude-implementer.ts +16 -3
- package/src/adapters/codex-appserver.ts +118 -8
- package/src/adapters/codex-review-isolation.ts +3 -1
- package/src/adapters/compat.ts +11 -6
- package/src/adapters/definitions/builtin-metadata.ts +8 -1
- package/src/adapters/definitions/native-cli-entry-kimi.ts +76 -7
- package/src/adapters/definitions/native-cli-preview-common.ts +2 -2
- package/src/adapters/definitions/native-cli-preview-kimi.ts +9 -2
- package/src/adapters/definitions/reviewer-variants.ts +3 -1
- package/src/adapters/kimi-review-policy-worker.js +2 -0
- package/src/adapters/kimi-review-policy-worker.ts +85 -0
- package/src/adapters/kimi-review-policy.ts +68 -0
- package/src/adapters/kimi-review.ts +41 -41
- package/src/adapters/kimi-tui-facts.ts +153 -0
- package/src/adapters/kimi-wire-usage.ts +33 -8
- package/src/adapters/local-worker.ts +59 -20
- package/src/adapters/model-token.ts +3 -0
- package/src/adapters/pi.ts +27 -5
- package/src/adapters/registry-types.ts +34 -3
- package/src/adapters/registry.ts +1 -0
- package/src/adapters/reviewer-policy-watch.ts +86 -0
- package/src/adapters/reviewer-print.ts +80 -18
- package/src/cli/agents.ts +1 -1
- package/src/cli/facts-hook.ts +4 -2
- package/src/cli/help.ts +3 -2
- package/src/cli/identity.ts +3 -0
- package/src/cli/kimi-facts-hook.ts +11 -0
- package/src/cli/kimi-plugin.ts +157 -0
- package/src/cli/main.ts +48 -9
- package/src/cli/model.ts +52 -0
- package/src/cli/output.ts +23 -1
- package/src/cli/recovery-package.ts +15 -7
- package/src/cli/reset.ts +1 -1
- package/src/cli/status-lines.ts +8 -4
- package/src/hub/budget.ts +1 -1
- package/src/hub/bus.ts +40 -2
- package/src/hub/code-facts.ts +10 -3
- package/src/hub/completions.ts +3 -1
- package/src/hub/config-apply.ts +2 -1
- package/src/hub/control-client.ts +1 -1
- package/src/hub/daemon.ts +1050 -106
- package/src/hub/deliverable-files.ts +38 -5
- package/src/hub/dogfood-profile.ts +1 -1
- package/src/hub/envelope.ts +1 -1
- package/src/hub/events.ts +28 -18
- package/src/hub/execution-lineage.ts +120 -0
- package/src/hub/execution-reference.ts +36 -0
- package/src/hub/hub-tools.ts +21 -2
- package/src/hub/instances.ts +4 -3
- package/src/hub/kimi-events.ts +46 -0
- package/src/hub/kimi-usage.ts +132 -29
- package/src/hub/lifecycle.ts +31 -15
- package/src/hub/limits.ts +13 -4
- package/src/hub/manager.ts +15 -2
- package/src/hub/memory-policy.ts +2 -1
- package/src/hub/memory-sync-attestation.ts +54 -0
- package/src/hub/memory-sync-state.ts +20 -5
- package/src/hub/model-overrides.ts +115 -0
- package/src/hub/native-hook-launch.ts +12 -3
- package/src/hub/operational-view.ts +2 -2
- package/src/hub/peers.ts +27 -0
- package/src/hub/permission-mode.ts +1 -1
- package/src/hub/person-figures.ts +88 -0
- package/src/hub/report.ts +38 -4
- package/src/hub/research-dataset.ts +158 -0
- package/src/hub/research-memory.ts +68 -0
- package/src/hub/research-quality.ts +118 -0
- package/src/hub/research-record.ts +279 -0
- package/src/hub/research.ts +126 -27
- package/src/hub/restart.ts +14 -0
- package/src/hub/settings.ts +3 -0
- package/src/hub/startup-event-seeds.ts +51 -0
- package/src/hub/startup-readiness.ts +65 -0
- package/src/hub/startup-timing.ts +38 -0
- package/src/hub/tasks.ts +29 -9
- package/src/hub/ui.ts +9 -5
- package/src/hub/visual-assets.ts +50 -0
- package/src/hub/visual.ts +257 -0
- package/src/memory/brief.ts +169 -14
- package/src/memory/capture.ts +2 -1
- package/src/memory/recall.ts +46 -5
- package/src/memory/record-provenance.ts +82 -0
- package/src/memory/records.ts +116 -0
- package/src/models/route/adec.ts +190 -0
- package/src/models/route/config.ts +37 -3
- package/src/models/route/decision-evidence.ts +83 -0
- package/src/models/route/decision.ts +143 -0
- package/src/models/route/runtime.ts +13 -6
- package/src/ui/index.html +134 -6
- package/src/ui/plugin.ts +65 -4
- package/templates/AGENTS.block.md +1 -1
- package/templates/config.json +1 -1
- package/templates/profiles/general.json +1 -1
- package/templates/profiles/research.json +1 -1
- 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
|
|
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,
|
|
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
|
-
|
|
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.
|
|
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,
|
package/src/adapters/compat.ts
CHANGED
|
@@ -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: "
|
|
59
|
-
receipt: "docs/verification/2026-11-
|
|
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);
|
|
63
|
-
receipt: "docs/verification/2026-11-
|
|
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" },
|