@staix/agent-hub 0.18.0 → 0.20.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 +30 -0
- package/README.md +4 -4
- package/docs/agent-notes/README.md +6 -0
- package/docs/agent-notes/adapters.md +13 -10
- package/docs/agent-notes/bus.md +1 -1
- package/docs/agent-notes/daemon.md +11 -4
- package/docs/agent-notes/local-worker.md +3 -1
- package/docs/agent-notes/tasks.md +6 -1
- package/docs/agent-notes/tests.md +8 -4
- package/docs/dogfood.md +13 -4
- package/docs/memory.md +1 -1
- package/docs/operations.md +98 -28
- package/docs/quickstart.md +13 -5
- package/docs/sdlc.md +12 -0
- package/docs/security.md +24 -15
- package/docs/smoke.md +3 -3
- package/docs/specs/2026-09-19-agent-hub-design.md +101 -8
- package/docs/verification/2026-10-25-remote-docker-transport.md +10 -0
- package/docs/verification/2026-10-26-agy-1.4.7.md +69 -0
- package/docs/verification/2026-10-26-claude-implementer-2.1.310.md +69 -0
- package/docs/verification/2026-10-26-headless-claude-shell-context.md +62 -0
- package/docs/verification/2026-10-26-jcode-0.99.1.md +49 -0
- package/docs/verification/2026-10-26-kimi-232-reviewer-compat.md +53 -0
- package/docs/verification/2026-10-26-pi-140-compat.md +41 -0
- package/docs/verification/2026-10-26-qwen-0261-acp.md +60 -0
- package/docs/verification/2026-10-27-0.19.0.md +86 -0
- package/docs/verification/2026-10-27-0.20.0.md +75 -0
- package/docs/verification/claude-permission-transport-native.py +187 -0
- package/docs/verification/claude-shell-wrapper-host.ts +20 -0
- package/docs/verification/claude-shell-wrapper-native.py +145 -0
- package/docs/verification/pi-140-model-free.ts +57 -0
- package/docs/verification/pi-140-native.mjs +21 -0
- package/docs/verified.json +25 -17
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +373 -72
- package/src/adapters/acp.ts +8 -0
- package/src/adapters/agy/entry.ts +1 -1
- package/src/adapters/channel-peer.ts +7 -5
- package/src/adapters/claude-channel.ts +7 -3
- package/src/adapters/claude-implementer.ts +6 -3
- package/src/adapters/codex-appserver.ts +11 -1
- package/src/adapters/compat.ts +32 -18
- package/src/adapters/conformance/enrollment.ts +4 -4
- package/src/adapters/conformance/factories.ts +2 -1
- package/src/adapters/conformance/fakes/pi-rpc.ts +1 -0
- package/src/adapters/conformance/kit.ts +10 -0
- package/src/adapters/definitions/builtin-metadata.ts +2 -2
- package/src/adapters/definitions/native-factories.ts +7 -2
- package/src/adapters/definitions/reviewer-variants.ts +2 -2
- package/src/adapters/local-worker.ts +2 -0
- package/src/adapters/pi.ts +46 -3
- package/src/adapters/qwen-dual.ts +25 -0
- package/src/adapters/registry-types.ts +6 -2
- package/src/adapters/registry.ts +1 -0
- package/src/adapters/reviewer-print.ts +1 -0
- package/src/cli/agents.ts +69 -7
- package/src/cli/claude-shell-wrapper.ts +54 -0
- package/src/cli/main.ts +30 -24
- package/src/cli/output.ts +4 -0
- package/src/hub/agent-drift.ts +61 -0
- package/src/hub/board.ts +14 -2
- package/src/hub/check-runner.ts +148 -18
- package/src/hub/ci-watch.ts +140 -0
- package/src/hub/claude-launch-proof.ts +67 -2
- package/src/hub/claude-shell-wrapper.ts +349 -0
- package/src/hub/config-apply.ts +2 -2
- package/src/hub/config-edit.ts +2 -2
- package/src/hub/config-tier.ts +2 -2
- package/src/hub/config-trust.ts +1 -0
- package/src/hub/daemon.ts +282 -113
- package/src/hub/host-executable.ts +20 -1
- package/src/hub/instances.ts +18 -2
- package/src/hub/lifecycle.ts +1 -1
- package/src/hub/operational-authority.ts +7 -0
- package/src/hub/operational-view.ts +6 -10
- package/src/hub/peers.ts +34 -0
- package/src/hub/read-only-shell-proof.ts +7 -6
- package/src/hub/review-scope.ts +13 -0
- package/src/hub/sdlc-metrics.ts +6 -4
- package/src/hub/settings.ts +1 -0
- package/src/hub/task-shell-command.ts +85 -0
- package/src/hub/task-shell-default.ts +79 -0
- package/src/hub/task-shell-proof.ts +171 -0
- package/src/hub/task-shell-sandbox.ts +61 -0
- package/src/hub/tasks.ts +274 -16
- package/src/local/deny.ts +112 -11
- package/src/local/sandbox.ts +70 -29
- package/src/local/shell-environment.ts +10 -0
- package/src/ui/index.html +1 -0
package/src/adapters/acp.ts
CHANGED
|
@@ -10,6 +10,7 @@ export { QwenIdentityError } from "./definitions/qwen-native.ts";
|
|
|
10
10
|
import { BasePeer } from "../hub/peers.ts";
|
|
11
11
|
import { peerChildEnv, stopOwnedProcess, trackGroup } from "../hub/child-process.ts";
|
|
12
12
|
import { AcpOwnedGroup } from "./acp-owned-group.ts";
|
|
13
|
+
import type { StartForm } from "./registry-types.ts";
|
|
13
14
|
|
|
14
15
|
export interface PermissionOption {
|
|
15
16
|
optionId: string;
|
|
@@ -163,6 +164,9 @@ function jsonObject(text: string | undefined): object | undefined {
|
|
|
163
164
|
|
|
164
165
|
/** ACP client (JSON-RPC 2.0, newline-delimited, over the child's stdio). One session, one prompt in flight. */
|
|
165
166
|
export class AcpPeer extends BasePeer {
|
|
167
|
+
readonly deliveryOnly: boolean = true;
|
|
168
|
+
readonly hubOwned: boolean = true;
|
|
169
|
+
readonly form: StartForm = "headless";
|
|
166
170
|
private permissionMode: PermissionMode;
|
|
167
171
|
private readonly nativePolicy: AcpOptions["nativePolicy"];
|
|
168
172
|
private modeUnknown = false;
|
|
@@ -256,6 +260,10 @@ export class AcpPeer extends BasePeer {
|
|
|
256
260
|
recoveryMetadata(): Record<string, unknown> {
|
|
257
261
|
return { launch: { kind: "acp", ...(this.opts.launchModel ? { model: this.opts.launchModel } : {}) }, ...(this.sessionId ? { sessionId: this.sessionId } : {}) };
|
|
258
262
|
}
|
|
263
|
+
get nativeSession(): string | undefined {
|
|
264
|
+
const session = this.recoveryMetadata().sessionId;
|
|
265
|
+
return typeof session === "string" ? session : undefined;
|
|
266
|
+
}
|
|
259
267
|
|
|
260
268
|
async start(): Promise<void> {
|
|
261
269
|
if (this.opts.requireOwnedGroup && this.proc) throw new Error("Configured ACP still has a process; stop it before starting another");
|
|
@@ -37,7 +37,7 @@ function runAgyTui(argv: string[], cwd: string, env: NodeJS.ProcessEnv): Promise
|
|
|
37
37
|
/** #607: agy 1.4.x attaches the plugin MCP server at the first prompt, so the hub tools are listed from the second turn. */
|
|
38
38
|
export const AGY_LAUNCH_NOTICE = "Antigravity: pull-only TUI; native permissions unmanaged, no hub sandbox; hub tools appear from the second turn (send any first prompt, then ask for hub work)";
|
|
39
39
|
|
|
40
|
-
/** agy auto-updates, so the 1.4.x line launches; only 1.4.2
|
|
40
|
+
/** agy auto-updates, so the 1.4.x line launches; only 1.4.2 and 1.4.7 have a T0 receipt and other patches warn (#589). */
|
|
41
41
|
export function agyVersionGate(stdout: string): { ok: true; message?: string } | { ok: false; message: string } {
|
|
42
42
|
// #691: the verified versions, the compatible line and the person's trust live in compat.ts.
|
|
43
43
|
const verdict = assessVersion("agy", COMPAT.agy.parse(stdout));
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { BasePeer, type PeerAdapter } from "../hub/peers.ts";
|
|
2
2
|
import type { Envelope } from "../hub/envelope.ts";
|
|
3
3
|
import { definitionOf } from "./registry.ts";
|
|
4
|
+
import type { StartForm } from "./registry-types.ts";
|
|
4
5
|
|
|
5
6
|
/** Only the control socket operations the peer owns; authentication remains in the daemon. */
|
|
6
7
|
export interface ChannelSocket {
|
|
@@ -12,6 +13,7 @@ export interface ChannelSocket {
|
|
|
12
13
|
|
|
13
14
|
/** A peer that lives in another process and attaches over the control WS (the Claude channel plugin). */
|
|
14
15
|
export class WsPeer extends BasePeer {
|
|
16
|
+
readonly form: StartForm = "tui";
|
|
15
17
|
private generation = crypto.randomUUID();
|
|
16
18
|
// Checkpoints become stale at hello/claim, before asynchronous recall finishes and attach changes delivery generation.
|
|
17
19
|
private claimGeneration = crypto.randomUUID();
|
|
@@ -23,7 +25,7 @@ export class WsPeer extends BasePeer {
|
|
|
23
25
|
private delivered = new Set<string>();
|
|
24
26
|
private sock: ChannelSocket | undefined;
|
|
25
27
|
private claimed: ChannelSocket | undefined;
|
|
26
|
-
private
|
|
28
|
+
private nativeTurnSession: string | undefined;
|
|
27
29
|
private nativeActive = false;
|
|
28
30
|
/** The attached session said it cannot show channel pushes (issue #205): the bus keeps its queue for `inbox`. */
|
|
29
31
|
pullOnly = false;
|
|
@@ -66,7 +68,7 @@ export class WsPeer extends BasePeer {
|
|
|
66
68
|
this.sock = sock;
|
|
67
69
|
this.generation = crypto.randomUUID();
|
|
68
70
|
this.delivered.clear();
|
|
69
|
-
this.
|
|
71
|
+
this.nativeTurnSession = undefined;
|
|
70
72
|
this.nativeActive = false;
|
|
71
73
|
this.pullOnly = this.claimedPullOnly; // before idle: the idle transition drains
|
|
72
74
|
// Native TUI readback in #493 proves important channel input can be consumed before the turn ends.
|
|
@@ -112,13 +114,13 @@ export class WsPeer extends BasePeer {
|
|
|
112
114
|
owns(sock: ChannelSocket): boolean { return this.sock === sock; }
|
|
113
115
|
nativeStarted(session: string): void {
|
|
114
116
|
if (!this.sock) return;
|
|
115
|
-
this.
|
|
117
|
+
this.nativeTurnSession = session;
|
|
116
118
|
this.nativeActive = true;
|
|
117
119
|
this.setState("busy");
|
|
118
120
|
}
|
|
119
121
|
nativeStopped(session: string): boolean {
|
|
120
|
-
if (!this.sock || (this.
|
|
121
|
-
this.
|
|
122
|
+
if (!this.sock || (this.nativeTurnSession !== undefined && this.nativeTurnSession !== session)) return false;
|
|
123
|
+
this.nativeTurnSession = session;
|
|
122
124
|
this.nativeActive = false;
|
|
123
125
|
this.setState("idle");
|
|
124
126
|
return true;
|
|
@@ -400,9 +400,13 @@ async function callTool(req: { params: { name: string; arguments?: Record<string
|
|
|
400
400
|
|| !args.input || typeof args.input !== "object" || Array.isArray(args.input)
|
|
401
401
|
|| typeof args.tool_use_id !== "string" || !args.tool_use_id.length || args.tool_use_id.length > 256) return deny("invalid native permission request");
|
|
402
402
|
try {
|
|
403
|
-
//
|
|
404
|
-
const res = await hub.request({ t: "permission_request", tool: args?.tool_name, input: args?.input }, 3_605_000);
|
|
405
|
-
|
|
403
|
+
// Only this fixed client capability permits host wrapper grants; older clients keep person approval.
|
|
404
|
+
const res = await hub.request({ t: "permission_request", tool: args?.tool_name, input: args?.input, wrapperProtocol: 1 }, 3_605_000);
|
|
405
|
+
if (res.ok !== true || res.behavior !== "allow") return deny(typeof res.message === "string" ? res.message : "permission denied");
|
|
406
|
+
if (res.wrapperProtocol !== undefined && res.wrapperProtocol !== 1) return deny("unsupported host execution response");
|
|
407
|
+
const updated = res.wrapperProtocol === 1 ? res.updatedInput : undefined;
|
|
408
|
+
if (res.wrapperProtocol === 1 && (args.tool_name !== "Bash" || !updated || typeof updated !== "object" || Array.isArray(updated) || typeof updated.command !== "string")) return deny("invalid host execution response");
|
|
409
|
+
return text(JSON.stringify({ behavior: "allow", updatedInput: updated ?? args?.input }));
|
|
406
410
|
} catch { return deny("hub permission unavailable"); }
|
|
407
411
|
}
|
|
408
412
|
if (name === "hub_delivery_done" && channel) {
|
|
@@ -23,6 +23,8 @@ export interface ClaudeImplementerOptions {
|
|
|
23
23
|
factsHook: string;
|
|
24
24
|
systemPrompt: string;
|
|
25
25
|
watchdogMs: number;
|
|
26
|
+
/** Declared `model` of this instance (#844): one validated word passed as `--model`; unset leaves the native default and the argv unchanged. */
|
|
27
|
+
model?: string;
|
|
26
28
|
mode?: "ask" | "ask-when-needed";
|
|
27
29
|
/** Exact host-reported transcript evidence only; unknown/read errors retain the recorded UUID. */
|
|
28
30
|
transcriptPersisted?(task: number, sessionId: string): boolean | "unknown";
|
|
@@ -38,10 +40,10 @@ export interface ClaudeImplementerOptions {
|
|
|
38
40
|
prove?(): Promise<() => boolean>;
|
|
39
41
|
}
|
|
40
42
|
export interface ImplementerTurn { sessionId: string; resume: boolean; persistent: boolean }
|
|
41
|
-
export function claudeImplementerArgs(turn: ImplementerTurn, mcp: string, settings: string, prompt: string): string[] {
|
|
43
|
+
export function claudeImplementerArgs(turn: ImplementerTurn, mcp: string, settings: string, prompt: string, model?: string): string[] {
|
|
42
44
|
return ["-p", "--output-format", "stream-json", "--verbose", turn.resume ? "--resume" : "--session-id", turn.sessionId,
|
|
43
45
|
...(!turn.persistent ? ["--no-session-persistence"] : []), "--strict-mcp-config", "--mcp-config", mcp, "--setting-sources", "", "--settings", settings,
|
|
44
|
-
"--permission-mode", "default", "--permission-prompts", "host", "--permission-prompt-tool", "mcp__agent-hub__hub_permission_request",
|
|
46
|
+
...(model ? ["--model", model] : []), "--permission-mode", "default", "--permission-prompts", "host", "--permission-prompt-tool", "mcp__agent-hub__hub_permission_request",
|
|
45
47
|
"--allowedTools", ...IMPLEMENTER_HUB_TOOL_NAMES.map(name => `mcp__agent-hub__${name}`), "--append-system-prompt", prompt];
|
|
46
48
|
}
|
|
47
49
|
|
|
@@ -70,6 +72,7 @@ export async function proveClaudeImplementer(peer: string, command: readonly str
|
|
|
70
72
|
/** One owned print child at a time. Idle holds no native process; task continuity is ids-only on disk. */
|
|
71
73
|
export class ClaudeImplementerPeer extends BasePeer {
|
|
72
74
|
readonly hubNative = true;
|
|
75
|
+
readonly hubOwned: boolean = true;
|
|
73
76
|
private generation = 0;
|
|
74
77
|
private owned: OwnedPrintChild | undefined;
|
|
75
78
|
private running: Promise<void> | undefined;
|
|
@@ -184,7 +187,7 @@ export class ClaudeImplementerPeer extends BasePeer {
|
|
|
184
187
|
AGENTHUB_INSTANCE_ID: this.options.instanceId, AGENTHUB_LAUNCH_ID: launchId, AGENTHUB_HOOK_PURPOSE: "permission", AGENTHUB_PERMISSION_TOOL: "1" };
|
|
185
188
|
const mcp = JSON.stringify({ mcpServers: { "agent-hub": { command: process.execPath, args: ["run", this.options.server], env } } });
|
|
186
189
|
let result = false, failed = false;
|
|
187
|
-
const child = new OwnedPrintChild({ peer: this.id, command: this.command!, args: claudeImplementerArgs(turn, mcp, settings, this.options.systemPrompt), cwd: this.options.cwd,
|
|
190
|
+
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,
|
|
188
191
|
input: group.envs.map(frame).join("\n\n"), env, current, stop: this.options.stopChild,
|
|
189
192
|
onSpawn: () => {
|
|
190
193
|
try {
|
|
@@ -2,7 +2,7 @@ import { spawn, type ChildProcess } from "node:child_process";
|
|
|
2
2
|
import type { Server, ServerWebSocket } from "bun";
|
|
3
3
|
import { renderDigest, replyAudience, replyParent, type Envelope, type PeerId } from "../hub/envelope.ts";
|
|
4
4
|
import type { PermissionMode } from "../hub/permission-mode.ts";
|
|
5
|
-
import { BasePeer } from "../hub/peers.ts";
|
|
5
|
+
import { BasePeer, type FrontendProxyPort } from "../hub/peers.ts";
|
|
6
6
|
import { codexContext, type ContextReading } from "../hub/context-window.ts";
|
|
7
7
|
import { peerChildEnv, stopOwnedProcess, trackGroup } from "../hub/child-process.ts";
|
|
8
8
|
import type { ContextReader } from "./registry-types.ts";
|
|
@@ -82,6 +82,7 @@ const TRACKED = new Set(["thread/start", "thread/resume"]);
|
|
|
82
82
|
* TUI's, and their responses are swallowed.
|
|
83
83
|
*/
|
|
84
84
|
export class CodexPeer extends BasePeer {
|
|
85
|
+
readonly frontendProxy: FrontendProxyPort;
|
|
85
86
|
private permissionMode: PermissionMode;
|
|
86
87
|
private readonly actionWaits = new Map<AbortController, { link: Link; turn: string | undefined }>();
|
|
87
88
|
get actionGated(): boolean { return this.opts.actionGate !== undefined; }
|
|
@@ -136,6 +137,13 @@ export class CodexPeer extends BasePeer {
|
|
|
136
137
|
) {
|
|
137
138
|
super(id, opts.watchdogMs);
|
|
138
139
|
this.permissionMode = opts.permissionMode ?? "ask";
|
|
140
|
+
const owner = this;
|
|
141
|
+
this.frontendProxy = {
|
|
142
|
+
get url() { return owner.proxyUrl; },
|
|
143
|
+
get attached() { return owner.proxyAttached; },
|
|
144
|
+
setStartupPermissionMode: this.setStartupPermissionMode.bind(this),
|
|
145
|
+
clearPermissionMode: this.clearPermissionMode.bind(this),
|
|
146
|
+
};
|
|
139
147
|
}
|
|
140
148
|
|
|
141
149
|
recoveryMetadata(): Record<string, unknown> {
|
|
@@ -149,6 +157,7 @@ export class CodexPeer extends BasePeer {
|
|
|
149
157
|
get thread(): string {
|
|
150
158
|
return this.threadId;
|
|
151
159
|
}
|
|
160
|
+
get nativeSession(): string | undefined { return this.threadId || undefined; }
|
|
152
161
|
|
|
153
162
|
get proxyUrl(): string {
|
|
154
163
|
return `ws://127.0.0.1:${this.server?.port ?? this.opts.proxyPort}`;
|
|
@@ -694,6 +703,7 @@ export class CodexPeer extends BasePeer {
|
|
|
694
703
|
link.up.send(JSON.stringify({ method: "thread/revert", id, params: { threadId: this.threadId, beforeTurnId: turnId } }));
|
|
695
704
|
});
|
|
696
705
|
}
|
|
706
|
+
revertTurn(nativeTurn: string): Promise<void> { return this.revert(nativeTurn); }
|
|
697
707
|
|
|
698
708
|
/** account/rateLimits/read through the TUI's connection, with a hub id so the answer never reaches the TUI. */
|
|
699
709
|
private readUsage(): void {
|
package/src/adapters/compat.ts
CHANGED
|
@@ -21,48 +21,62 @@ export interface CompatPolicy {
|
|
|
21
21
|
parse: (stdout: string) => string | undefined;
|
|
22
22
|
/** What runtime checks still decide after trust (isolation) or what stays unverified (contract). */
|
|
23
23
|
trustNote: string;
|
|
24
|
+
/** The last receipt in docs/verification whose model-free procedure a new version repeats (#843). */
|
|
25
|
+
receipt: string;
|
|
24
26
|
/** The config key that selects this kind's executable, when one exists. */
|
|
25
|
-
config?: "codex_bin" | "jcode_cmd" | "qwen_cmd";
|
|
27
|
+
config?: "codex_bin" | "jcode_cmd" | "qwen_cmd" | "pi.cmd";
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
const plain = (text: string) => text.length <= 64 && /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(text) ? text : undefined;
|
|
29
31
|
export const COMPAT = {
|
|
30
|
-
// Receipt: docs/verification/2026-10-
|
|
31
|
-
|
|
32
|
+
// Receipt: docs/verification/2026-10-26-pi-140-compat.md
|
|
33
|
+
pi: { label: "Pi", verified: ["1.4.0"], gate: "contract", probe: ["pi", "--version"], config: "pi.cmd",
|
|
34
|
+
parse: out => plain(out.trim()),
|
|
35
|
+
trustNote: "The model-free extension load, RPC session identity and managed tool receipt path were checked on 1.4.0. Permissions remain host-owned; account-backed inference and another version's extension contract are unverified.",
|
|
36
|
+
receipt: "docs/verification/2026-10-26-pi-140-compat.md" },
|
|
37
|
+
// Transport receipts: docs/verification/2026-10-23-headless-claude-permissions.md; 2.1.310: docs/verification/2026-10-26-claude-implementer-2.1.310.md; host-wrapper receipt below repeats with them.
|
|
38
|
+
"claude-implementer": { label: "Claude headless implementer", verified: ["2.1.309", "2.1.310"], gate: "contract", probe: ["claude", "--version"],
|
|
32
39
|
parse: out => plain(out.trim().replace(/ \(Claude Code\)$/, "")),
|
|
33
|
-
trustNote: "The native prompt-tool
|
|
40
|
+
trustNote: "The native prompt-tool transport and host-wrapper rewrite, environment isolation and replay contract have isolated receipts. No dontAsk/bypass fallback exists; another version's permission or rewrite behavior is unverified.",
|
|
41
|
+
receipt: "docs/verification/2026-10-26-headless-claude-shell-context.md" },
|
|
34
42
|
// 0.163.0 receipt: docs/verification/2026-10-22-codex-reviewer-isolation.md; 0.164.0 receipt: docs/verification/2026-10-24-codex-0164-reviewer-isolation.md
|
|
35
43
|
"codex-review": { label: "Codex reviewer (codex-cli)", verified: ["0.146.0", "0.163.0", "0.164.0"], gate: "isolation", probe: ["codex", "--version"], config: "codex_bin",
|
|
36
44
|
parse: out => plain(out.trim().replace(/^codex-cli /, "")),
|
|
37
|
-
trustNote: "Every launch still proves the MCP inventory, Apps/plugins/hooks disabled, sandbox read/write denial and the binary fence; a failed leg still refuses. Trust covers only the source-level claims those probes cannot see (docs/verification/2026-10-19-reviewer-isolation.md)."
|
|
38
|
-
|
|
39
|
-
|
|
45
|
+
trustNote: "Every launch still proves the MCP inventory, Apps/plugins/hooks disabled, sandbox read/write denial and the binary fence; a failed leg still refuses. Trust covers only the source-level claims those probes cannot see (docs/verification/2026-10-19-reviewer-isolation.md).",
|
|
46
|
+
receipt: "docs/verification/2026-10-24-codex-0164-reviewer-isolation.md" },
|
|
47
|
+
// Kimi 2.3.0 / Qwen 0.26.0 receipt: docs/verification/2026-10-22-kimi-qwen-compat.md; Kimi 2.3.1: docs/verification/2026-10-25-kimi-231-reviewer-compat.md; Kimi 2.3.2: docs/verification/2026-10-26-kimi-232-reviewer-compat.md
|
|
48
|
+
"kimi-review": { label: "Kimi reviewer (kimi-code)", verified: ["2.2.0", "2.3.0", "2.3.1", "2.3.2"], gate: "isolation", probe: ["kimi", "--version"],
|
|
40
49
|
parse: out => plain(out.trim()),
|
|
41
|
-
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."
|
|
42
|
-
|
|
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
|
+
receipt: "docs/verification/2026-10-26-kimi-232-reviewer-compat.md" },
|
|
52
|
+
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",
|
|
43
53
|
parse: out => { try { const v = JSON.parse(out).semver; return typeof v === "string" ? plain(v) : undefined; } catch { return undefined; } },
|
|
44
|
-
trustNote: "The ACP, serve and MCP contract was verified on 0.98.1 and steering on 0.99.0; an untested version may change them."
|
|
45
|
-
|
|
54
|
+
trustNote: "The ACP, serve and MCP contract was verified on 0.98.1 and 0.99.1 and steering on 0.99.0 and 0.99.1; an untested version may change them.",
|
|
55
|
+
receipt: "docs/verification/2026-10-26-jcode-0.99.1.md" },
|
|
56
|
+
agy: { label: "agy", verified: ["1.4.2", "1.4.7"], line: { min: "1.4.0", below: "1.5.0" }, gate: "contract", probe: ["agy", "--version"],
|
|
46
57
|
parse: out => plain(out.trim()),
|
|
47
|
-
trustNote: "Only 1.4.2
|
|
48
|
-
|
|
58
|
+
trustNote: "Only 1.4.2 and 1.4.7 are T0-verified; the plugin MCP and hook contract of another version is unverified.",
|
|
59
|
+
receipt: "docs/verification/2026-10-26-agy-1.4.7.md" },
|
|
60
|
+
qwen: { label: "Qwen Code", verified: ["0.25.0", "0.26.0", "0.26.1"], gate: "contract", probe: ["qwen", "--version"], config: "qwen_cmd",
|
|
49
61
|
parse: out => plain(out.trim()),
|
|
50
|
-
trustNote: "Native ACP identity
|
|
62
|
+
trustNote: "Native ACP identity, mode ids and permission callbacks were re-checked without inference on 0.26.1; its two ACP fixes were judged not to change the hub contract. Another version may change those contracts.",
|
|
63
|
+
receipt: "docs/verification/2026-10-26-qwen-0261-acp.md" },
|
|
51
64
|
// Receipt: docs/verification/2026-10-25-qwen-dual-output-t0.md. The dual-output contract (input file, event file, can_use_tool control requests) is its own entry:
|
|
52
65
|
// the ACP receipts of the headless `qwen` entry say nothing about it, so that entry is not widened and this one carries no `line`.
|
|
53
66
|
"qwen-dual": { label: "Qwen Code dual-output TUI", verified: ["0.26.1"], gate: "contract", probe: ["qwen", "--version"], config: "qwen_cmd",
|
|
54
67
|
parse: out => plain(out.trim()),
|
|
55
|
-
trustNote: "The dual-output file contract (submit and confirmation_response input, user/assistant/stream_event/control events, session_start identity) was verified on 0.26.1 only. A hub deny ends the turn without any Qwen event, and no external stop of a running turn exists; another version may change either."
|
|
68
|
+
trustNote: "The dual-output file contract (submit and confirmation_response input, user/assistant/stream_event/control events, session_start identity) was verified on 0.26.1 only. A hub deny ends the turn without any Qwen event, and no external stop of a running turn exists; another version may change either.",
|
|
69
|
+
receipt: "docs/verification/2026-10-25-qwen-dual-output-t0.md" },
|
|
56
70
|
} as const satisfies Record<string, CompatPolicy>;
|
|
57
71
|
export type CompatId = keyof typeof COMPAT;
|
|
58
|
-
type CommandConfig = { codex_bin: string; jcode_cmd: string[]; qwen_cmd: string[] };
|
|
72
|
+
type CommandConfig = { codex_bin: string; jcode_cmd: string[]; qwen_cmd: string[]; pi?: { cmd: string[] } };
|
|
59
73
|
/** The configured executables the native gates use; the rest probe their PATH default. */
|
|
60
74
|
export function configuredCommands(config: CommandConfig): Partial<Record<CompatId, readonly string[]>> {
|
|
61
75
|
const out: Partial<Record<CompatId, readonly string[]>> = {};
|
|
62
76
|
for (const [id, policy] of Object.entries(COMPAT) as [CompatId, CompatPolicy][]) {
|
|
63
77
|
if (!policy.config) continue;
|
|
64
|
-
const value = config[policy.config];
|
|
65
|
-
out[id] = typeof value === "string" ? [value] : value;
|
|
78
|
+
const value = policy.config === "pi.cmd" ? config.pi?.cmd : config[policy.config];
|
|
79
|
+
if (value !== undefined) out[id] = typeof value === "string" ? [value] : value;
|
|
66
80
|
}
|
|
67
81
|
return out;
|
|
68
82
|
}
|
|
@@ -4,7 +4,7 @@ import { isFixedBuiltinDefinition } from "../registry.ts";
|
|
|
4
4
|
/** Stable generic ACP legs; both actual test registration and enrollment validation consume this catalog. */
|
|
5
5
|
export const ACP_LEGS = Object.freeze(["native-approval-rpc", "native-mode-ack", "native-quota", "watchdog-generation"] as const);
|
|
6
6
|
export type AcpLeg = typeof ACP_LEGS[number];
|
|
7
|
-
/**
|
|
7
|
+
/** Mandatory product invariant, asserted for generic and owned ACP factories rather than skipped protocol legs. */
|
|
8
8
|
export const ACP_PORTS_LEG = "restrictive-ports";
|
|
9
9
|
export const NATIVE_PROOF_PORTS_LEG = "native-proof-ports";
|
|
10
10
|
/** No unsupported key or non-generic ACP engine can silently escape an executable catalog leg. */
|
|
@@ -18,8 +18,9 @@ export function assertAcpLegEnrollment(definitions: readonly AdapterDefinition[]
|
|
|
18
18
|
if (!row || !isFixedBuiltinDefinition(row.definition)) throw new Error(`${key}: only fixed builtin proof legs may be nonapplicable`);
|
|
19
19
|
continue;
|
|
20
20
|
}
|
|
21
|
-
if (separator < 1 || !acp.has(kind)
|
|
22
|
-
if (leg === ACP_PORTS_LEG
|
|
21
|
+
if (separator < 1 || !acp.has(kind)) throw new Error(`${key}: stale or unknown unsupported ACP leg`);
|
|
22
|
+
if (leg === ACP_PORTS_LEG) throw new Error(`${key}: mandatory ACP restrictive ports leg cannot be unsupported`);
|
|
23
|
+
if (!(ACP_LEGS as readonly string[]).includes(leg)) throw new Error(`${key}: stale or unknown unsupported ACP leg`);
|
|
23
24
|
}
|
|
24
25
|
for (const definition of acp.values()) if (definition.start.daemonPort !== "acp") {
|
|
25
26
|
for (const leg of ACP_LEGS) {
|
|
@@ -50,7 +51,6 @@ export function assertEnrollment(definitions: readonly AdapterDefinition[], enro
|
|
|
50
51
|
}
|
|
51
52
|
for (const { id, definition } of rows) {
|
|
52
53
|
if (isFixedBuiltinDefinition(definition) && !Object.hasOwn(unsupportedLegs, `${id}:${NATIVE_PROOF_PORTS_LEG}`)) throw new Error(`${id}: native proof ports need an explicit nonapplicable reason`);
|
|
53
|
-
if (definition.transport === "acp" && !Object.hasOwn(unsupportedLegs, `${id}:${ACP_PORTS_LEG}`)) throw new Error(`${id}: ACP restrictive ports require the reserved 2b reason`);
|
|
54
54
|
if (!enrolled.includes(id) && !Object.hasOwn(unsupported, id)) throw new Error(`${id}: no conformance fixture or unsupported reason`);
|
|
55
55
|
if (definition.transport === "acp" && !enrolled.includes(id)) throw new Error(`${id}: every ACP definition must run the ACP legs`);
|
|
56
56
|
if (id.endsWith(":reviewer") && !definition.reviewer) throw new Error(`${id}: reviewer variant lacks reviewer policy`);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { AdapterDefinition, CreateContext, DaemonPorts } from "../registry-types.ts";
|
|
2
2
|
import type { PeerAdapter } from "../../hub/peers.ts";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
-
import { assertNativeProofPorts } from "./kit.ts";
|
|
4
|
+
import { assertAcpProductPorts, assertNativeProofPorts } from "./kit.ts";
|
|
5
5
|
|
|
6
6
|
/** The supplied definition creates its actual adapter, with only this fixture's transport options. */
|
|
7
7
|
export function createFromDefinition<T extends PeerAdapter>(definition: AdapterDefinition, daemon: DaemonPorts): T {
|
|
@@ -10,6 +10,7 @@ export function createFromDefinition<T extends PeerAdapter>(definition: AdapterD
|
|
|
10
10
|
const product = definition.start.create({ instance: definition.kind, projectRoot: options.cwd, stateDir: options.stateDir ?? join(options.cwd, "state"),
|
|
11
11
|
kindConfig: {}, permission: "ask", mcpServers: options.mcpServers ?? [], daemon }) as T;
|
|
12
12
|
assertNativeProofPorts(definition, product);
|
|
13
|
+
assertAcpProductPorts(definition, product);
|
|
13
14
|
return product;
|
|
14
15
|
}
|
|
15
16
|
/** Explicit watchdog seam on the factory-created implementation, replacing direct test subclasses. */
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createInterface } from "node:readline";
|
|
2
2
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
+
if (process.argv.includes("--version")) { console.log("1.4.0"); process.exit(0); }
|
|
4
5
|
const option = (flag: string) => process.argv.includes(flag) ? process.argv[process.argv.indexOf(flag) + 1] : undefined;
|
|
5
6
|
const empty = process.argv.includes("--empty-session");
|
|
6
7
|
const dir = option("--session-dir")!;
|
|
@@ -24,6 +24,15 @@ export interface AdapterConformance {
|
|
|
24
24
|
create(): Promise<ConformanceFixture>;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
/** Every ACP factory product keeps the transport's restrictive delivery and ownership contract. */
|
|
28
|
+
export function assertAcpProductPorts(definition: AdapterDefinition | undefined, product: PeerAdapter): void {
|
|
29
|
+
if (definition?.transport !== "acp") return;
|
|
30
|
+
for (const port of ["deliveryOnly", "hubOwned"] as const) {
|
|
31
|
+
if (product[port] !== true) throw new Error(`${definition.kind}: ACP restrictive port ${port} must be true`);
|
|
32
|
+
}
|
|
33
|
+
if (product.form !== "headless") throw new Error(`${definition.kind}: ACP restrictive port form must be headless`);
|
|
34
|
+
}
|
|
35
|
+
|
|
27
36
|
/** Presence itself is forbidden on a foreign product: even an undefined native proof port must fail the kit. */
|
|
28
37
|
export function assertNativeProofPorts(definition: AdapterDefinition | undefined, product: PeerAdapter): void {
|
|
29
38
|
if (isFixedBuiltinDefinition(definition)) return;
|
|
@@ -38,6 +47,7 @@ export function registerConformance(adapter: AdapterConformance): void {
|
|
|
38
47
|
const f = await adapter.create();
|
|
39
48
|
try {
|
|
40
49
|
assertNativeProofPorts(adapter.definition, f.peer);
|
|
50
|
+
assertAcpProductPorts(adapter.definition, f.peer);
|
|
41
51
|
expect(f.peer.id).toBe(adapter.kind);
|
|
42
52
|
expect(f.peer.state).toBe("idle");
|
|
43
53
|
const envelope: Envelope = newEnvelope("user", "conformance ping", { to: [adapter.kind] });
|
|
@@ -85,8 +85,8 @@ const CLAUDE_IMPLEMENTER_VARIANT: AdapterDefinition = {
|
|
|
85
85
|
api: 1, kind: "claude", displayName: "Claude Code implementer", family: "claude", transport: "hub-native",
|
|
86
86
|
roles: Object.freeze(["implementer"]), defaultOnRoster: true, trust: { onPrem: false, hubSandboxed: false },
|
|
87
87
|
start: { create: createClaudeImplementer, daemonPort: "claude-implementer", forms: headlessForms, defaultForm: "headless", conductorStartable: true, hubSwitches: [], ownedFlags: [] },
|
|
88
|
-
identity: { mcp: "env", currentChild: true }, instances: { mode: "headless", roles: ["implementer", "verifier"] },
|
|
89
|
-
permission: { grants: { ask: "every write, edit and shell command asks", "ask-when-needed": "file tools inside the owned worktree may run automatically;
|
|
88
|
+
identity: { mcp: "env", currentChild: true }, instances: { mode: "headless", roles: ["implementer", "verifier"], modelSelect: true },
|
|
89
|
+
permission: { grants: { ask: "every write, edit and shell command asks", "ask-when-needed": "file tools, proven read-only shell commands and bound task commands inside the owned worktree may run automatically; protected paths and other commands ask" } },
|
|
90
90
|
session: { idField: "sessionId", currentId: sessionId("sessionId"), freshOnRestore: false, launchKind: "claude-implementer", crashPlan: () => ({ resume: {}, how: "Claude implementer resumes its recorded per-task sessions" }) },
|
|
91
91
|
turnEnd: "adapter", budget: { account: "claude" },
|
|
92
92
|
cli: { command: "claude", startWords: () => ["claude"] }, display: CLAUDE_DISPLAY,
|
|
@@ -34,8 +34,13 @@ export function createQwenDual(ctx: CreateContext): PeerAdapter {
|
|
|
34
34
|
export function createPi(ctx: CreateContext): PeerAdapter {
|
|
35
35
|
if (!("pi" in ctx.daemon)) throw new Error("Pi requires its Pi transport options");
|
|
36
36
|
if (ctx.permission === "read-only") throw new Error("Pi has no verified read-only permission mapping");
|
|
37
|
-
const { PiPeer } = require("../pi.ts") as typeof import("../pi.ts");
|
|
38
|
-
|
|
37
|
+
const { PiPeer, PiVersionRefusal, piVersionAsync } = require("../pi.ts") as typeof import("../pi.ts");
|
|
38
|
+
const options = ctx.daemon.pi;
|
|
39
|
+
return new PiPeer(ctx.instance, options, async () => {
|
|
40
|
+
const verdict = await piVersionAsync(options.cmd ?? ["pi"], options.cwd);
|
|
41
|
+
if (!verdict.ok) throw new PiVersionRefusal(verdict);
|
|
42
|
+
if (verdict.note) options.log?.(verdict.note);
|
|
43
|
+
});
|
|
39
44
|
}
|
|
40
45
|
|
|
41
46
|
export function createLocal(ctx: CreateContext): PeerAdapter {
|
|
@@ -19,9 +19,9 @@ function reviewerVariant(kind: string, displayName: string, reviewer: NonNullabl
|
|
|
19
19
|
reviewer, cli: { command: kind, startWords: () => [] }, display,
|
|
20
20
|
};
|
|
21
21
|
}
|
|
22
|
-
const codexVariant = reviewerVariant("codex", "Codex reviewer", { backend: codexReviewerBackend, defaultPool: true, declaration: "explicit" }, CODEX_DISPLAY);
|
|
22
|
+
const codexVariant = reviewerVariant("codex", "Codex reviewer", { backend: codexReviewerBackend, defaultPool: true, declaration: "explicit", scope: "code" }, CODEX_DISPLAY);
|
|
23
23
|
export const CODEX_REVIEWER_VARIANT: AdapterDefinition = { ...codexVariant, start: { ...codexVariant.start, create: createCodexReviewer } };
|
|
24
|
-
export const KIMI_REVIEWER_VARIANT = reviewerVariant("kimi", "Kimi reviewer", { backend: kimiReviewerBackend, defaultPool: true, declaration: "explicit" }, KIMI_DISPLAY);
|
|
24
|
+
export const KIMI_REVIEWER_VARIANT = reviewerVariant("kimi", "Kimi reviewer", { backend: kimiReviewerBackend, defaultPool: true, declaration: "explicit", scope: "code" }, KIMI_DISPLAY);
|
|
25
25
|
/** Data-only declared placeholder for route explanations; no factory, backend or native argv. */
|
|
26
26
|
export const AGY_REVIEWER_VARIANT: AdapterDefinition = {
|
|
27
27
|
...reviewerVariant("agy", "agy reviewer", { defaultPool: false, declaration: "automatic", unavailable: "agy is an implementer peer (#485)" }, { palette: { fg: "#6558a6", bg: "#f1effa" } }),
|
|
@@ -77,6 +77,8 @@ const system = (cwd: string, preamble = "") =>
|
|
|
77
77
|
/** Hub-native agent loop: the hub owns every model call, so it can choose the model per call (L2) and the gateway (L3). */
|
|
78
78
|
export class LocalPeer extends BasePeer {
|
|
79
79
|
readonly hubNative = true;
|
|
80
|
+
readonly deliveryOnly: boolean = true;
|
|
81
|
+
readonly hubOwned: boolean = true;
|
|
80
82
|
private readonly history: ChatMessage[] = [];
|
|
81
83
|
private readonly sessionId = `agent-hub-local-${randomUUID()}`;
|
|
82
84
|
private readonly routes: HubRouteRuntime;
|
package/src/adapters/pi.ts
CHANGED
|
@@ -2,15 +2,17 @@ import { buildPiLaunch } from "../pi/launch.ts";
|
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
3
|
import { existsSync, mkdirSync } from "node:fs";
|
|
4
4
|
import { join, resolve } from "node:path";
|
|
5
|
-
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
|
5
|
+
import { spawn, type ChildProcess, type ChildProcessWithoutNullStreams } from "node:child_process";
|
|
6
6
|
import { renderDigest, replyAudience, replyParent, type Envelope, type PeerId } from "../hub/envelope.ts";
|
|
7
7
|
import { BasePeer } from "../hub/peers.ts";
|
|
8
|
-
import { stopOwnedProcess, trackGroup } from "../hub/child-process.ts";
|
|
8
|
+
import { peerChildEnv, stopOwnedProcess, trackGroup } from "../hub/child-process.ts";
|
|
9
|
+
import { assessVersion, COMPAT, type CompatVerdict } from "./compat.ts";
|
|
9
10
|
import { processLiveness, processSignature } from "../pi/process-signature.ts";
|
|
10
11
|
import { PiSessionBookmarkMismatch, validatePiSession } from "../pi/session.ts";
|
|
11
12
|
import { ApprovalWaitStop, toolResultFailed, type ApprovalProvenance } from "../local/tools.ts";
|
|
12
13
|
import { piToolStepCeiling, type PiToolStepCeiling } from "../pi/ceiling.ts";
|
|
13
14
|
import type { ExecutionBudgetDecision, ExecutionUnit } from "../hub/execution-budget.ts";
|
|
15
|
+
import type { StartForm } from "./registry-types.ts";
|
|
14
16
|
|
|
15
17
|
export interface PiModelDescriptor { id: string; name?: string; contextWindow?: number; maxTokens?: number; reasoning?: boolean; }
|
|
16
18
|
export interface PiToolSchema { name: string; description?: string; parameters: Record<string, unknown>; }
|
|
@@ -56,6 +58,39 @@ export interface PiOptions {
|
|
|
56
58
|
signature?: typeof processSignature;
|
|
57
59
|
}
|
|
58
60
|
export interface PiTuiLaunch { cmd: string; args: string[]; env: NodeJS.ProcessEnv; }
|
|
61
|
+
|
|
62
|
+
/** Only the host's parsed version verdict may cross the generic startup-error boundary. */
|
|
63
|
+
export class PiVersionRefusal extends Error {
|
|
64
|
+
constructor(verdict: Extract<CompatVerdict, { ok: false }>) { super(verdict.reason); }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Bounded, non-blocking probe of the exact configured native command, including wrapper arguments. */
|
|
68
|
+
export function piVersionAsync(command: readonly string[], cwd: string, timeoutMs = 10_000): Promise<CompatVerdict> {
|
|
69
|
+
const [bin, ...prefix] = command;
|
|
70
|
+
if (!bin) return Promise.resolve(assessVersion("pi", undefined));
|
|
71
|
+
return new Promise(resolve => {
|
|
72
|
+
let proc: ChildProcess;
|
|
73
|
+
try { proc = spawn(bin, [...prefix, "--version"], { cwd, env: peerChildEnv("pi"), detached: true, stdio: ["ignore", "pipe", "ignore"] }); }
|
|
74
|
+
catch { resolve(assessVersion("pi", undefined)); return; }
|
|
75
|
+
let output = "", bytes = 0, done = false;
|
|
76
|
+
const finish = async (code: number | null) => {
|
|
77
|
+
if (done) return; done = true; clearTimeout(timer);
|
|
78
|
+
try { await stopOwnedProcess(proc, { group: true }); }
|
|
79
|
+
catch { resolve(assessVersion("pi", undefined)); return; }
|
|
80
|
+
resolve(assessVersion("pi", code === 0 && bytes <= 64 * 1024 ? COMPAT.pi.parse(output) : undefined));
|
|
81
|
+
};
|
|
82
|
+
const timer = setTimeout(() => { void finish(null); }, timeoutMs);
|
|
83
|
+
proc.once("spawn", () => trackGroup(proc));
|
|
84
|
+
proc.stdout!.on("data", (chunk: Buffer) => {
|
|
85
|
+
if (done) return;
|
|
86
|
+
bytes += chunk.length;
|
|
87
|
+
if (bytes > 64 * 1024) { void finish(null); return; }
|
|
88
|
+
output += chunk.toString("utf8");
|
|
89
|
+
});
|
|
90
|
+
proc.once("error", () => { void finish(null); });
|
|
91
|
+
proc.once("close", code => { void finish(code); });
|
|
92
|
+
});
|
|
93
|
+
}
|
|
59
94
|
type RpcMessage = { type?: string; id?: string | number; command?: string; success?: boolean; data?: any; [key: string]: any };
|
|
60
95
|
type VerifiedEmptyResume = { sessionId: string };
|
|
61
96
|
type TerminalOwnerObservation = { pid: number; signature: string; sessionId: string; sessionFile: string; ownerToken: string; checkedAt: number };
|
|
@@ -65,6 +100,7 @@ function ownerStillAlive(pid: number, signature: string | undefined): boolean {
|
|
|
65
100
|
|
|
66
101
|
export class PiPeer extends BasePeer {
|
|
67
102
|
readonly hubNative = true;
|
|
103
|
+
readonly hubOwned: boolean = true;
|
|
68
104
|
private proc?: ChildProcessWithoutNullStreams;
|
|
69
105
|
private server?: ReturnType<typeof Bun.serve>;
|
|
70
106
|
private buffer = "";
|
|
@@ -132,7 +168,8 @@ export class PiPeer extends BasePeer {
|
|
|
132
168
|
private tuiQueue: Record<string, unknown>[] = [];
|
|
133
169
|
private tuiWaiters: Array<{ resolve: (command: Record<string, unknown> | undefined) => void; timer: ReturnType<typeof setTimeout> }> = [];
|
|
134
170
|
|
|
135
|
-
|
|
171
|
+
/** The fixed production factory supplies preflight separately, preserving transport option identity. */
|
|
172
|
+
constructor(id: PeerId, private readonly opts: PiOptions, private readonly preflight?: () => Promise<void>) { super(id, opts.watchdogMs); }
|
|
136
173
|
get tuiLaunch(): PiTuiLaunch | undefined { return this._tuiLaunch; }
|
|
137
174
|
get pendingResume(): { sessionId?: string; sessionFile?: string } {
|
|
138
175
|
if (this.canReuseVerifiedEmptyResume()) return { sessionId: this.verifiedEmptyResume!.sessionId };
|
|
@@ -167,6 +204,10 @@ export class PiPeer extends BasePeer {
|
|
|
167
204
|
const file = this.sessionFile && (exists || !empty) ? this.sessionFile : undefined;
|
|
168
205
|
return { launch: { kind: "pi", cwd: this.opts.cwd, mode: this.opts.mode, backend: this.opts.backend, ...(this.opts.model ? { model: this.opts.model } : {}), ...(file ? { sessionFile: file } : {}) }, ...(this.sessionId ? { sessionId: this.sessionId } : {}), ...(file ? { sessionFile: file } : {}) };
|
|
169
206
|
}
|
|
207
|
+
get nativeSession(): string | undefined {
|
|
208
|
+
const session = this.recoveryMetadata().sessionId;
|
|
209
|
+
return typeof session === "string" ? session : undefined;
|
|
210
|
+
}
|
|
170
211
|
|
|
171
212
|
/** Verify the live source before choosing ID-only restoration. Never infer emptiness from a missing file. */
|
|
172
213
|
async captureResume(): Promise<Record<string, unknown>> {
|
|
@@ -344,6 +385,7 @@ export class PiPeer extends BasePeer {
|
|
|
344
385
|
}
|
|
345
386
|
|
|
346
387
|
private async startImpl(): Promise<void> {
|
|
388
|
+
if (this.preflight) await this.preflight();
|
|
347
389
|
this.terminalOwnerObservation = undefined;
|
|
348
390
|
this.stopping = false;
|
|
349
391
|
this.rosterDetached = false; this.rosterUnownedRun = false;
|
|
@@ -466,6 +508,7 @@ export class PiPeer extends BasePeer {
|
|
|
466
508
|
return pending.map((call) => call.settled);
|
|
467
509
|
}
|
|
468
510
|
get mode(): "headless" | "tui" { return this.opts.mode; }
|
|
511
|
+
get form(): StartForm { return this.opts.mode; }
|
|
469
512
|
|
|
470
513
|
/** Withdraw hub admission without shutting down a person's Pi TUI or its bridge. */
|
|
471
514
|
async detachForRoster(): Promise<void> {
|
|
@@ -82,6 +82,17 @@ export function qwenDualNamesStateDir(input: unknown, stateDir: string): boolean
|
|
|
82
82
|
return seen(input, 0);
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
/**
|
|
86
|
+
* #517: Qwen tells the model that one of its background shells finished with a `user` event of its own, which is neither the hub's submit nor the person typing. Qwen may fold
|
|
87
|
+
* several finished shells into one event, each its own envelope, separated by one blank line (8 of the 48 in the live event files do). The shape is exact: the text is wholly
|
|
88
|
+
* one or more complete envelopes and nothing else, so a person's text that merely contains or resembles one is still a person's prompt. Nothing inside an envelope is read
|
|
89
|
+
* beyond its header lines, so nothing in it can steer the adapter.
|
|
90
|
+
*/
|
|
91
|
+
const NOTIFICATION = String.raw`<task-notification>\n<task-id>bg_[0-9a-f]{4,32}<\/task-id>\n<kind>[a-z_-]{1,32}<\/kind>\n<status>[a-z_-]{1,32}<\/status>\n(?:(?!<task-notification>|<\/task-notification>)[\s\S])*<\/task-notification>`;
|
|
92
|
+
const QWEN_TASK_NOTIFICATIONS = new RegExp(`^${NOTIFICATION}(?:\\n\\n${NOTIFICATION})*$`);
|
|
93
|
+
const NOTIFICATION_CAP = 64 * 1024;
|
|
94
|
+
export function isQwenTaskNotification(text: string): boolean { return text.length <= NOTIFICATION_CAP && QWEN_TASK_NOTIFICATIONS.test(text); }
|
|
95
|
+
|
|
85
96
|
/** `qwen --version` against the dual-output compat entry. Never throws: a refusal is a reason. */
|
|
86
97
|
export async function qwenDualVersionAsync(cmd: readonly string[], cwd: string): Promise<{ ok: true; version: string; note?: string } | { ok: false; reason: string }> {
|
|
87
98
|
const [bin, ...prefix] = cmd.filter(word => word !== "--acp");
|
|
@@ -416,6 +427,17 @@ export class QwenDualPeer extends BasePeer {
|
|
|
416
427
|
if (!this.stopping && !this.detached && this.state === "offline") this.setState("idle");
|
|
417
428
|
}
|
|
418
429
|
|
|
430
|
+
/**
|
|
431
|
+
* Judged on the raw event, not on `promptText`, which joins every text block and drops the rest: an event is Qwen's own notice only when its content is exactly one text block
|
|
432
|
+
* and that block is wholly the strict envelope. A text split across blocks, or a block with anything else beside it (an image, a second text), is a person's prompt.
|
|
433
|
+
*/
|
|
434
|
+
private isNotificationEvent(event: Json): boolean {
|
|
435
|
+
const content = event.message?.content;
|
|
436
|
+
if (!Array.isArray(content) || content.length !== 1) return false;
|
|
437
|
+
const block = content[0];
|
|
438
|
+
return !!block && typeof block === "object" && block.type === "text" && typeof block.text === "string" && isQwenTaskNotification(block.text);
|
|
439
|
+
}
|
|
440
|
+
|
|
419
441
|
private promptText(event: Json): string | undefined {
|
|
420
442
|
const content = event.message?.content;
|
|
421
443
|
if (typeof content === "string") return content;
|
|
@@ -425,6 +447,9 @@ export class QwenDualPeer extends BasePeer {
|
|
|
425
447
|
}
|
|
426
448
|
|
|
427
449
|
private onUser(event: Json): void {
|
|
450
|
+
// Qwen's own background-shell notice (#517) is part of whatever turn is running and never the person typing, so it ends no hub turn and holds no queue. It changes nothing else:
|
|
451
|
+
// an idle peer stays idle (Qwen may answer it or not, and a peer that waited for an answer that never comes would block its queue), and a running turn keeps its owner.
|
|
452
|
+
if (this.isNotificationEvent(event)) return;
|
|
428
453
|
const text = this.promptText(event);
|
|
429
454
|
if (text === undefined) return; // a tool result echoed back, not a prompt
|
|
430
455
|
const turn = this.turn;
|
|
@@ -210,7 +210,9 @@ export interface AdapterDefinition {
|
|
|
210
210
|
/** Declaration admission only; execution-root and runtime owner proof remain host responsibilities. */
|
|
211
211
|
instances?: { mode: "external" | "headless"; roles?: readonly string[];
|
|
212
212
|
/** Each declared instance runs its own native data home (`jcode_home`) and may pass its own provider, model and profile names (#685). Fixed builtins only. */
|
|
213
|
-
nativeHome?: true
|
|
213
|
+
nativeHome?: true;
|
|
214
|
+
/** A declared headless instance of this definition may pass one validated `model` word to its native child (#844). Fixed builtins only. */
|
|
215
|
+
modelSelect?: true };
|
|
214
216
|
attach?: {
|
|
215
217
|
/** The authenticated external hello may claim this id; independent of MCP configuration shape. */
|
|
216
218
|
externalHello?: boolean;
|
|
@@ -309,7 +311,9 @@ export interface AdapterDefinition {
|
|
|
309
311
|
/** #525: fixed builtin variant for declared headless implementer instances. */
|
|
310
312
|
headlessVariant?: AdapterDefinition;
|
|
311
313
|
/** Fixed host backend; runtime isolation proof is required before bus admission. */
|
|
312
|
-
reviewer?: { backend?: ReviewerBackend; defaultPool: boolean; unavailable?: string; declaration: "automatic" | "explicit"
|
|
314
|
+
reviewer?: { backend?: ReviewerBackend; defaultPool: boolean; unavailable?: string; declaration: "automatic" | "explicit";
|
|
315
|
+
/** #852: what a reviewer of this kind reads. `code`: the code only, its sandbox cannot run tests or read CI. Unset means `full`. Fixed builtins only. */
|
|
316
|
+
scope?: "full" | "code" };
|
|
313
317
|
}
|
|
314
318
|
|
|
315
319
|
/** Explicit project declarations, passed by the bound host. No process-global instance registration. */
|
package/src/adapters/registry.ts
CHANGED
|
@@ -259,6 +259,7 @@ export function assertNativeCapabilityScope(definition: AdapterDefinition): void
|
|
|
259
259
|
["cli.entry", declared(definition.cli, "entry")],
|
|
260
260
|
["cli.preview", declared(definition.cli, "preview")],
|
|
261
261
|
["cli.usage", declared(definition.cli, "usage")],
|
|
262
|
+
["instances.modelSelect", declared(definition.instances, "modelSelect")], // #844: a fixed-builtin assumption, one validated model word per declared headless instance
|
|
262
263
|
];
|
|
263
264
|
const field = restricted.find(([, present]) => present)?.[0];
|
|
264
265
|
if (field) throw new Error(`${field} is restricted to fixed builtin adapter definitions`);
|
|
@@ -156,6 +156,7 @@ const reviewTask = (env: Envelope): number | undefined => {
|
|
|
156
156
|
|
|
157
157
|
export class ReviewerPrintPeer extends BasePeer {
|
|
158
158
|
readonly hubNative = true;
|
|
159
|
+
readonly hubOwned: boolean = true;
|
|
159
160
|
private generation = 0;
|
|
160
161
|
private child: OwnedPrintChild | undefined;
|
|
161
162
|
private childCleanup: (() => Promise<void>) | undefined;
|