@staix/agent-hub 0.12.44 → 0.12.45
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 +22 -0
- package/README.md +3 -3
- package/THIRD_PARTY_NOTICES.md +29 -0
- package/docs/adding-an-ai-tool.md +102 -14
- package/docs/agent-notes/README.md +9 -0
- package/docs/agent-notes/adapters.md +58 -44
- package/docs/agent-notes/budget.md +1 -1
- package/docs/agent-notes/daemon.md +9 -7
- package/docs/agent-notes/local-worker.md +4 -3
- package/docs/agent-notes/tasks.md +8 -5
- package/docs/agent-notes/tests.md +7 -7
- package/docs/operations.md +99 -7
- package/docs/quickstart.md +5 -1
- package/docs/sdlc.md +26 -8
- package/docs/security.md +6 -3
- package/docs/specs/2026-09-19-agent-hub-design.md +114 -5
- package/docs/verification/2026-10-21-469-r1-dashboard-tokens.md +43 -0
- package/docs/verification/2026-10-21-469-r2-dashboard-shell.md +54 -0
- package/docs/verification/2026-10-21-469-r3-dashboard-board.md +51 -0
- package/docs/verification/2026-10-21-469-r4-dashboard-views.md +77 -0
- package/docs/verification/2026-10-21-469-r6-dashboard-motion-states.md +82 -0
- package/docs/verification/2026-10-22-0.12.45.md +60 -0
- package/docs/verification/assets/469-r1-board-1440-dark.png +0 -0
- package/docs/verification/assets/469-r1-board-1440-light.png +0 -0
- package/docs/verification/assets/469-r1-board-390-dark.png +0 -0
- package/docs/verification/assets/469-r1-board-390-light.png +0 -0
- package/docs/verification/assets/469-r2-agents-1440-dark.png +0 -0
- package/docs/verification/assets/469-r2-agents-768-dark.png +0 -0
- package/docs/verification/assets/469-r2-board-1024-light.png +0 -0
- package/docs/verification/assets/469-r2-board-1440-dark.png +0 -0
- package/docs/verification/assets/469-r2-board-1440-light.png +0 -0
- package/docs/verification/assets/469-r2-board-390-dark.png +0 -0
- package/docs/verification/assets/469-r2-board-390-light.png +0 -0
- package/docs/verification/assets/469-r2-board-768-light.png +0 -0
- package/docs/verification/assets/469-r3-board-1440-dark.png +0 -0
- package/docs/verification/assets/469-r3-board-1440-light.png +0 -0
- package/docs/verification/assets/469-r3-board-390-light.png +0 -0
- package/docs/verification/assets/469-r3-detail-1440-dark.png +0 -0
- package/docs/verification/assets/469-r3-detail-1440-light.png +0 -0
- package/docs/verification/assets/469-r3-detail-390-dark.png +0 -0
- package/docs/verification/assets/469-r3-plan-1440-light.png +0 -0
- package/docs/verification/assets/469-r3-plan-390-light.png +0 -0
- package/docs/verification/assets/469-r4-agents-1440-dark.png +0 -0
- package/docs/verification/assets/469-r4-agents-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-agents-390-dark.png +0 -0
- package/docs/verification/assets/469-r4-memory-1440-dark.png +0 -0
- package/docs/verification/assets/469-r4-memory-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-memory-390-dark.png +0 -0
- package/docs/verification/assets/469-r4-message-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-office-1440-dark.png +0 -0
- package/docs/verification/assets/469-r4-office-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-settings-1440-dark.png +0 -0
- package/docs/verification/assets/469-r4-settings-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-stream-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-stream-390-light.png +0 -0
- package/docs/verification/assets/469-r6-agents-empty-390-dark.png +0 -0
- package/docs/verification/assets/469-r6-agents-empty-390-light.png +0 -0
- package/docs/verification/assets/469-r6-board-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-board-390-dark.png +0 -0
- package/docs/verification/assets/469-r6-board-empty-1440-dark.png +0 -0
- package/docs/verification/assets/469-r6-board-empty-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-disconnected-1440-dark.png +0 -0
- package/docs/verification/assets/469-r6-disconnected-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-disconnected-390-dark.png +0 -0
- package/docs/verification/assets/469-r6-skeleton-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-skeleton-390-dark.png +0 -0
- package/docs/verification/assets/469-r6-toasts-1440-dark.png +0 -0
- package/docs/verification/assets/469-r6-toasts-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-toasts-390-light.png +0 -0
- package/docs/verified.json +19 -16
- package/package.json +5 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +9818 -9605
- package/src/adapters/adapter-api.ts +2 -0
- package/src/adapters/agy/definition.ts +3 -1
- package/src/adapters/claude-channel.ts +14 -5
- package/src/adapters/codex-appserver.ts +1 -0
- package/src/adapters/conformance/acp.ts +114 -0
- package/src/adapters/conformance/codex.ts +191 -0
- package/src/adapters/conformance/enrollment.ts +43 -0
- package/src/adapters/conformance/factories.ts +17 -0
- package/src/adapters/conformance/fake-paths.ts +12 -0
- package/src/adapters/conformance/fakes/acp-server.ts +267 -0
- package/src/adapters/conformance/fakes/acp-watchdog-agent.ts +10 -0
- package/src/adapters/conformance/fakes/app-server.ts +119 -0
- package/src/adapters/conformance/fakes/codex-bin.ts +15 -0
- package/src/adapters/conformance/fakes/jcode-agent.ts +91 -0
- package/src/adapters/conformance/fakes/model-server.ts +39 -0
- package/src/adapters/conformance/fakes/pi-rpc.ts +32 -0
- package/src/adapters/conformance/index.ts +11 -0
- package/src/adapters/conformance/kit.ts +50 -0
- package/src/adapters/conformance/local-pi.ts +100 -0
- package/src/adapters/conformance/owned-acp.ts +44 -0
- package/src/adapters/definitions/builtin-metadata.ts +23 -6
- package/src/adapters/definitions/native-cli-entry-kimi.ts +7 -1
- package/src/adapters/definitions/native-cli-entry.ts +7 -19
- package/src/adapters/definitions/native-cli-preview.ts +2 -11
- package/src/adapters/definitions/qwen.ts +4 -1
- package/src/adapters/external-metadata.ts +48 -0
- package/src/adapters/external-module-scan.ts +126 -0
- package/src/adapters/external-modules.ts +240 -0
- package/src/adapters/pi.ts +2 -0
- package/src/adapters/public.ts +7 -0
- package/src/adapters/registry-types.ts +10 -0
- package/src/adapters/registry.ts +51 -15
- package/src/cli/help.ts +21 -16
- package/src/cli/identity.ts +1 -1
- package/src/cli/main.ts +23 -5
- package/src/cli/upgrade-labels.ts +1 -1
- package/src/cli/upgrade-presentation.ts +1 -1
- package/src/cli/upgrade-restore-state.ts +3 -2
- package/src/cli/upgrade-runtime.ts +4 -1
- package/src/hub/board.ts +30 -4
- package/src/hub/config-apply.ts +18 -10
- package/src/hub/config-reload.ts +8 -5
- package/src/hub/config-trust.ts +1 -1
- package/src/hub/control-client.ts +4 -1
- package/src/hub/daemon.ts +95 -39
- package/src/hub/facts.ts +76 -13
- package/src/hub/hub-tools.ts +21 -5
- package/src/hub/instance-config.ts +17 -5
- package/src/hub/instance-execution.ts +20 -8
- package/src/hub/instances.ts +2 -1
- package/src/hub/peers.ts +9 -0
- package/src/hub/plan-conformance.ts +83 -0
- package/src/hub/review-findings.ts +104 -0
- package/src/hub/roster.ts +31 -5
- package/src/hub/sdlc-queue.ts +19 -4
- package/src/hub/sdlc-release.ts +16 -1
- package/src/hub/sdlc.ts +27 -12
- package/src/hub/settings.ts +5 -1
- package/src/hub/snapshots.ts +8 -6
- package/src/hub/tasks.ts +275 -45
- package/src/hub/ui.ts +2 -2
- package/src/ui/index.html +559 -78
- package/src/ui/office-model.ts +3 -1
- package/src/ui/peer-palette.ts +44 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import type { Envelope } from "../../hub/envelope.ts";
|
|
3
|
+
import { newEnvelope } from "../../hub/envelope.ts";
|
|
4
|
+
import type { PeerAdapter } from "../../hub/peers.ts";
|
|
5
|
+
|
|
6
|
+
export async function until(condition: () => boolean, label: string): Promise<void> {
|
|
7
|
+
const deadline = Date.now() + 5000;
|
|
8
|
+
while (!condition() && Date.now() < deadline) await Bun.sleep(10);
|
|
9
|
+
expect(condition(), label).toBe(true);
|
|
10
|
+
}
|
|
11
|
+
export interface ConformanceFixture {
|
|
12
|
+
peer: PeerAdapter;
|
|
13
|
+
messages: string[];
|
|
14
|
+
/** Claude's native hooks own busy; channel insertion itself has no native acceptance evidence. */
|
|
15
|
+
busyOnDeliver: boolean;
|
|
16
|
+
settle?(): Promise<void>;
|
|
17
|
+
verifyTransport(): void | Promise<void>;
|
|
18
|
+
close(): Promise<void>;
|
|
19
|
+
}
|
|
20
|
+
export interface AdapterConformance {
|
|
21
|
+
kind: string;
|
|
22
|
+
create(): Promise<ConformanceFixture>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Shared observable contract; every invocation uses the real adapter and its fake native transport. */
|
|
26
|
+
export function registerConformance(adapter: AdapterConformance): void {
|
|
27
|
+
test(`P4 ${adapter.kind}: transport delivery, synchronous occupancy and owned teardown`, async () => {
|
|
28
|
+
const f = await adapter.create();
|
|
29
|
+
try {
|
|
30
|
+
expect(f.peer.id).toBe(adapter.kind);
|
|
31
|
+
expect(f.peer.state).toBe("idle");
|
|
32
|
+
const envelope: Envelope = newEnvelope("user", "conformance ping", { to: [adapter.kind] });
|
|
33
|
+
const delivering = f.peer.deliver([envelope]);
|
|
34
|
+
// Attach the rejection observer before teardown, even if an assertion fails while delivery is pending.
|
|
35
|
+
const outcome = delivering.then(() => undefined, error => error as Error);
|
|
36
|
+
expect(f.peer.state).toBe(f.busyOnDeliver ? "busy" : "idle");
|
|
37
|
+
expect(await outcome).toBeUndefined();
|
|
38
|
+
await f.settle?.();
|
|
39
|
+
await until(() => f.messages.length === 1 && f.peer.state === "idle", `${adapter.kind} one settled reply`);
|
|
40
|
+
expect(f.messages[0]).not.toBeEmpty();
|
|
41
|
+
await f.verifyTransport();
|
|
42
|
+
const metadata = f.peer.recoveryMetadata?.();
|
|
43
|
+
expect(JSON.stringify(metadata ?? {})).not.toContain("conformance ping");
|
|
44
|
+
} finally { await f.close(); }
|
|
45
|
+
await until(() => f.peer.state === "offline", `${adapter.kind} stop observed offline`);
|
|
46
|
+
}, 20_000);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Host-owned authenticated channel fixtures share the observable delivery contract, not a driven native factory. */
|
|
50
|
+
export function registerExternalChannelConformance(adapter: AdapterConformance): void { registerConformance(adapter); }
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import type { LocalPeer } from "../local-worker.ts";
|
|
6
|
+
import type { PiPeer } from "../pi.ts";
|
|
7
|
+
import { OmniRoute } from "../../omniroute/client.ts";
|
|
8
|
+
import { newEnvelope } from "../../hub/envelope.ts";
|
|
9
|
+
import { startFakeModelServer, toolCall } from "./fakes/model-server.ts";
|
|
10
|
+
import { until } from "./kit.ts";
|
|
11
|
+
import { fakePaths } from "./fake-paths.ts";
|
|
12
|
+
import type { AdapterDefinition } from "../registry-types.ts";
|
|
13
|
+
import { createFromDefinition, expire } from "./factories.ts";
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
export function registerLocalConformance(definition: AdapterDefinition, { label = definition.displayName }: { label?: string } = {}): void {
|
|
17
|
+
if (definition.start.daemonPort !== "local-worker") throw new Error(definition.kind + ": conformance requires local-worker transport factory");
|
|
18
|
+
test(`P4 ${label}: a consumed model result from an expired generation cannot publish into the next turn`, async () => {
|
|
19
|
+
const dir = mkdtempSync(join(tmpdir(), "conformance-local-late-"));
|
|
20
|
+
let release = () => {}, observed = false, parsed = () => {};
|
|
21
|
+
const parsedLate = new Promise<void>(resolve => { parsed = resolve; });
|
|
22
|
+
const server = startFakeModelServer({ key: "conformance-key", script: async body => {
|
|
23
|
+
const text = String(body.messages.at(-1)?.content);
|
|
24
|
+
if (text.includes("OLD_GENERATION")) await new Promise<void>(resolve => { release = resolve; observed = true; });
|
|
25
|
+
return { content: text.includes("OLD_GENERATION") ? "stale answer" : "current answer" };
|
|
26
|
+
} });
|
|
27
|
+
const oldUrl = process.env.AGENTHUB_OMNIROUTE_URL, oldKey = process.env.OMNIROUTE_API_KEY;
|
|
28
|
+
process.env.AGENTHUB_OMNIROUTE_URL = server.url; process.env.OMNIROUTE_API_KEY = "conformance-key";
|
|
29
|
+
const omni = new OmniRoute({ urls: [server.url], access_hosts: [] }), chat = omni.chat.bind(omni);
|
|
30
|
+
omni.chat = async (body, options = {}) => {
|
|
31
|
+
const old = String(body.messages.at(-1)?.content).includes("OLD_GENERATION");
|
|
32
|
+
const result = await chat(body, old ? { ...options, signal: undefined } : options);
|
|
33
|
+
if (old) parsed(); return result;
|
|
34
|
+
};
|
|
35
|
+
const messages: string[] = [];
|
|
36
|
+
const peer = createFromDefinition<LocalPeer>(definition, { local: { cwd: dir, omni, fixedModel: "vllm/fixed", tools: { deny: [], permit: async () => false } } });
|
|
37
|
+
peer.onMessage = text => { messages.push(text); };
|
|
38
|
+
let oldTurn: Promise<void> | undefined;
|
|
39
|
+
try {
|
|
40
|
+
await peer.start(); oldTurn = peer.deliver([newEnvelope("user", "OLD_GENERATION")]); oldTurn.catch(() => {});
|
|
41
|
+
await until(() => observed, "old gateway call held"); expire(peer);
|
|
42
|
+
expect(peer.state).toBe("idle");
|
|
43
|
+
await peer.deliver([newEnvelope("user", "CURRENT_GENERATION")]);
|
|
44
|
+
await until(() => messages.length === 1, "new generation publishes");
|
|
45
|
+
release(); await parsedLate; await oldTurn; await new Promise<void>(resolve => setImmediate(resolve));
|
|
46
|
+
expect(messages).toEqual(["current answer"]); expect(peer.state).toBe("idle");
|
|
47
|
+
} finally { release(); await peer.stop(); await oldTurn?.catch(() => {}); server.stop();
|
|
48
|
+
if (oldUrl === undefined) delete process.env.AGENTHUB_OMNIROUTE_URL; else process.env.AGENTHUB_OMNIROUTE_URL = oldUrl;
|
|
49
|
+
if (oldKey === undefined) delete process.env.OMNIROUTE_API_KEY; else process.env.OMNIROUTE_API_KEY = oldKey;
|
|
50
|
+
rmSync(dir, { recursive: true, force: true }); }
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test(`P4 ${label}: tool approval refusal prevents the write and hub tools use the host callback`, async () => {
|
|
54
|
+
const dir = mkdtempSync(join(tmpdir(), "conformance-local-approval-")), path = join(dir, "a.txt"); writeFileSync(path, "before");
|
|
55
|
+
const asked: string[] = [], called: string[] = [];
|
|
56
|
+
const server = startFakeModelServer({ key: "conformance-key", script: body => {
|
|
57
|
+
const tools = body.messages.filter(message => message.role === "tool").length;
|
|
58
|
+
return tools === 0 ? { tool_calls: [toolCall("write", { path: "a.txt", content: "after" })] }
|
|
59
|
+
: tools === 1 ? { tool_calls: [toolCall("hub_task_list", {})] } : { content: "done" };
|
|
60
|
+
} });
|
|
61
|
+
const oldUrl = process.env.AGENTHUB_OMNIROUTE_URL, oldKey = process.env.OMNIROUTE_API_KEY;
|
|
62
|
+
process.env.AGENTHUB_OMNIROUTE_URL = server.url; process.env.OMNIROUTE_API_KEY = "conformance-key";
|
|
63
|
+
const peer = createFromDefinition<LocalPeer>(definition, { local: { cwd: dir, omni: new OmniRoute({ urls: [server.url], access_hosts: [] }), fixedModel: "vllm/fixed",
|
|
64
|
+
tools: { deny: [], permit: async title => { asked.push(title); return false; } }, taskTool: async name => { called.push(name); return "[]"; } } });
|
|
65
|
+
try {
|
|
66
|
+
await peer.start(); await peer.deliver([newEnvelope("user", "edit and list tasks")]);
|
|
67
|
+
await until(() => peer.state === "idle", "local tools settled");
|
|
68
|
+
expect(readFileSync(path, "utf8")).toBe("before"); expect(asked).toHaveLength(1); expect(called).toEqual(["hub_task_list"]);
|
|
69
|
+
} finally { await peer.stop(); server.stop(); if (oldUrl === undefined) delete process.env.AGENTHUB_OMNIROUTE_URL; else process.env.AGENTHUB_OMNIROUTE_URL = oldUrl;
|
|
70
|
+
if (oldKey === undefined) delete process.env.OMNIROUTE_API_KEY; else process.env.OMNIROUTE_API_KEY = oldKey;
|
|
71
|
+
rmSync(dir, { recursive: true, force: true }); }
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function registerPiConformance(definition: AdapterDefinition, { label = definition.displayName }: { label?: string } = {}): void {
|
|
77
|
+
if (definition.start.daemonPort !== "pi-rpc") throw new Error(definition.kind + ": conformance requires pi-rpc transport factory");
|
|
78
|
+
test(`P4 ${label}: recovery resumes its exact persisted session and rejects a stale bridge generation`, async () => {
|
|
79
|
+
const dir = mkdtempSync(join(tmpdir(), "conformance-pi-resume-")), stateDir = join(dir, "state"), sessions = join(stateDir, "pi-sessions");
|
|
80
|
+
mkdirSync(sessions, { recursive: true });
|
|
81
|
+
const sessionFile = join(sessions, "saved-session.jsonl"); writeFileSync(sessionFile, JSON.stringify({ type: "session", id: "saved-session", cwd: dir }) + "\n");
|
|
82
|
+
const peer = createFromDefinition<PiPeer>(definition, { pi: { cwd: dir, stateDir, sessionFile, mode: "headless", backend: "dgx", cmd: [process.execPath, fakePaths.piRpc],
|
|
83
|
+
relay: { url: "http://127.0.0.1:9/v1", token: "fixture", models: [{ id: "dgx/coding" }] }, tools: [], executeTool: async () => "fixture" } });
|
|
84
|
+
const messages: string[] = []; peer.onMessage = text => { messages.push(text); };
|
|
85
|
+
try {
|
|
86
|
+
await peer.start(); expect(peer.recoveryMetadata()).toMatchObject({ sessionId: "saved-session", sessionFile });
|
|
87
|
+
const launch = peer.tuiLaunch!;
|
|
88
|
+
const event = async (body: unknown) => fetch(`${launch.env.AGENTHUB_PI_BRIDGE_URL}/event`, { method: "POST", headers: { authorization: `Bearer ${launch.env.AGENTHUB_PI_BRIDGE_TOKEN}`, "content-type": "application/json" }, body: JSON.stringify(body) });
|
|
89
|
+
await peer.deliver([newEnvelope("user", "resumed turn")]);
|
|
90
|
+
expect((await event({ type: "agent_start", generation: 3, sessionId: "saved-session" })).status).toBe(200);
|
|
91
|
+
await event({ type: "agent_end", generation: 2, text: "stale answer" });
|
|
92
|
+
await event({ type: "agent_settled", generation: 2 });
|
|
93
|
+
expect(peer.state).toBe("busy"); expect(messages).toEqual([]);
|
|
94
|
+
await event({ type: "agent_end", generation: 3, text: "current answer" });
|
|
95
|
+
await event({ type: "agent_settled", generation: 3 });
|
|
96
|
+
expect(peer.state).toBe("idle"); expect(messages).toEqual(["current answer"]);
|
|
97
|
+
} finally { await peer.stop(); rmSync(dir, { recursive: true, force: true }); }
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import type { JcodePeer } from "../jcode.ts";
|
|
6
|
+
import { newEnvelope } from "../../hub/envelope.ts";
|
|
7
|
+
import { fakePaths } from "./fake-paths.ts";
|
|
8
|
+
import type { AdapterDefinition } from "../registry-types.ts";
|
|
9
|
+
import { createFromDefinition, expire } from "./factories.ts";
|
|
10
|
+
import { until } from "./kit.ts";
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
/** The owned-ACP protocol flavor and its unmanaged mode ceiling are supplied explicitly. */
|
|
14
|
+
export function registerOwnedAcpConformance(definition: AdapterDefinition, { label = definition.displayName, unmanagedReason }: { label?: string; unmanagedReason?: string } = {}): void {
|
|
15
|
+
if (definition.start.daemonPort !== "owned-acp") throw new Error(definition.kind + ": conformance requires owned-acp transport factory");
|
|
16
|
+
if (!unmanagedReason?.trim()) throw new Error(definition.kind + ": owned-ACP unmanaged mode reason must be explicit");
|
|
17
|
+
test(`P4 ${label}: watchdog waits for the consumed late native result without publishing it into a successor`, async () => {
|
|
18
|
+
const dir = mkdtempSync(join(tmpdir(), "conformance-jcode-watchdog-")), home = join(dir, "home"), release = join(dir, "release");
|
|
19
|
+
mkdirSync(home); writeFileSync(join(home, "mcp.json"), "{}");
|
|
20
|
+
const messages: string[] = [], failed: unknown[] = [];
|
|
21
|
+
const peer = createFromDefinition<JcodePeer>(definition, { jcode: { cwd: dir, home,
|
|
22
|
+
command: [process.execPath, fakePaths.jcodeAgent, "--steer-file", release],
|
|
23
|
+
} });
|
|
24
|
+
peer.onMessage = text => { messages.push(text); }; peer.onFailed = envelopes => { failed.push(envelopes); };
|
|
25
|
+
try {
|
|
26
|
+
await peer.start();
|
|
27
|
+
expect(unmanagedReason).toContain("no verified native permission-mode mapping");
|
|
28
|
+
expect(peer.permissionModeState).toBe("unmanaged");
|
|
29
|
+
await expect(peer.setPermissionMode("ask")).rejects.toThrow("no verified mode mapping");
|
|
30
|
+
await peer.deliver([newEnvelope("user", "old Jcode turn")]);
|
|
31
|
+
await until(() => existsSync(`${release}.busy`), "native Jcode consumed the held prompt");
|
|
32
|
+
expire(peer);
|
|
33
|
+
expect(peer.state).toBe("busy"); expect(messages).toEqual([]);
|
|
34
|
+
writeFileSync(release, "release");
|
|
35
|
+
// Returning idle proves the old prompt settled, not merely that a cancel notification was written.
|
|
36
|
+
await until(() => peer.state === "idle", "Jcode late prompt result consumed after watchdog cancellation");
|
|
37
|
+
expect(messages).toEqual([]); expect(failed).toEqual([]);
|
|
38
|
+
await peer.deliver([newEnvelope("user", "successor Jcode turn")]);
|
|
39
|
+
await until(() => messages.length === 1 && peer.state === "idle", "only the successor Jcode answer publishes");
|
|
40
|
+
expect(messages).toEqual(["JCODE_REPLY"]);
|
|
41
|
+
} finally { writeFileSync(release, "release"); await peer.stop(); rmSync(dir, { recursive: true, force: true }); }
|
|
42
|
+
}, 20_000);
|
|
43
|
+
|
|
44
|
+
}
|
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
import { runClaudeEntry } from "./native-cli-entry-claude.ts";
|
|
2
|
+
import { runCodexEntry } from "./native-cli-entry-codex.ts";
|
|
3
|
+
import { runKimiEntry } from "./native-cli-entry-kimi.ts";
|
|
4
|
+
import { runPiEntry } from "./native-cli-entry-pi.ts";
|
|
5
|
+
import { runLocalEntry } from "./native-cli-entry-local.ts";
|
|
6
|
+
import { runJcodeEntry } from "./native-cli-entry-jcode.ts";
|
|
7
|
+
import { previewClaude } from "./native-cli-preview-claude.ts";
|
|
8
|
+
import { previewCodex } from "./native-cli-preview-codex.ts";
|
|
9
|
+
import { previewKimi } from "./native-cli-preview-kimi.ts";
|
|
10
|
+
import { previewPi } from "./native-cli-preview-pi.ts";
|
|
11
|
+
import { previewJcode } from "./native-cli-preview-jcode.ts";
|
|
1
12
|
import type { PermissionMode } from "../../hub/permission-mode.ts";
|
|
2
13
|
import { claudeReviewerBackend, CODEX_REVIEWER_VARIANT, KIMI_REVIEWER_VARIANT } from "./reviewer-variants.ts";
|
|
3
14
|
import { kimiAcpCommand } from "./native-daemon-kimi.ts";
|
|
@@ -73,6 +84,7 @@ const CLAUDE_REVIEWER_VARIANT: AdapterDefinition = {
|
|
|
73
84
|
// daemon.ts runtime mappings/stop policy, budget.ts exemptions, and ui/peer-palette.ts CSS foregrounds.
|
|
74
85
|
export const NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly AdapterDefinition[] = Object.freeze([
|
|
75
86
|
{
|
|
87
|
+
projection: { roleRank: 0, helpRank: 0, plannerEligible: true },
|
|
76
88
|
api: 1, kind: "claude", displayName: "Claude Code", family: "claude", transport: "claude-channel",
|
|
77
89
|
roles: Object.freeze(["planner", "reviewer"]), defaultOnRoster: true, trust: { onPrem: false, hubSandboxed: false },
|
|
78
90
|
start: { forms: Object.freeze(["tui"] as const), defaultForm: "tui", launch: claudeNativeLaunch, version: claudeObservedVersion, ...CLAUDE_LAUNCH_POLICY, conductorStartable: true,
|
|
@@ -84,11 +96,12 @@ export const NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly AdapterDefinition
|
|
|
84
96
|
"never-ask": "every tool runs without asking" } },
|
|
85
97
|
session: { idField: "sessionId", currentId: sessionId("sessionId"), configDirEnv: "CLAUDE_CONFIG_DIR", zeroTurnFresh: true, launchKind: "claude-channel", launchRecord: true, ...CLAUDE_RECOVERY_SESSION, crashPlan: claudeCrashPlan, unrecordedTerminal: "always" },
|
|
86
98
|
turnEnd: "native-hook", budget: { account: "claude", checkpoint: true, reader: { source: "claude status line", read: claudeWindows }, context: { source: "claude_statusline", read: claudeContext } },
|
|
87
|
-
cli: { command: "claude", retainedTerminalNotice: claudeRetainedTerminalNotice, stopNotice: () => "this peer belongs to a native terminal; end it in its terminal", startWords: () => ["claude"] }, display: CLAUDE_DISPLAY, hooks: { recallPlatform: "claude", recallSource: "claude", recallInjected: true, pluginInventory: claudePluginInventory },
|
|
99
|
+
cli: { command: "claude", entry: { lifecycleAdmission: true, execute: runClaudeEntry }, preview: previewClaude, usage: [["ahub claude [--print-command] [--unattended] [args...]", "launch Claude Code with the hub channel"]], retainedTerminalNotice: claudeRetainedTerminalNotice, stopNotice: () => "this peer belongs to a native terminal; end it in its terminal", startWords: () => ["claude"] }, display: CLAUDE_DISPLAY, hooks: { recallPlatform: "claude", recallSource: "claude", recallInjected: true, pluginInventory: claudePluginInventory },
|
|
88
100
|
reviewerVariant: CLAUDE_REVIEWER_VARIANT,
|
|
89
101
|
},
|
|
90
102
|
{
|
|
91
103
|
reviewerVariant: CODEX_REVIEWER_VARIANT,
|
|
104
|
+
projection: { roleRank: 1, startRank: 1, helpRank: 1, plannerEligible: true },
|
|
92
105
|
api: 1, kind: "codex", displayName: "Codex", family: "codex", transport: "codex-appserver",
|
|
93
106
|
roles: Object.freeze(["implementer"]), defaultOnRoster: true, trust: { onPrem: false, hubSandboxed: false },
|
|
94
107
|
start: { create: createCodex, daemonPort: "codex-appserver", forms: dualForms, defaultForm: "tui", launch: codexNativeLaunch, version: codexObservedVersion, ...CODEX_LAUNCH_POLICY, conductorStartable: true,
|
|
@@ -97,10 +110,11 @@ export const NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly AdapterDefinition
|
|
|
97
110
|
permission: { trackUnattended: true, grants: { ask: "the session's own approval policy applies again", "ask-when-needed": "approval policy on-request on every turn", "never-ask": "approval policy never on every turn: nothing asks" } },
|
|
98
111
|
session: { nativeRevert: true, idField: "threadId", currentId: sessionId("threadId"), configDirEnv: "CODEX_HOME", zeroTurnFresh: true, launchKind: "codex", launchRecord: true, ...CODEX_RECOVERY_SESSION, crashPlan: codexCrashPlan, unrecordedTerminal: "always" },
|
|
99
112
|
turnEnd: "adapter", budget: { account: "codex", checkpoint: true, reader: { source: "codex rateLimits", read: codexWindows }, context: { source: "codex_token_usage", read: codexContext } },
|
|
100
|
-
cli: { command: "codex", launchConfigKeys: ["codex_bin"], retainedTerminalNotice: codexRetainedTerminalNotice, rosterReturnNotice: codexRosterReturnNotice, stopNotice: () => "this peer belongs to a native terminal; end it in its terminal", startWords: () => ["codex"] }, display: CODEX_DISPLAY, hooks: { recallPlatform: "codex", recallSource: "codex", recallInjected: true, pluginInventory: codexPluginInventory, classifyTool: codexToolSemantic },
|
|
113
|
+
cli: { command: "codex", entry: { lifecycleAdmission: true, execute: runCodexEntry }, preview: previewCodex, usage: [["ahub codex [--print-command] [--unattended] [args...]", "start the Codex adapter and attach the TUI"]], launchConfigKeys: ["codex_bin"], retainedTerminalNotice: codexRetainedTerminalNotice, rosterReturnNotice: codexRosterReturnNotice, stopNotice: () => "this peer belongs to a native terminal; end it in its terminal", startWords: () => ["codex"] }, display: CODEX_DISPLAY, hooks: { recallPlatform: "codex", recallSource: "codex", recallInjected: true, pluginInventory: codexPluginInventory, classifyTool: codexToolSemantic },
|
|
101
114
|
},
|
|
102
115
|
{
|
|
103
116
|
reviewerVariant: KIMI_REVIEWER_VARIANT,
|
|
117
|
+
projection: { roleRank: 2, startRank: 2, helpRank: 2, plannerEligible: true },
|
|
104
118
|
api: 1, kind: "kimi", displayName: "Kimi Code", family: "kimi", transport: "acp",
|
|
105
119
|
roles: implementerRoles, defaultOnRoster: true, trust: { onPrem: false, hubSandboxed: false },
|
|
106
120
|
start: { create: createKimi, acpCommand: kimiAcpCommand, explicitHeadlessMode: true, planCommand: () => "ahub kimi", daemonPort: "acp", forms: dualForms, defaultForm: "tui", conductorStartable: true, hubSwitches: [], ownedFlags: [] },
|
|
@@ -112,9 +126,10 @@ export const NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly AdapterDefinition
|
|
|
112
126
|
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" },
|
|
113
127
|
session: { idField: "sessionId", currentId: sessionId("sessionId"), freshOnRestore: true, launchKind: "acp", ...KIMI_RECOVERY_SESSION, crashPlan: kimiCrashPlan },
|
|
114
128
|
turnEnd: "adapter", budget: { account: "kimi", resetSoftTokensOnResume: true, context: { source: "acp_usage_update", read: acpContextValue } }, capabilities: { watch: true }, hooks: { recallPlatform: "kimi", recallSource: "kimi" },
|
|
115
|
-
cli: { command: "kimi", 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,
|
|
129
|
+
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,
|
|
116
130
|
},
|
|
117
131
|
{
|
|
132
|
+
projection: { roleRank: 4, startRank: 0, helpRank: 6, plannerEligible: false },
|
|
118
133
|
api: 1, kind: "pi", enabledSwitchStartOnly: true, displayName: "Pi", family: "pi", transport: "hub-native",
|
|
119
134
|
roles: implementerRoles, defaultOnRoster: false, trust: { onPrem: false, hubSandboxed: true },
|
|
120
135
|
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, autoStart: "pi.auto_start", conductorStartArgs: piConductorStartArgs, hubSwitches: [], ownedFlags: [] },
|
|
@@ -124,9 +139,10 @@ export const NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly AdapterDefinition
|
|
|
124
139
|
session: { idField: "sessionId", currentId: sessionId("sessionId"), restoreOnStart: true, launchKind: "pi", launchRecord: true, ...PI_RECOVERY_SESSION, crashPlan: piCrashPlan, unrecordedTerminal: "tui", continuityBookmark: piRecoveryContinuity, continuity: { key: "pi", parse: parsePiContinuity } },
|
|
125
140
|
turnEnd: "adapter", budget: { account: "pi", instrumented: true }, reporting: { tokensNotReported: true }, configuration: { schema: piConfigSchema, routingSchema: piRoutingSchema, settings: PI_SETTINGS, restartReasons: PI_RESTART_REASONS, validateEffective: piEffectiveConfig },
|
|
126
141
|
routing: { localAllowedRefusal: "local_allowed = false also excludes pi for this class", longContext: "backend-limit", preferredTier: true, handoffPriority: 0, trace: "backend", backendLabel: "pi" },
|
|
127
|
-
cli: { command: "pi", launchConfigKeys: ["pi.cmd", "pi.backend", "pi.dgx_coding", "pi.dgx_fast", "pi.max_steps", "routing.pi", "peers.pi.start_mode"], previewArgs: ["--mode", "tui"], retainedTerminalNotice: piRetainedTerminalNotice, personOnlyFlags: ["--replace-session"], startWords: form => ["pi", "--mode", form] }, display: PI_DISPLAY,
|
|
142
|
+
cli: { command: "pi", entry: { lifecycleAdmission: false, execute: runPiEntry }, preview: previewPi, usage: [["ahub pi [--print-command] [--mode headless|tui | --headless] [--backend auto|dgx|mlx] [--model <alias>] [--session-id <id>] [--session-file <path>] [--replace-session]", "start Pi: its TUI in this terminal unless --headless or the start mode setting (peers.pi.start_mode) says headless; without a terminal the hub opens one or says how"]], launchConfigKeys: ["pi.cmd", "pi.backend", "pi.dgx_coding", "pi.dgx_fast", "pi.max_steps", "routing.pi", "peers.pi.start_mode"], previewArgs: ["--mode", "tui"], retainedTerminalNotice: piRetainedTerminalNotice, personOnlyFlags: ["--replace-session"], startWords: form => ["pi", "--mode", form] }, display: PI_DISPLAY,
|
|
128
143
|
},
|
|
129
144
|
{
|
|
145
|
+
projection: { roleRank: 3, helpRank: 7, plannerEligible: false },
|
|
130
146
|
api: 1, kind: "local", displayName: "Local worker", family: "local", transport: "hub-native",
|
|
131
147
|
roles: implementerRoles, defaultOnRoster: true, trust: { onPrem: true, hubSandboxed: true },
|
|
132
148
|
start: { create: createLocal, reconfigureWhileAttached: true, refreshTools: true, daemonPort: "local-worker", forms: headlessForms, defaultForm: "headless", conductorStartable: true, hubSwitches: [], ownedFlags: [] },
|
|
@@ -136,9 +152,10 @@ export const NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly AdapterDefinition
|
|
|
136
152
|
session: { idField: "sessionId", currentId: sessionId("sessionId"), freshOnRestore: true, launchKind: "local", ...LOCAL_RECOVERY_SESSION, crashPlan: localCrashPlan },
|
|
137
153
|
turnEnd: "adapter", budget: { account: "local", quotaExempt: true, instrumented: true }, configuration: { schema: localConfigSchema, routingSchema: localRoutingSchema, restartReasons: LOCAL_RESTART_REASONS },
|
|
138
154
|
routing: { localAllowedRefusal: "local_allowed = false for this class", longContext: "skip-owner", preferredTier: true, handoffPriority: 1, trace: "route" },
|
|
139
|
-
cli: { command: "local", launchConfigKeys: ["local.deny", "local.bash_network", "local.network_allow", "local.max_steps", "local.read_allow", "routing.local"], startWords: () => ["local"] }, display: LOCAL_DISPLAY,
|
|
155
|
+
cli: { command: "local", entry: { lifecycleAdmission: false, execute: runLocalEntry }, usage: [["ahub local [--route <id> | --model <id>]", "start the hub-native worker on the self-hosted models (routing.toml)"]], launchConfigKeys: ["local.deny", "local.bash_network", "local.network_allow", "local.max_steps", "local.read_allow", "routing.local"], startWords: () => ["local"] }, display: LOCAL_DISPLAY,
|
|
140
156
|
},
|
|
141
157
|
{
|
|
158
|
+
projection: { roleRank: 6, startRank: 3, helpRank: 3, plannerEligible: true },
|
|
142
159
|
api: 1, kind: "jcode", displayName: "Jcode", family: "jcode", transport: "acp",
|
|
143
160
|
roles: implementerRoles, optional: true, defaultOnRoster: false, trust: { onPrem: false, hubSandboxed: false },
|
|
144
161
|
start: { create: createJcode, preflight: jcodeStartPreflight, daemonPort: "owned-acp", forms: dualForms, defaultForm: "tui", explicitHeadlessMode: true, conductorStartable: true, hubSwitches: [], ownedFlags: [] },
|
|
@@ -147,7 +164,7 @@ export const NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly AdapterDefinition
|
|
|
147
164
|
ask: "no verified hub mode mapping; native policy only", "ask-when-needed": "no verified hub mode mapping; native policy only", "never-ask": "no verified hub mode mapping; native policy only" } },
|
|
148
165
|
session: { idField: "sessionId", currentId: sessionId("sessionId"), launchKind: "jcode", ...JCODE_RECOVERY_SESSION, crashPlan: jcodeCrashPlan }, turnEnd: "adapter", budget: { account: "jcode" },
|
|
149
166
|
// Jcode has no dedicated CSS swatch today: retain peer-palette.ts's existing 'other' fallback.
|
|
150
|
-
cli: { command: "jcode", authority: "person", launchConfigKeys: ["jcode_cmd", "jcode_home", "jcode_provider_profile"], startWords: mode => ["jcode", "--mode", mode] }, display: JCODE_DISPLAY, hooks: { recallPlatform: "jcode" },
|
|
167
|
+
cli: { command: "jcode", entry: { lifecycleAdmission: true, execute: runJcodeEntry }, preview: previewJcode, usage: [["ahub jcode [--mode tui|headless] [--model <alias>] [--provider-profile <name>] [--session-id <id>] [--print-command]", "optional Jcode (roster.jcode, default off): its TUI on the hub-owned ACP session, opened once that session is saved; --mode headless for ACP only; native policy unmanaged, no hub sandbox"]], authority: "person", launchConfigKeys: ["jcode_cmd", "jcode_home", "jcode_provider_profile"], startWords: mode => ["jcode", "--mode", mode] }, display: JCODE_DISPLAY, hooks: { recallPlatform: "jcode" },
|
|
151
168
|
},
|
|
152
169
|
] satisfies AdapterDefinition[]);
|
|
153
170
|
|
|
@@ -45,7 +45,13 @@ export async function runKimiEntry(ctx: NativeCliEntryContext): Promise<void> {
|
|
|
45
45
|
let result: ReturnType<typeof spawnSync>;
|
|
46
46
|
try {
|
|
47
47
|
// Inside the try: a kimi_cmd with no TUI form throws here, and the admission must still be released (#549).
|
|
48
|
-
|
|
48
|
+
// The running hub's value wins over the file's while a reload is retained (#654): when it has no TUI form, say whose it is, so the
|
|
49
|
+
// error is not read as a fault in the file. The words themselves are never echoed.
|
|
50
|
+
const running = Array.isArray(runningCommand) && runningCommand.every((arg: unknown) => typeof arg === "string");
|
|
51
|
+
try { launch = buildKimiTuiLaunch(running ? runningCommand : projectConfig().kimi_cmd, model); }
|
|
52
|
+
catch (error) {
|
|
53
|
+
throw running ? new Error(`${(error as Error).message} (the command is the running hub's kimi_cmd: the value in the config file is retained until ahub settings reload)`) : error;
|
|
54
|
+
}
|
|
49
55
|
// The daemon's own bundle, so the MCP server matches the hub it talks to (#502); this CLI's copy only for an older hub.
|
|
50
56
|
const written = configureKimiMcp(cwd, stateDir, bundleInStatus(snapshot) ?? resolve(cliDir, "../../plugins/agent-hub/server.js"), "tui");
|
|
51
57
|
if (written.tracked) console.error(".kimi-code/mcp.json is tracked; the agent-hub entry contains machine paths. Review this local change before committing it.");
|
|
@@ -1,21 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { AdapterDefinition } from "../registry-types.ts";
|
|
2
2
|
import type { BuiltinCliEntry } from "./native-cli-entry-types.ts";
|
|
3
|
-
import { runClaudeEntry } from "./native-cli-entry-claude.ts";
|
|
4
|
-
import { runCodexEntry } from "./native-cli-entry-codex.ts";
|
|
5
|
-
import { runJcodeEntry } from "./native-cli-entry-jcode.ts";
|
|
6
|
-
import { runQwenEntry } from "./native-cli-entry-qwen.ts";
|
|
7
|
-
import { runKimiEntry } from "./native-cli-entry-kimi.ts";
|
|
8
|
-
import { runPiEntry } from "./native-cli-entry-pi.ts";
|
|
9
|
-
import { runLocalEntry } from "./native-cli-entry-local.ts";
|
|
10
3
|
|
|
11
|
-
|
|
12
|
-
export
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
{ command: "qwen", lifecycleAdmission: true, execute: runQwenEntry },
|
|
18
|
-
{ command: "kimi", lifecycleAdmission: true, execute: runKimiEntry },
|
|
19
|
-
{ command: "pi", lifecycleAdmission: false, execute: runPiEntry },
|
|
20
|
-
{ command: "local", lifecycleAdmission: false, execute: runLocalEntry },
|
|
21
|
-
]);
|
|
4
|
+
/** Project only the trusted definitions supplied by the host, preserving their order. */
|
|
5
|
+
export function builtinCliEntries(definitions: readonly AdapterDefinition[]): readonly BuiltinCliEntry[] {
|
|
6
|
+
return Object.freeze(definitions.flatMap(definition => definition.cli.entry
|
|
7
|
+
? [Object.freeze({ ...definition.cli.entry, command: definition.cli.command })]
|
|
8
|
+
: []));
|
|
9
|
+
}
|
|
@@ -1,16 +1,7 @@
|
|
|
1
1
|
import type { AdapterDefinition } from "../registry-types.ts";
|
|
2
2
|
import type { NativePreviewBuilder } from "./native-cli-preview-common.ts";
|
|
3
|
-
import { previewClaude } from "./native-cli-preview-claude.ts";
|
|
4
|
-
import { previewCodex } from "./native-cli-preview-codex.ts";
|
|
5
|
-
import { previewKimi } from "./native-cli-preview-kimi.ts";
|
|
6
|
-
import { previewPi } from "./native-cli-preview-pi.ts";
|
|
7
|
-
import { previewQwen } from "./native-cli-preview-qwen.ts";
|
|
8
|
-
import { previewJcode } from "./native-cli-preview-jcode.ts";
|
|
9
3
|
|
|
10
|
-
|
|
11
|
-
const previews: Readonly<Record<string, NativePreviewBuilder>> = Object.freeze({
|
|
12
|
-
claude: previewClaude, codex: previewCodex, kimi: previewKimi, pi: previewPi, qwen: previewQwen, jcode: previewJcode,
|
|
13
|
-
});
|
|
4
|
+
/** The host admits builtin-only preview ports before calling this projection. */
|
|
14
5
|
export function builtinPreview(definition: AdapterDefinition): NativePreviewBuilder | undefined {
|
|
15
|
-
return
|
|
6
|
+
return definition.cli.preview;
|
|
16
7
|
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { runQwenEntry } from "./native-cli-entry-qwen.ts";
|
|
2
|
+
import { previewQwen } from "./native-cli-preview-qwen.ts";
|
|
1
3
|
import { QWEN_DISPLAY } from "./native-display.ts";
|
|
2
4
|
import { QWEN_RECOVERY_SESSION } from "./native-recovery.ts";
|
|
3
5
|
import { AcpPeer } from "../acp.ts";
|
|
@@ -9,6 +11,7 @@ import { shellQuote } from "../../cli/shell-quote.ts";
|
|
|
9
11
|
export const QWEN_DEFINITION: AdapterDefinition = {
|
|
10
12
|
api: 1,
|
|
11
13
|
kind: "qwen",
|
|
14
|
+
projection: { roleRank: 5, helpRank: 5, plannerEligible: true },
|
|
12
15
|
displayName: "Qwen Code",
|
|
13
16
|
family: "qwen",
|
|
14
17
|
transport: "acp",
|
|
@@ -64,7 +67,7 @@ export const QWEN_DEFINITION: AdapterDefinition = {
|
|
|
64
67
|
turnEnd: "adapter",
|
|
65
68
|
budget: { account: "qwen" },
|
|
66
69
|
capabilities: { watch: true },
|
|
67
|
-
cli: { command: "qwen", launchConfigKeys: ["qwen_cmd"], startWords: () => ["qwen"] },
|
|
70
|
+
cli: { command: "qwen", entry: { lifecycleAdmission: true, execute: runQwenEntry }, preview: previewQwen, usage: [["ahub qwen [--mode headless] [--model <alias>] [--session-id <id>] [--print-command]", "start optional Qwen Code 0.25.0 ACP (roster.qwen, default off)"]], launchConfigKeys: ["qwen_cmd"], startWords: () => ["qwen"] },
|
|
68
71
|
display: QWEN_DISPLAY,
|
|
69
72
|
hooks: { recallPlatform: "qwen" },
|
|
70
73
|
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { types } from "node:util";
|
|
2
|
+
import { ADAPTER_API } from "./adapter-api.ts";
|
|
3
|
+
import type { AdapterDefinition } from "./registry-types.ts";
|
|
4
|
+
export interface ExternalAdapterMetadata { readonly kind: string; readonly displayName: string }
|
|
5
|
+
const MAX_MODULES = 16;
|
|
6
|
+
const fail = (reason: string): never => { throw new Error(`external adapter: ${reason}`); };
|
|
7
|
+
export function moduleDataObject(value: unknown): Record<string, unknown> {
|
|
8
|
+
if (!value || typeof value !== "object" || types.isProxy(value) || Array.isArray(value)
|
|
9
|
+
|| ![Object.prototype, null].includes(Object.getPrototypeOf(value)) || Object.getOwnPropertySymbols(value).length) return fail("plain own-data object required");
|
|
10
|
+
const fields: Record<string, PropertyDescriptor> = Object.assign(Object.create(null), Object.getOwnPropertyDescriptors(value));
|
|
11
|
+
if (Object.values(fields).some(field => !Object.hasOwn(field, "value") || !field.enumerable)) return fail("accessors and hidden fields are unsupported");
|
|
12
|
+
return Object.fromEntries(Object.entries(fields).map(([key, field]) => [key, field.value]));
|
|
13
|
+
}
|
|
14
|
+
export function moduleText(value: unknown, max: number): string {
|
|
15
|
+
if (typeof value !== "string" || !value.trim() || Buffer.byteLength(value) > max || /[\p{Cc}\p{Cf}]/u.test(value)) return fail("bounded control-free text required");
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
export function moduleArray(value: unknown, max: number): unknown[] {
|
|
19
|
+
if (!Array.isArray(value) || types.isProxy(value) || Object.getPrototypeOf(value) !== Array.prototype || value.length > max
|
|
20
|
+
|| Object.getOwnPropertySymbols(value).length) return fail("bounded array required");
|
|
21
|
+
const fields: Record<string, PropertyDescriptor> = Object.assign(Object.create(null), Object.getOwnPropertyDescriptors(value));
|
|
22
|
+
if (Object.keys(fields).length !== value.length + 1) return fail("dense array required");
|
|
23
|
+
return Array.from({ length: value.length }, (_, index) => {
|
|
24
|
+
const field = fields[String(index)];
|
|
25
|
+
if (!field || !Object.hasOwn(field, "value") || !field.enumerable) return fail("array data fields required");
|
|
26
|
+
return field.value;
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
/** Bounded inert metadata, usable by CLI/config readers without executing module callbacks. */
|
|
30
|
+
export function externalAdapterMetadata(raw: unknown): readonly ExternalAdapterMetadata[] {
|
|
31
|
+
if (raw === undefined) return Object.freeze([]);
|
|
32
|
+
const rows = moduleArray(raw, MAX_MODULES).map(value => {
|
|
33
|
+
const row = moduleDataObject(value), kind = row.kind;
|
|
34
|
+
if (Object.keys(row).some(key => !["kind", "displayName"].includes(key)) || typeof kind !== "string" || !/^[a-z][a-z0-9-]{0,31}$/.test(kind)) return fail("invalid external display metadata");
|
|
35
|
+
return Object.freeze({ kind, displayName: moduleText(row.displayName, 256) });
|
|
36
|
+
});
|
|
37
|
+
if (new Set(rows.map(row => row.kind)).size !== rows.length) return fail("duplicate external display metadata");
|
|
38
|
+
return Object.freeze(rows);
|
|
39
|
+
}
|
|
40
|
+
export function externalMetadataDefinitions(raw: unknown): readonly AdapterDefinition[] {
|
|
41
|
+
return Object.freeze(externalAdapterMetadata(raw).map(({ kind, displayName }): AdapterDefinition => ({
|
|
42
|
+
api: ADAPTER_API, kind, family: kind, displayName, transport: "acp", roles: [], optional: true, defaultOnRoster: false,
|
|
43
|
+
trust: { onPrem: false, hubSandboxed: false }, identity: { mcp: "acp-session" }, permission: { genericAcp: {}, unmanaged: "external adapter metadata; authority belongs to the running daemon" },
|
|
44
|
+
start: { forms: ["headless"], defaultForm: "headless", conductorStartable: true, hubSwitches: [], ownedFlags: [] },
|
|
45
|
+
session: { idField: "sessionId", currentId: () => undefined }, turnEnd: "adapter",
|
|
46
|
+
cli: { command: kind, startWords: () => [kind] }, display: { palette: { fg: "white", bg: "black" } },
|
|
47
|
+
})));
|
|
48
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
interface Token { kind: "word" | "string" | "punct"; value: string }
|
|
2
|
+
const forbiddenNames = new Set(["require", "createRequire", "eval", "Function"]);
|
|
3
|
+
const globalNames = new Set(["globalThis", "global", "self", "window"]);
|
|
4
|
+
const regexAfterWords = new Set(["return", "throw", "case", "delete", "typeof", "void", "yield", "await", "in", "of", "else", "do"]);
|
|
5
|
+
const regexAfterPunctuation = new Set(["=", "(", "[", "{", ":", ",", ";", "!", "?", "&&", "||", "??", "=>", "+", "-", "*", "%", "&", "|", "^", "~", "<", ">"]);
|
|
6
|
+
const fail = (): never => { throw new Error("external adapter: module source scan refused (details withheld)"); };
|
|
7
|
+
|
|
8
|
+
/** Lex normalized JS, retaining literal tokens only for direct computed global/member access. */
|
|
9
|
+
function tokensOf(source: string): Token[] {
|
|
10
|
+
const tokens: Token[] = [];
|
|
11
|
+
let index = 0;
|
|
12
|
+
const controlParens: boolean[] = [];
|
|
13
|
+
let regexAllowed = true;
|
|
14
|
+
const push = (kind: Token["kind"], value: string) => {
|
|
15
|
+
const previous = tokens.at(-1);
|
|
16
|
+
if (kind === "punct" && value === "(") controlParens.push(previous?.kind === "word" && ["if", "while", "for", "with", "switch", "catch"].includes(previous.value));
|
|
17
|
+
const controlEnd = kind === "punct" && value === ")" ? controlParens.pop() === true : false;
|
|
18
|
+
tokens.push({ kind, value });
|
|
19
|
+
regexAllowed = controlEnd || (kind === "word" ? regexAfterWords.has(value) : kind === "punct" && regexAfterPunctuation.has(value));
|
|
20
|
+
};
|
|
21
|
+
const wordStart = (char: string) => /[$_\p{ID_Start}]/u.test(char);
|
|
22
|
+
const wordPart = (char: string) => /[$_\p{ID_Continue}]/u.test(char);
|
|
23
|
+
const quoted = (quote: string) => {
|
|
24
|
+
index++;
|
|
25
|
+
let value = "";
|
|
26
|
+
while (index < source.length) {
|
|
27
|
+
const char = source[index++]!;
|
|
28
|
+
if (char === quote) { push("string", value); return; }
|
|
29
|
+
if (char === "\\") {
|
|
30
|
+
const escaped = source[index++];
|
|
31
|
+
// Unknown escapes are not a proof of a statically named member. Transform normally canonicalizes them.
|
|
32
|
+
value += escaped === quote || escaped === "\\" ? escaped : "?";
|
|
33
|
+
} else value += char;
|
|
34
|
+
}
|
|
35
|
+
fail();
|
|
36
|
+
};
|
|
37
|
+
const regex = () => {
|
|
38
|
+
index++;
|
|
39
|
+
let characterClass = false;
|
|
40
|
+
while (index < source.length) {
|
|
41
|
+
const char = source[index++]!;
|
|
42
|
+
if (char === "\\") { index++; continue; }
|
|
43
|
+
if (char === "[") characterClass = true;
|
|
44
|
+
else if (char === "]") characterClass = false;
|
|
45
|
+
else if (char === "/" && !characterClass) {
|
|
46
|
+
while (index < source.length && /[a-z]/i.test(source[index]!)) index++;
|
|
47
|
+
push("string", "");
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
fail();
|
|
52
|
+
};
|
|
53
|
+
const template = (depth: number) => {
|
|
54
|
+
if (depth > 128) fail();
|
|
55
|
+
index++;
|
|
56
|
+
let text = "";
|
|
57
|
+
while (index < source.length) {
|
|
58
|
+
const char = source[index]!;
|
|
59
|
+
if (source.charCodeAt(index) === 96) { index++; push("string", text); return; }
|
|
60
|
+
if (char === "\\") { index += 2; text += "?"; continue; }
|
|
61
|
+
if (char === "$" && source[index + 1] === "{") {
|
|
62
|
+
push("string", text); text = ""; index += 2;
|
|
63
|
+
push("punct", "(");
|
|
64
|
+
code(true, depth + 1);
|
|
65
|
+
push("punct", ")");
|
|
66
|
+
} else { text += char; index++; }
|
|
67
|
+
}
|
|
68
|
+
fail();
|
|
69
|
+
};
|
|
70
|
+
const code = (stopAtBrace: boolean, depth: number) => {
|
|
71
|
+
if (depth > 128) fail();
|
|
72
|
+
let braces = 0;
|
|
73
|
+
while (index < source.length) {
|
|
74
|
+
const char = source[index]!, next = source[index + 1];
|
|
75
|
+
if (/\s/u.test(char)) { index++; continue; }
|
|
76
|
+
if (char === "/" && next === "/") {
|
|
77
|
+
index += 2; while (index < source.length && source[index] !== "\n") index++;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (char === "/" && next === "*") {
|
|
81
|
+
const end = source.indexOf("*/", index + 2);
|
|
82
|
+
if (end < 0) fail(); index = end + 2; continue;
|
|
83
|
+
}
|
|
84
|
+
if (char === "'" || char === '"') { quoted(char); continue; }
|
|
85
|
+
if (source.charCodeAt(index) === 96) { template(depth + 1); continue; }
|
|
86
|
+
if (char === "}" && stopAtBrace && braces === 0) { index++; return; }
|
|
87
|
+
if (char === "{") braces++;
|
|
88
|
+
if (char === "}") braces--;
|
|
89
|
+
if (char === "/" && regexAllowed) { regex(); continue; }
|
|
90
|
+
if (wordStart(char)) {
|
|
91
|
+
const start = index++;
|
|
92
|
+
while (index < source.length && wordPart(source[index]!)) index++;
|
|
93
|
+
push("word", source.slice(start, index)); continue;
|
|
94
|
+
}
|
|
95
|
+
const pair = char + (next ?? "");
|
|
96
|
+
if (["=>", "&&", "||", "??", "++", "--", "?."].includes(pair)) { push("punct", pair); index += 2; }
|
|
97
|
+
else { push("punct", char); index++; }
|
|
98
|
+
}
|
|
99
|
+
if (stopAtBrace) fail();
|
|
100
|
+
};
|
|
101
|
+
code(false, 0);
|
|
102
|
+
return tokens;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Conservative ESM dependency/code-loading inspection, not a sandbox or proof against obfuscated privileged code.
|
|
107
|
+
* Literal messages/comments are inert. Direct code-loading names are refused even when aliased or shadowed.
|
|
108
|
+
*/
|
|
109
|
+
export function scanModuleSource(source: string): readonly Bun.Import[] {
|
|
110
|
+
try {
|
|
111
|
+
const transpiler = new Bun.Transpiler({ loader: "js", minifyWhitespace: true });
|
|
112
|
+
const imports = transpiler.scanImports(source);
|
|
113
|
+
if (imports.some(record => ["dynamic-import", "require-call", "require-resolve"].includes(record.kind))) fail();
|
|
114
|
+
const tokens = tokensOf(transpiler.transformSync(source));
|
|
115
|
+
for (const [index, token] of tokens.entries()) {
|
|
116
|
+
if (token.kind === "word" && forbiddenNames.has(token.value)) fail();
|
|
117
|
+
if (token.kind === "word" && token.value === "import" && tokens[index + 1]?.value === "(") fail();
|
|
118
|
+
if (token.kind === "string" && forbiddenNames.has(token.value) && tokens[index - 1]?.value === "[" && tokens[index + 1]?.value === "]") {
|
|
119
|
+
const owner = tokens[index - 2];
|
|
120
|
+
const importMeta = owner?.value === "meta" && tokens[index - 3]?.value === "." && tokens[index - 4]?.value === "import";
|
|
121
|
+
if (owner?.kind === "word" && globalNames.has(owner.value) || importMeta) fail();
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return Object.freeze(imports.map(record => Object.freeze({ ...record })));
|
|
125
|
+
} catch { return fail(); }
|
|
126
|
+
}
|