@staix/agent-hub 0.12.43 → 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 +38 -3
- package/README.md +3 -3
- package/THIRD_PARTY_NOTICES.md +29 -0
- package/docs/adding-an-ai-tool.md +228 -0
- package/docs/agent-notes/README.md +14 -0
- package/docs/agent-notes/adapters.md +61 -42
- package/docs/agent-notes/budget.md +2 -2
- package/docs/agent-notes/daemon.md +16 -10
- package/docs/agent-notes/local-worker.md +4 -3
- package/docs/agent-notes/tasks.md +13 -6
- package/docs/agent-notes/tests.md +9 -7
- package/docs/operations.md +163 -12
- package/docs/quickstart.md +10 -2
- package/docs/sdlc.md +26 -8
- package/docs/security.md +11 -6
- package/docs/specs/2026-09-19-agent-hub-design.md +273 -8
- package/docs/verification/2026-10-21-0.12.44.md +54 -0
- 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-21-grok-config-acp-t0.md +30 -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 +38 -20
- package/package.json +5 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +10287 -9560
- package/src/adapters/acp-owned-group.ts +124 -0
- package/src/adapters/acp.ts +92 -25
- package/src/adapters/adapter-api.ts +2 -0
- package/src/adapters/agy/definition.ts +4 -4
- package/src/adapters/agy/entry.ts +4 -1
- package/src/adapters/channel-peer.ts +143 -0
- package/src/adapters/claude-channel.ts +21 -12
- package/src/adapters/codex-appserver.ts +17 -2
- package/src/adapters/configured-acp.ts +124 -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 +60 -39
- package/src/adapters/definitions/native-channel-state.ts +41 -0
- package/src/adapters/definitions/native-cli-entry-kimi.ts +9 -1
- package/src/adapters/definitions/native-cli-entry.ts +7 -19
- package/src/adapters/definitions/native-cli-preview-qwen.ts +1 -1
- package/src/adapters/definitions/native-cli-preview.ts +2 -11
- package/src/adapters/definitions/native-identity-defaults.ts +14 -0
- package/src/adapters/definitions/qwen.ts +7 -9
- package/src/adapters/definitions/reviewer-variants.ts +3 -7
- 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/jcode.ts +4 -0
- package/src/adapters/local-worker.ts +4 -0
- package/src/adapters/pi.ts +6 -0
- package/src/adapters/public.ts +7 -0
- package/src/adapters/registry-types.ts +28 -16
- package/src/adapters/registry.ts +115 -17
- package/src/adapters/reviewer-print.ts +4 -0
- package/src/cli/agy-mcp.ts +3 -2
- package/src/cli/configured-acp-entry.ts +58 -0
- package/src/cli/console-state.ts +9 -4
- package/src/cli/facts-hook.ts +2 -1
- package/src/cli/help.ts +27 -16
- package/src/cli/identity.ts +8 -7
- package/src/cli/launch.ts +5 -4
- package/src/cli/main.ts +101 -32
- package/src/cli/memory.ts +13 -10
- package/src/cli/output.ts +1 -0
- package/src/cli/preview.ts +9 -0
- package/src/cli/recovery-guidance.ts +1 -1
- package/src/cli/terminal-recovery.ts +16 -13
- package/src/cli/upgrade-labels.ts +1 -1
- package/src/cli/upgrade-presentation.ts +1 -1
- package/src/cli/upgrade-restore-state.ts +6 -4
- package/src/cli/upgrade-runtime.ts +25 -7
- package/src/cli/upgrade.ts +3 -2
- package/src/hub/board.ts +37 -8
- package/src/hub/budget.ts +9 -2
- package/src/hub/bus.ts +6 -2
- package/src/hub/claude-state.ts +2 -37
- package/src/hub/conductor.ts +8 -6
- package/src/hub/config-apply.ts +37 -17
- package/src/hub/config-reload.ts +42 -11
- package/src/hub/config-trust.ts +1 -1
- package/src/hub/context-window.ts +6 -0
- package/src/hub/control-client.ts +4 -1
- package/src/hub/crash.ts +8 -6
- package/src/hub/daemon.ts +412 -328
- package/src/hub/facts.ts +76 -13
- package/src/hub/hub-tools.ts +57 -5
- package/src/hub/instance-config.ts +33 -12
- package/src/hub/instance-execution.ts +23 -11
- package/src/hub/instances.ts +47 -22
- package/src/hub/lifecycle-schema.ts +5 -3
- package/src/hub/memory-profile.ts +16 -3
- package/src/hub/no-writes.ts +50 -0
- package/src/hub/peers.ts +9 -0
- package/src/hub/permission-mode.ts +9 -7
- package/src/hub/plan-conformance.ts +83 -0
- package/src/hub/review-findings.ts +104 -0
- package/src/hub/roster.ts +35 -6
- package/src/hub/routing.ts +21 -20
- 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 +54 -16
- package/src/hub/snapshots.ts +32 -6
- package/src/hub/start-mode.ts +10 -8
- package/src/hub/task-sweep.ts +8 -2
- package/src/hub/tasks.ts +358 -60
- package/src/hub/ui.ts +2 -2
- package/src/memory/capture.ts +2 -1
- package/src/ui/index.html +563 -77
- package/src/ui/office-model.ts +3 -1
- package/src/ui/peer-palette.ts +44 -0
- package/templates/AGENTS.block.md +1 -1
- package/templates/profiles/general.json +1 -1
- package/templates/profiles/research.json +1 -1
- package/templates/profiles/writing.json +1 -1
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Fake OpenAI-compatible gateway (OmniRoute-like): /health, bearer check, x-omniroute-provider, scripted replies.
|
|
2
|
+
import type { ChatMessage } from "../../../omniroute/client.ts";
|
|
3
|
+
|
|
4
|
+
export type Script = (body: { model: string; messages: ChatMessage[]; tools?: unknown[]; max_tokens?: number }, call: number) => Partial<ChatMessage> | Promise<Partial<ChatMessage>>;
|
|
5
|
+
|
|
6
|
+
export const toolCall = (name: string, args: unknown, id = `call_${name}_${Math.random().toString(36).slice(2, 8)}`) => ({
|
|
7
|
+
id,
|
|
8
|
+
type: "function" as const,
|
|
9
|
+
function: { name, arguments: typeof args === "string" ? args : JSON.stringify(args) },
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
export function startFakeModelServer(opts: { key?: string; script?: Script; healthy?: boolean; models?: string[] } = {}) {
|
|
13
|
+
const requests: { headers: Record<string, string>; body: any }[] = [];
|
|
14
|
+
const state = { healthy: opts.healthy ?? true, script: opts.script ?? ((b) => ({ content: `echo: ${b.messages.at(-1)?.content}` })) };
|
|
15
|
+
const server = Bun.serve({
|
|
16
|
+
hostname: "127.0.0.1",
|
|
17
|
+
port: 0,
|
|
18
|
+
async fetch(req) {
|
|
19
|
+
const url = new URL(req.url);
|
|
20
|
+
if (url.pathname === "/v1/models") return state.healthy ? Response.json({ data: (opts.models ?? ["m", "vllm/pinned", "vllm/x", "vllm/test", "vllm/next"]).map((id) => ({ id })) }) : new Response("down", { status: 503 });
|
|
21
|
+
if (url.pathname !== "/v1/chat/completions") return new Response("not found", { status: 404 });
|
|
22
|
+
const body = (await req.json()) as any;
|
|
23
|
+
if (opts.key && req.headers.get("authorization") !== `Bearer ${opts.key}`) return new Response("invalid api key", { status: 401 });
|
|
24
|
+
if (body.messages?.[0]?.content === "agent-hub model availability check: reply OK") {
|
|
25
|
+
return Response.json({ choices: [{ message: { role: "assistant", content: "OK" } }] });
|
|
26
|
+
}
|
|
27
|
+
requests.push({ headers: Object.fromEntries(req.headers), body });
|
|
28
|
+
let scripted: Partial<ChatMessage>;
|
|
29
|
+
try {
|
|
30
|
+
scripted = await state.script(body, requests.length);
|
|
31
|
+
} catch (e) {
|
|
32
|
+
return new Response(`upstream error: ${(e as Error).message}`, { status: 500 });
|
|
33
|
+
}
|
|
34
|
+
const message = { role: "assistant", content: null, reasoning_content: "private chain of thought", ...scripted };
|
|
35
|
+
return Response.json({ choices: [{ message }] }, { headers: { "x-omniroute-provider": "vllm" } });
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
return { url: `http://127.0.0.1:${server.port}/v1`, requests, state, stop: () => server.stop(true) };
|
|
39
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { createInterface } from "node:readline";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
const option = (flag: string) => process.argv.includes(flag) ? process.argv[process.argv.indexOf(flag) + 1] : undefined;
|
|
5
|
+
const empty = process.argv.includes("--empty-session");
|
|
6
|
+
const dir = option("--session-dir")!;
|
|
7
|
+
mkdirSync(dir, { recursive: true });
|
|
8
|
+
const existing = option("--session");
|
|
9
|
+
const sessionId = option("--session-id") ?? (existing ? JSON.parse(readFileSync(existing, "utf8").split("\n")[0]!).id : "fake-session");
|
|
10
|
+
const sessionFile = existing ?? join(dir, `${sessionId}.jsonl`);
|
|
11
|
+
if (!empty && !existsSync(sessionFile)) writeFileSync(sessionFile, JSON.stringify({ type: "session", id: sessionId, cwd: process.cwd() }) + "\n");
|
|
12
|
+
let messageCount = 0;
|
|
13
|
+
const send = (value: unknown) => process.stdout.write(`${JSON.stringify(value)}\n`);
|
|
14
|
+
createInterface({ input: process.stdin }).on("line", (line) => {
|
|
15
|
+
const request = JSON.parse(line) as { id?: string; type?: string };
|
|
16
|
+
if (request.type === "get_state") send({ type: "response", id: request.id, command: "get_state", success: true, data: { isStreaming: false, isCompacting: false, pendingMessageCount: 0, messageCount, sessionId, sessionFile } });
|
|
17
|
+
else if (request.type === "set_model") send({ type: "response", id: request.id, command: "set_model", success: true });
|
|
18
|
+
else if (request.type === "prompt" || request.type === "steer") { messageCount++; send({ type: "response", id: request.id, command: request.type, success: true }); }
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const exitTrigger = option("--exit-trigger");
|
|
22
|
+
if (exitTrigger) {
|
|
23
|
+
const timer = setInterval(() => {
|
|
24
|
+
if (!existsSync(exitTrigger)) return;
|
|
25
|
+
clearInterval(timer);
|
|
26
|
+
if (process.argv.includes("--exit-consume-trigger")) rmSync(exitTrigger);
|
|
27
|
+
const signal = option("--exit-signal");
|
|
28
|
+
if (signal) process.kill(process.pid, signal as NodeJS.Signals);
|
|
29
|
+
else process.exit(Number(option("--exit-code") ?? 0));
|
|
30
|
+
}, 10);
|
|
31
|
+
timer.unref();
|
|
32
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Bun test registration kit. Importing this entry registers no suite and starts no native transport. */
|
|
2
|
+
export { registerConformance, registerExternalChannelConformance, until, type AdapterConformance, type ConformanceFixture } from "./kit.ts";
|
|
3
|
+
export { registerAcpConformance, type AcpConformanceOptions } from "./acp.ts";
|
|
4
|
+
export { registerCodexConformance } from "./codex.ts";
|
|
5
|
+
export { registerOwnedAcpConformance } from "./owned-acp.ts";
|
|
6
|
+
export { registerLocalConformance, registerPiConformance } from "./local-pi.ts";
|
|
7
|
+
export { createFromDefinition, expire } from "./factories.ts";
|
|
8
|
+
export { ACP_LEGS, assertAcpLegEnrollment, assertEnrollment, definitionIdentities, type AcpLeg } from "./enrollment.ts";
|
|
9
|
+
export { fakePaths } from "./fake-paths.ts";
|
|
10
|
+
export { startFakeAppServer } from "./fakes/app-server.ts";
|
|
11
|
+
export { startFakeModelServer, toolCall, type Script } from "./fakes/model-server.ts";
|
|
@@ -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,15 @@
|
|
|
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";
|
|
12
|
+
import type { PermissionMode } from "../../hub/permission-mode.ts";
|
|
1
13
|
import { claudeReviewerBackend, CODEX_REVIEWER_VARIANT, KIMI_REVIEWER_VARIANT } from "./reviewer-variants.ts";
|
|
2
14
|
import { kimiAcpCommand } from "./native-daemon-kimi.ts";
|
|
3
15
|
import { claudeRecoveryLaunch, kimiRecoveryLaunch, claudeRetainedTerminalNotice, piRetainedTerminalNotice, codexRetainedTerminalNotice, codexRosterReturnNotice, jcodeStartPreflight, claudeObservedVersion, codexObservedVersion } from "./native-daemon-policy.ts";
|
|
@@ -12,14 +24,14 @@ import { CLAUDE_DISPLAY, CODEX_DISPLAY, KIMI_DISPLAY, PI_DISPLAY, LOCAL_DISPLAY,
|
|
|
12
24
|
import { CLAUDE_RECOVERY_SESSION, CODEX_RECOVERY_SESSION, KIMI_RECOVERY_SESSION, PI_RECOVERY_SESSION, LOCAL_RECOVERY_SESSION, JCODE_RECOVERY_SESSION } from "./native-recovery.ts";
|
|
13
25
|
import { claudeWindows, codexWindows } from "./native-quota.ts";
|
|
14
26
|
import { claudeContext, codexContext, acpContextValue } from "./native-context.ts";
|
|
15
|
-
import type { AdapterDefinition, LaunchContext, LaunchPlan, StartForm } from "../registry-types.ts";
|
|
27
|
+
import type { AdapterDefinition, LaunchContext, LaunchPlan, StartForm, NativeModeSpec } from "../registry-types.ts";
|
|
16
28
|
import type { PeerAdapter } from "../../hub/peers.ts";
|
|
17
29
|
import { shellQuote } from "../../cli/shell-quote.ts";
|
|
18
30
|
|
|
19
31
|
type CompatibilityKind = "claude" | "codex" | "kimi" | "pi" | "local" | "jcode";
|
|
20
32
|
|
|
21
33
|
/**
|
|
22
|
-
* Preserve CLI-wrapper argv plans for
|
|
34
|
+
* Preserve CLI-wrapper argv plans only for the frozen A1 compatibility export.
|
|
23
35
|
* Native startup and daemon factories are supplied separately by the runtime definitions.
|
|
24
36
|
*/
|
|
25
37
|
function compatibilityLaunch(kind: CompatibilityKind, ctx: LaunchContext, explicitMode = false): LaunchPlan {
|
|
@@ -43,8 +55,6 @@ function sessionId(field: "sessionId" | "threadId"): (peer: PeerAdapter) => stri
|
|
|
43
55
|
return typeof value === "string" && value ? value : undefined;
|
|
44
56
|
};
|
|
45
57
|
}
|
|
46
|
-
const terminal = (form: StartForm) => form === "tui";
|
|
47
|
-
const headless = () => false;
|
|
48
58
|
const switches = Object.freeze(["--unattended", "--safe", "--new"]);
|
|
49
59
|
const implementerRoles = Object.freeze(["implementer", "verifier"]);
|
|
50
60
|
const dualForms = Object.freeze(["tui", "headless"] as const);
|
|
@@ -62,9 +72,9 @@ const hubGrants = {
|
|
|
62
72
|
const CLAUDE_REVIEWER_VARIANT: AdapterDefinition = {
|
|
63
73
|
api: 1, kind: "claude", displayName: "Claude Code reviewer", family: "claude", transport: "hub-native",
|
|
64
74
|
roles: Object.freeze(["reviewer"]), defaultOnRoster: true, trust: { onPrem: false, hubSandboxed: false },
|
|
65
|
-
start: { create: createClaudeReviewer, daemonPort: "claude-print", forms: headlessForms, defaultForm: "headless",
|
|
66
|
-
identity: { mcp: "env" },
|
|
67
|
-
permission: {
|
|
75
|
+
start: { create: createClaudeReviewer, daemonPort: "claude-print", forms: headlessForms, defaultForm: "headless", conductorStartable: true, hubSwitches: [], ownedFlags: [] },
|
|
76
|
+
identity: { mcp: "env" }, instances: { mode: "headless" },
|
|
77
|
+
permission: { unmanaged: "each review runs read-only (Read, Grep, Glob) and may call only hub_review and hub_task_show" },
|
|
68
78
|
session: { idField: "sessionId", currentId: () => undefined, freshOnRestore: true, launchKind: "claude-print", crashPlan: () => ({ resume: {}, how: "claude reviewer: starts again; every review runs in a new session" }) },
|
|
69
79
|
turnEnd: "adapter", budget: { account: "claude" }, routing: { reviewOnly: true }, reviewer: { backend: claudeReviewerBackend, defaultPool: true, declaration: "automatic" },
|
|
70
80
|
cli: { command: "claude", startWords: () => ["claude"] }, display: CLAUDE_DISPLAY,
|
|
@@ -74,87 +84,98 @@ const CLAUDE_REVIEWER_VARIANT: AdapterDefinition = {
|
|
|
74
84
|
// daemon.ts runtime mappings/stop policy, budget.ts exemptions, and ui/peer-palette.ts CSS foregrounds.
|
|
75
85
|
export const NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly AdapterDefinition[] = Object.freeze([
|
|
76
86
|
{
|
|
87
|
+
projection: { roleRank: 0, helpRank: 0, plannerEligible: true },
|
|
77
88
|
api: 1, kind: "claude", displayName: "Claude Code", family: "claude", transport: "claude-channel",
|
|
78
89
|
roles: Object.freeze(["planner", "reviewer"]), defaultOnRoster: true, trust: { onPrem: false, hubSandboxed: false },
|
|
79
|
-
start: { forms: Object.freeze(["tui"] as const), defaultForm: "tui",
|
|
90
|
+
start: { forms: Object.freeze(["tui"] as const), defaultForm: "tui", launch: claudeNativeLaunch, version: claudeObservedVersion, ...CLAUDE_LAUNCH_POLICY, conductorStartable: true,
|
|
80
91
|
unattended: true, hubSwitches: switches, ownedFlags: Object.freeze(["--dangerously-load-development-channels", "--dangerously-skip-permissions"]) },
|
|
81
|
-
identity: { mcp: "channel-plugin" }, attach: { recoveryLaunch: claudeRecoveryLaunch },
|
|
82
|
-
//
|
|
83
|
-
permission: {
|
|
92
|
+
identity: { mcp: "channel-plugin" }, instances: { mode: "external", roles: ["reviewer", "planner"] }, attach: { externalHello: true, recoveryLaunch: claudeRecoveryLaunch },
|
|
93
|
+
// Approval enforcement belongs to the verified native transport and host permission gate.
|
|
94
|
+
permission: { grants: { ask: "Claude's own permission rules decide",
|
|
84
95
|
"ask-when-needed": "file tools on project files run without asking, outside .agenthub, .git and agent configuration; other tools keep Claude's own rules",
|
|
85
96
|
"never-ask": "every tool runs without asking" } },
|
|
86
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" },
|
|
87
98
|
turnEnd: "native-hook", budget: { account: "claude", checkpoint: true, reader: { source: "claude status line", read: claudeWindows }, context: { source: "claude_statusline", read: claudeContext } },
|
|
88
|
-
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 },
|
|
89
100
|
reviewerVariant: CLAUDE_REVIEWER_VARIANT,
|
|
90
101
|
},
|
|
91
102
|
{
|
|
92
103
|
reviewerVariant: CODEX_REVIEWER_VARIANT,
|
|
104
|
+
projection: { roleRank: 1, startRank: 1, helpRank: 1, plannerEligible: true },
|
|
93
105
|
api: 1, kind: "codex", displayName: "Codex", family: "codex", transport: "codex-appserver",
|
|
94
106
|
roles: Object.freeze(["implementer"]), defaultOnRoster: true, trust: { onPrem: false, hubSandboxed: false },
|
|
95
|
-
start: { create: createCodex, daemonPort: "codex-appserver", forms: dualForms, defaultForm: "tui",
|
|
107
|
+
start: { create: createCodex, daemonPort: "codex-appserver", forms: dualForms, defaultForm: "tui", launch: codexNativeLaunch, version: codexObservedVersion, ...CODEX_LAUNCH_POLICY, conductorStartable: true,
|
|
96
108
|
unattended: true, hubSwitches: switches, ownedFlags: Object.freeze(["--remote", "--remote-auth-token-env", "--yolo", "--dangerously-bypass-approvals-and-sandbox"]) },
|
|
97
|
-
identity: { mcp: "env" },
|
|
98
|
-
permission: { trackUnattended: true,
|
|
99
|
-
"ask-when-needed": { kind: "codex-approval", policy: "on-request" }, "never-ask": { kind: "codex-approval", policy: "never" } },
|
|
100
|
-
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" } },
|
|
109
|
+
identity: { mcp: "env" }, instances: { mode: "headless" },
|
|
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" } },
|
|
101
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" },
|
|
102
112
|
turnEnd: "adapter", budget: { account: "codex", checkpoint: true, reader: { source: "codex rateLimits", read: codexWindows }, context: { source: "codex_token_usage", read: codexContext } },
|
|
103
|
-
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 },
|
|
104
114
|
},
|
|
105
115
|
{
|
|
106
116
|
reviewerVariant: KIMI_REVIEWER_VARIANT,
|
|
117
|
+
projection: { roleRank: 2, startRank: 2, helpRank: 2, plannerEligible: true },
|
|
107
118
|
api: 1, kind: "kimi", displayName: "Kimi Code", family: "kimi", transport: "acp",
|
|
108
119
|
roles: implementerRoles, defaultOnRoster: true, trust: { onPrem: false, hubSandboxed: false },
|
|
109
|
-
start: { create: createKimi, acpCommand: kimiAcpCommand, explicitHeadlessMode: true, planCommand: () => "ahub kimi", daemonPort: "acp", forms: dualForms, defaultForm: "tui",
|
|
120
|
+
start: { create: createKimi, acpCommand: kimiAcpCommand, explicitHeadlessMode: true, planCommand: () => "ahub kimi", daemonPort: "acp", forms: dualForms, defaultForm: "tui", conductorStartable: true, hubSwitches: [], ownedFlags: [] },
|
|
110
121
|
// ACP sessions carry headless identity; the existing CLI alone owns the TUI's project MCP entry.
|
|
111
|
-
identity: { mcp: "acp-session" }, attach: { pullOnlyForms: Object.freeze(["tui"] as const), recoveryLaunch: kimiRecoveryLaunch,
|
|
122
|
+
identity: { mcp: "acp-session" }, instances: { mode: "headless" }, attach: { pullOnlyForms: Object.freeze(["tui"] as const), recoveryLaunch: kimiRecoveryLaunch,
|
|
112
123
|
pullOnlyNotice: "pull-only: messages wait for hub_inbox; no push, steer, watchdog or automatic quota handoff",
|
|
113
124
|
admission: { reservedRefusal: "Kimi attaches pull-only in TUI mode; run ahub kimi", watchRefusal: "Kimi is in pull-only TUI mode; use its native terminal or start headless" } },
|
|
114
|
-
permission: {
|
|
115
|
-
grants: { ask: "Kimi asks as it does on its own", "ask-when-needed": "Kimi's yolo mode: it asks only where it judges it must", "never-ask": "Kimi's auto mode: nothing asks" },
|
|
125
|
+
permission: { grants: { ask: "Kimi asks as it does on its own", "ask-when-needed": "Kimi's yolo mode: it asks only where it judges it must", "never-ask": "Kimi's auto mode: nothing asks" },
|
|
116
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" },
|
|
117
127
|
session: { idField: "sessionId", currentId: sessionId("sessionId"), freshOnRestore: true, launchKind: "acp", ...KIMI_RECOVERY_SESSION, crashPlan: kimiCrashPlan },
|
|
118
|
-
turnEnd: "adapter", budget: { account: "kimi", resetSoftTokensOnResume: true, context: { source: "acp_usage_update", read: acpContextValue } }, capabilities: { watch: true },
|
|
119
|
-
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,
|
|
128
|
+
turnEnd: "adapter", budget: { account: "kimi", resetSoftTokensOnResume: true, context: { source: "acp_usage_update", read: acpContextValue } }, capabilities: { watch: true }, hooks: { recallPlatform: "kimi", recallSource: "kimi" },
|
|
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,
|
|
120
130
|
},
|
|
121
131
|
{
|
|
132
|
+
projection: { roleRank: 4, startRank: 0, helpRank: 6, plannerEligible: false },
|
|
122
133
|
api: 1, kind: "pi", enabledSwitchStartOnly: true, displayName: "Pi", family: "pi", transport: "hub-native",
|
|
123
134
|
roles: implementerRoles, defaultOnRoster: false, trust: { onPrem: false, hubSandboxed: true },
|
|
124
|
-
start: { create: createPi, exclusiveStartRefusal: "Pi start is in progress; inspect status before retrying", refreshTools: true, explicitHeadlessMode: true, daemonPort: "pi-rpc", forms: dualForms, defaultForm: "tui",
|
|
125
|
-
identity: { mcp: "env" },
|
|
126
|
-
//
|
|
127
|
-
permission: {
|
|
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: [] },
|
|
136
|
+
identity: { mcp: "env" }, instances: { mode: "headless" },
|
|
137
|
+
// Approval enforcement belongs to the verified native transport and host permission gate.
|
|
138
|
+
permission: { grants: { ...hubGrants } },
|
|
128
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 } },
|
|
129
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 },
|
|
130
141
|
routing: { localAllowedRefusal: "local_allowed = false also excludes pi for this class", longContext: "backend-limit", preferredTier: true, handoffPriority: 0, trace: "backend", backendLabel: "pi" },
|
|
131
|
-
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,
|
|
132
143
|
},
|
|
133
144
|
{
|
|
145
|
+
projection: { roleRank: 3, helpRank: 7, plannerEligible: false },
|
|
134
146
|
api: 1, kind: "local", displayName: "Local worker", family: "local", transport: "hub-native",
|
|
135
147
|
roles: implementerRoles, defaultOnRoster: true, trust: { onPrem: true, hubSandboxed: true },
|
|
136
|
-
start: { create: createLocal, reconfigureWhileAttached: true, refreshTools: true, daemonPort: "local-worker", forms: headlessForms, defaultForm: "headless",
|
|
137
|
-
identity: { mcp: "env" },
|
|
138
|
-
//
|
|
139
|
-
permission: {
|
|
148
|
+
start: { create: createLocal, reconfigureWhileAttached: true, refreshTools: true, daemonPort: "local-worker", forms: headlessForms, defaultForm: "headless", conductorStartable: true, hubSwitches: [], ownedFlags: [] },
|
|
149
|
+
identity: { mcp: "env" }, instances: { mode: "headless" },
|
|
150
|
+
// Approval enforcement belongs to the verified native transport and host permission gate.
|
|
151
|
+
permission: { grants: { ...hubGrants } },
|
|
140
152
|
session: { idField: "sessionId", currentId: sessionId("sessionId"), freshOnRestore: true, launchKind: "local", ...LOCAL_RECOVERY_SESSION, crashPlan: localCrashPlan },
|
|
141
153
|
turnEnd: "adapter", budget: { account: "local", quotaExempt: true, instrumented: true }, configuration: { schema: localConfigSchema, routingSchema: localRoutingSchema, restartReasons: LOCAL_RESTART_REASONS },
|
|
142
154
|
routing: { localAllowedRefusal: "local_allowed = false for this class", longContext: "skip-owner", preferredTier: true, handoffPriority: 1, trace: "route" },
|
|
143
|
-
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,
|
|
144
156
|
},
|
|
145
157
|
{
|
|
158
|
+
projection: { roleRank: 6, startRank: 3, helpRank: 3, plannerEligible: true },
|
|
146
159
|
api: 1, kind: "jcode", displayName: "Jcode", family: "jcode", transport: "acp",
|
|
147
160
|
roles: implementerRoles, optional: true, defaultOnRoster: false, trust: { onPrem: false, hubSandboxed: false },
|
|
148
|
-
start: { create: createJcode, preflight: jcodeStartPreflight, daemonPort: "owned-acp", forms: dualForms, defaultForm: "tui",
|
|
161
|
+
start: { create: createJcode, preflight: jcodeStartPreflight, daemonPort: "owned-acp", forms: dualForms, defaultForm: "tui", explicitHeadlessMode: true, conductorStartable: true, hubSwitches: [], ownedFlags: [] },
|
|
149
162
|
identity: { mcp: "project-file" },
|
|
150
|
-
permission: {
|
|
163
|
+
permission: { unmanaged: "Jcode native policy is unmanaged; no hub mode mapping", grants: {
|
|
151
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" } },
|
|
152
165
|
session: { idField: "sessionId", currentId: sessionId("sessionId"), launchKind: "jcode", ...JCODE_RECOVERY_SESSION, crashPlan: jcodeCrashPlan }, turnEnd: "adapter", budget: { account: "jcode" },
|
|
153
166
|
// Jcode has no dedicated CSS swatch today: retain peer-palette.ts's existing 'other' fallback.
|
|
154
|
-
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" },
|
|
155
168
|
},
|
|
156
169
|
] satisfies AdapterDefinition[]);
|
|
157
170
|
|
|
171
|
+
// Frozen A1 metadata is a compatibility snapshot, not the runtime definition contract.
|
|
172
|
+
export type LegacyAdapterDefinition = AdapterDefinition & {
|
|
173
|
+
init?: { templateFragment?: string; mcpFile?: string };
|
|
174
|
+
start: AdapterDefinition["start"] & { ownsTerminal(form: StartForm): boolean; launch(ctx: LaunchContext): LaunchPlan };
|
|
175
|
+
permission: AdapterDefinition["permission"] & { modes: Partial<Record<PermissionMode, NativeModeSpec>>; readOnly?: NativeModeSpec };
|
|
176
|
+
};
|
|
177
|
+
const terminal = (form: StartForm) => form === "tui";
|
|
178
|
+
const headless = () => false;
|
|
158
179
|
/**
|
|
159
180
|
* ponytail: retain the released A1 metadata export for source/library compatibility until A4 retires it.
|
|
160
181
|
* Core runtime consumers use NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN through the registry exclusively.
|
|
@@ -162,7 +183,7 @@ export const NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly AdapterDefinition
|
|
|
162
183
|
* resolves to the TUI (#464, #559). Callers of this export must pass `--mode headless` themselves.
|
|
163
184
|
*/
|
|
164
185
|
const palette = (fg: string) => ({ palette: { fg, bg: "transparent" } });
|
|
165
|
-
export const BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly
|
|
186
|
+
export const BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly LegacyAdapterDefinition[] = Object.freeze([
|
|
166
187
|
{
|
|
167
188
|
api: 1, kind: "claude", displayName: "Claude Code", family: "claude", transport: "claude-channel",
|
|
168
189
|
roles: Object.freeze(["planner", "reviewer"]), defaultOnRoster: true, trust: { onPrem: false, hubSandboxed: false },
|
|
@@ -233,4 +254,4 @@ export const BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly AdapterDefinition[] = Ob
|
|
|
233
254
|
// Jcode has no dedicated CSS swatch today: retain peer-palette.ts's existing 'other' fallback.
|
|
234
255
|
cli: { command: "jcode", startWords: () => ["jcode"] }, display: palette("#8a8f99"),
|
|
235
256
|
},
|
|
236
|
-
] satisfies
|
|
257
|
+
] satisfies LegacyAdapterDefinition[]);
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// No imports: the statusline tee and the CLI load this without the daemon (#450 I3).
|
|
2
|
+
const PEER = /^[a-z][a-z0-9-]{0,31}$/;
|
|
3
|
+
|
|
4
|
+
/** The peer a Claude process writes for: its AGENTHUB_PEER_ID when well formed, otherwise the builtin claude peer. */
|
|
5
|
+
export function claudePeer(value: string | undefined): string {
|
|
6
|
+
return value !== undefined && PEER.test(value) ? value : "claude";
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// Instance ids are always <kind>-<name>, so a Claude peer is the builtin id or a claude-<name> instance.
|
|
10
|
+
const CLAUDE_PEER = /^claude(?:-[a-z0-9-]+)?$/;
|
|
11
|
+
|
|
12
|
+
/** Per-peer Claude state file names. The builtin claude peer keeps its original names, so nothing migrates. */
|
|
13
|
+
export function claudeStateFile(base: "claude-launch" | "claude-session" | "claude-context", peer: string): string {
|
|
14
|
+
// #624: another kind (agy is a hyphen-free builtin too) must never resolve to Claude's own files.
|
|
15
|
+
if (!CLAUDE_PEER.test(peer) || !PEER.test(peer)) throw new Error(`${peer} is not a Claude peer; Claude state files belong to Claude peers only`);
|
|
16
|
+
return peer.includes("-") ? `${base}.${peer}.json` : `${base}.json`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** A Claude peer's own row in terminal-recovery.json for this daemon, never a sibling instance's (row order is arbitrary). */
|
|
20
|
+
export function ownTerminalRow(records: unknown, peer: string, projectRoot: string, instanceId: string): { launchId?: unknown } | undefined {
|
|
21
|
+
if (!Array.isArray(records)) return undefined;
|
|
22
|
+
return records.find((row) => row?.peer === peer && row?.projectRoot === projectRoot && row?.instanceId === instanceId);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Whether a Claude session's transcript exists: the path the peer's own status line reported for that session, else
|
|
27
|
+
* the builtin's default location. An instance may run under its own CLAUDE_CONFIG_DIR, which the hub cannot see:
|
|
28
|
+
* without its reported path the answer is unknown, and unknown counts as persisted (it never waives session identity).
|
|
29
|
+
*/
|
|
30
|
+
export function transcriptPersisted(reported: { sessionId?: string; transcriptPath?: string }, sessionId: string, instance: boolean, fallbackPath: string, exists: (path: string) => boolean | "unknown"): boolean {
|
|
31
|
+
// Only a positive "missing" (ENOENT) is not persisted; an unreadable path is unknown and keeps the identity strict.
|
|
32
|
+
const found = (path: string) => exists(path) !== false;
|
|
33
|
+
if (reported.sessionId === sessionId && reported.transcriptPath) return found(reported.transcriptPath);
|
|
34
|
+
return instance || found(fallbackPath);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** lstat evidence: true when present, false only on ENOENT/ENOTDIR, "unknown" for any other failure (EACCES included). */
|
|
38
|
+
export function pathEvidence(path: string, lstat: (path: string) => unknown): boolean | "unknown" {
|
|
39
|
+
try { lstat(path); return true; }
|
|
40
|
+
catch (error) { const code = (error as { code?: string }).code; return code === "ENOENT" || code === "ENOTDIR" ? false : "unknown"; }
|
|
41
|
+
}
|
|
@@ -41,9 +41,17 @@ export async function runKimiEntry(ctx: NativeCliEntryContext): Promise<void> {
|
|
|
41
41
|
const admission = await hub.request({ t: "start", peer, args: { mode: "tui" }, operationId: process.env.AGENTHUB_RECOVERY_OPERATION });
|
|
42
42
|
if (!admission.ok) fail(admission.error);
|
|
43
43
|
const runningCommand = admission.retainedLaunchConfig?.kimi_cmd;
|
|
44
|
-
|
|
44
|
+
let launch: ReturnType<typeof buildKimiTuiLaunch>;
|
|
45
45
|
let result: ReturnType<typeof spawnSync>;
|
|
46
46
|
try {
|
|
47
|
+
// Inside the try: a kimi_cmd with no TUI form throws here, and the admission must still be released (#549).
|
|
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
|
+
}
|
|
47
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.
|
|
48
56
|
const written = configureKimiMcp(cwd, stateDir, bundleInStatus(snapshot) ?? resolve(cliDir, "../../plugins/agent-hub/server.js"), "tui");
|
|
49
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
|
+
}
|
|
@@ -14,7 +14,7 @@ export function previewQwen(ctx: NativePreviewContext) {
|
|
|
14
14
|
const sessionId = value("--session-id");
|
|
15
15
|
if (sessionId !== undefined && !adapter.session.crashIdentity?.({ sessionId }).sessionId) throw new Error("invalid Qwen session identity");
|
|
16
16
|
if (sessionId) unresolved.push({ field: "sessionId", reason: "requested through ACP session/load at start; preview does not load or expose it" });
|
|
17
|
-
launch = adapter.start.launch({ instance: tool, form: "headless", projectRoot: cwd, stateDir, entrypoint: join(cliDir, "main.js"),
|
|
17
|
+
launch = adapter.start.launch!({ instance: tool, form: "headless", projectRoot: cwd, stateDir, entrypoint: join(cliDir, "main.js"),
|
|
18
18
|
userArgs: [], unattended: false, env: {}, preview: true, nativeCommand: nativeCommand(config.qwen_cmd, ["qwen", "--acp"]), model: model ? REDACTED : undefined });
|
|
19
19
|
unresolved.push({ field: "nativeIdentity", reason: "Qwen Code 0.25.0 identity and permission modes are checked at ACP initialization; preview starts nothing" });
|
|
20
20
|
|