@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.
Files changed (180) hide show
  1. package/CHANGELOG.md +38 -3
  2. package/README.md +3 -3
  3. package/THIRD_PARTY_NOTICES.md +29 -0
  4. package/docs/adding-an-ai-tool.md +228 -0
  5. package/docs/agent-notes/README.md +14 -0
  6. package/docs/agent-notes/adapters.md +61 -42
  7. package/docs/agent-notes/budget.md +2 -2
  8. package/docs/agent-notes/daemon.md +16 -10
  9. package/docs/agent-notes/local-worker.md +4 -3
  10. package/docs/agent-notes/tasks.md +13 -6
  11. package/docs/agent-notes/tests.md +9 -7
  12. package/docs/operations.md +163 -12
  13. package/docs/quickstart.md +10 -2
  14. package/docs/sdlc.md +26 -8
  15. package/docs/security.md +11 -6
  16. package/docs/specs/2026-09-19-agent-hub-design.md +273 -8
  17. package/docs/verification/2026-10-21-0.12.44.md +54 -0
  18. package/docs/verification/2026-10-21-469-r1-dashboard-tokens.md +43 -0
  19. package/docs/verification/2026-10-21-469-r2-dashboard-shell.md +54 -0
  20. package/docs/verification/2026-10-21-469-r3-dashboard-board.md +51 -0
  21. package/docs/verification/2026-10-21-469-r4-dashboard-views.md +77 -0
  22. package/docs/verification/2026-10-21-469-r6-dashboard-motion-states.md +82 -0
  23. package/docs/verification/2026-10-21-grok-config-acp-t0.md +30 -0
  24. package/docs/verification/2026-10-22-0.12.45.md +60 -0
  25. package/docs/verification/assets/469-r1-board-1440-dark.png +0 -0
  26. package/docs/verification/assets/469-r1-board-1440-light.png +0 -0
  27. package/docs/verification/assets/469-r1-board-390-dark.png +0 -0
  28. package/docs/verification/assets/469-r1-board-390-light.png +0 -0
  29. package/docs/verification/assets/469-r2-agents-1440-dark.png +0 -0
  30. package/docs/verification/assets/469-r2-agents-768-dark.png +0 -0
  31. package/docs/verification/assets/469-r2-board-1024-light.png +0 -0
  32. package/docs/verification/assets/469-r2-board-1440-dark.png +0 -0
  33. package/docs/verification/assets/469-r2-board-1440-light.png +0 -0
  34. package/docs/verification/assets/469-r2-board-390-dark.png +0 -0
  35. package/docs/verification/assets/469-r2-board-390-light.png +0 -0
  36. package/docs/verification/assets/469-r2-board-768-light.png +0 -0
  37. package/docs/verification/assets/469-r3-board-1440-dark.png +0 -0
  38. package/docs/verification/assets/469-r3-board-1440-light.png +0 -0
  39. package/docs/verification/assets/469-r3-board-390-light.png +0 -0
  40. package/docs/verification/assets/469-r3-detail-1440-dark.png +0 -0
  41. package/docs/verification/assets/469-r3-detail-1440-light.png +0 -0
  42. package/docs/verification/assets/469-r3-detail-390-dark.png +0 -0
  43. package/docs/verification/assets/469-r3-plan-1440-light.png +0 -0
  44. package/docs/verification/assets/469-r3-plan-390-light.png +0 -0
  45. package/docs/verification/assets/469-r4-agents-1440-dark.png +0 -0
  46. package/docs/verification/assets/469-r4-agents-1440-light.png +0 -0
  47. package/docs/verification/assets/469-r4-agents-390-dark.png +0 -0
  48. package/docs/verification/assets/469-r4-memory-1440-dark.png +0 -0
  49. package/docs/verification/assets/469-r4-memory-1440-light.png +0 -0
  50. package/docs/verification/assets/469-r4-memory-390-dark.png +0 -0
  51. package/docs/verification/assets/469-r4-message-1440-light.png +0 -0
  52. package/docs/verification/assets/469-r4-office-1440-dark.png +0 -0
  53. package/docs/verification/assets/469-r4-office-1440-light.png +0 -0
  54. package/docs/verification/assets/469-r4-settings-1440-dark.png +0 -0
  55. package/docs/verification/assets/469-r4-settings-1440-light.png +0 -0
  56. package/docs/verification/assets/469-r4-stream-1440-light.png +0 -0
  57. package/docs/verification/assets/469-r4-stream-390-light.png +0 -0
  58. package/docs/verification/assets/469-r6-agents-empty-390-dark.png +0 -0
  59. package/docs/verification/assets/469-r6-agents-empty-390-light.png +0 -0
  60. package/docs/verification/assets/469-r6-board-1440-light.png +0 -0
  61. package/docs/verification/assets/469-r6-board-390-dark.png +0 -0
  62. package/docs/verification/assets/469-r6-board-empty-1440-dark.png +0 -0
  63. package/docs/verification/assets/469-r6-board-empty-1440-light.png +0 -0
  64. package/docs/verification/assets/469-r6-disconnected-1440-dark.png +0 -0
  65. package/docs/verification/assets/469-r6-disconnected-1440-light.png +0 -0
  66. package/docs/verification/assets/469-r6-disconnected-390-dark.png +0 -0
  67. package/docs/verification/assets/469-r6-skeleton-1440-light.png +0 -0
  68. package/docs/verification/assets/469-r6-skeleton-390-dark.png +0 -0
  69. package/docs/verification/assets/469-r6-toasts-1440-dark.png +0 -0
  70. package/docs/verification/assets/469-r6-toasts-1440-light.png +0 -0
  71. package/docs/verification/assets/469-r6-toasts-390-light.png +0 -0
  72. package/docs/verified.json +38 -20
  73. package/package.json +5 -1
  74. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  75. package/plugins/agent-hub/server.js +10287 -9560
  76. package/src/adapters/acp-owned-group.ts +124 -0
  77. package/src/adapters/acp.ts +92 -25
  78. package/src/adapters/adapter-api.ts +2 -0
  79. package/src/adapters/agy/definition.ts +4 -4
  80. package/src/adapters/agy/entry.ts +4 -1
  81. package/src/adapters/channel-peer.ts +143 -0
  82. package/src/adapters/claude-channel.ts +21 -12
  83. package/src/adapters/codex-appserver.ts +17 -2
  84. package/src/adapters/configured-acp.ts +124 -0
  85. package/src/adapters/conformance/acp.ts +114 -0
  86. package/src/adapters/conformance/codex.ts +191 -0
  87. package/src/adapters/conformance/enrollment.ts +43 -0
  88. package/src/adapters/conformance/factories.ts +17 -0
  89. package/src/adapters/conformance/fake-paths.ts +12 -0
  90. package/src/adapters/conformance/fakes/acp-server.ts +267 -0
  91. package/src/adapters/conformance/fakes/acp-watchdog-agent.ts +10 -0
  92. package/src/adapters/conformance/fakes/app-server.ts +119 -0
  93. package/src/adapters/conformance/fakes/codex-bin.ts +15 -0
  94. package/src/adapters/conformance/fakes/jcode-agent.ts +91 -0
  95. package/src/adapters/conformance/fakes/model-server.ts +39 -0
  96. package/src/adapters/conformance/fakes/pi-rpc.ts +32 -0
  97. package/src/adapters/conformance/index.ts +11 -0
  98. package/src/adapters/conformance/kit.ts +50 -0
  99. package/src/adapters/conformance/local-pi.ts +100 -0
  100. package/src/adapters/conformance/owned-acp.ts +44 -0
  101. package/src/adapters/definitions/builtin-metadata.ts +60 -39
  102. package/src/adapters/definitions/native-channel-state.ts +41 -0
  103. package/src/adapters/definitions/native-cli-entry-kimi.ts +9 -1
  104. package/src/adapters/definitions/native-cli-entry.ts +7 -19
  105. package/src/adapters/definitions/native-cli-preview-qwen.ts +1 -1
  106. package/src/adapters/definitions/native-cli-preview.ts +2 -11
  107. package/src/adapters/definitions/native-identity-defaults.ts +14 -0
  108. package/src/adapters/definitions/qwen.ts +7 -9
  109. package/src/adapters/definitions/reviewer-variants.ts +3 -7
  110. package/src/adapters/external-metadata.ts +48 -0
  111. package/src/adapters/external-module-scan.ts +126 -0
  112. package/src/adapters/external-modules.ts +240 -0
  113. package/src/adapters/jcode.ts +4 -0
  114. package/src/adapters/local-worker.ts +4 -0
  115. package/src/adapters/pi.ts +6 -0
  116. package/src/adapters/public.ts +7 -0
  117. package/src/adapters/registry-types.ts +28 -16
  118. package/src/adapters/registry.ts +115 -17
  119. package/src/adapters/reviewer-print.ts +4 -0
  120. package/src/cli/agy-mcp.ts +3 -2
  121. package/src/cli/configured-acp-entry.ts +58 -0
  122. package/src/cli/console-state.ts +9 -4
  123. package/src/cli/facts-hook.ts +2 -1
  124. package/src/cli/help.ts +27 -16
  125. package/src/cli/identity.ts +8 -7
  126. package/src/cli/launch.ts +5 -4
  127. package/src/cli/main.ts +101 -32
  128. package/src/cli/memory.ts +13 -10
  129. package/src/cli/output.ts +1 -0
  130. package/src/cli/preview.ts +9 -0
  131. package/src/cli/recovery-guidance.ts +1 -1
  132. package/src/cli/terminal-recovery.ts +16 -13
  133. package/src/cli/upgrade-labels.ts +1 -1
  134. package/src/cli/upgrade-presentation.ts +1 -1
  135. package/src/cli/upgrade-restore-state.ts +6 -4
  136. package/src/cli/upgrade-runtime.ts +25 -7
  137. package/src/cli/upgrade.ts +3 -2
  138. package/src/hub/board.ts +37 -8
  139. package/src/hub/budget.ts +9 -2
  140. package/src/hub/bus.ts +6 -2
  141. package/src/hub/claude-state.ts +2 -37
  142. package/src/hub/conductor.ts +8 -6
  143. package/src/hub/config-apply.ts +37 -17
  144. package/src/hub/config-reload.ts +42 -11
  145. package/src/hub/config-trust.ts +1 -1
  146. package/src/hub/context-window.ts +6 -0
  147. package/src/hub/control-client.ts +4 -1
  148. package/src/hub/crash.ts +8 -6
  149. package/src/hub/daemon.ts +412 -328
  150. package/src/hub/facts.ts +76 -13
  151. package/src/hub/hub-tools.ts +57 -5
  152. package/src/hub/instance-config.ts +33 -12
  153. package/src/hub/instance-execution.ts +23 -11
  154. package/src/hub/instances.ts +47 -22
  155. package/src/hub/lifecycle-schema.ts +5 -3
  156. package/src/hub/memory-profile.ts +16 -3
  157. package/src/hub/no-writes.ts +50 -0
  158. package/src/hub/peers.ts +9 -0
  159. package/src/hub/permission-mode.ts +9 -7
  160. package/src/hub/plan-conformance.ts +83 -0
  161. package/src/hub/review-findings.ts +104 -0
  162. package/src/hub/roster.ts +35 -6
  163. package/src/hub/routing.ts +21 -20
  164. package/src/hub/sdlc-queue.ts +19 -4
  165. package/src/hub/sdlc-release.ts +16 -1
  166. package/src/hub/sdlc.ts +27 -12
  167. package/src/hub/settings.ts +54 -16
  168. package/src/hub/snapshots.ts +32 -6
  169. package/src/hub/start-mode.ts +10 -8
  170. package/src/hub/task-sweep.ts +8 -2
  171. package/src/hub/tasks.ts +358 -60
  172. package/src/hub/ui.ts +2 -2
  173. package/src/memory/capture.ts +2 -1
  174. package/src/ui/index.html +563 -77
  175. package/src/ui/office-model.ts +3 -1
  176. package/src/ui/peer-palette.ts +44 -0
  177. package/templates/AGENTS.block.md +1 -1
  178. package/templates/profiles/general.json +1 -1
  179. package/templates/profiles/research.json +1 -1
  180. 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 definitions that still declare them and the frozen A1 compatibility export.
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", ownsTerminal: headless, launch: ctx => compatibilityLaunch("claude", ctx), conductorStartable: true, hubSwitches: [], ownedFlags: [] },
66
- identity: { mcp: "env" },
67
- permission: { modes: {}, unmanaged: "each review runs read-only (Read, Grep, Glob) and may call only hub_review and hub_task_show" },
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", ownsTerminal: terminal, launch: claudeNativeLaunch, version: claudeObservedVersion, ...CLAUDE_LAUNCH_POLICY, conductorStartable: true,
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
- // Existing daemon permission hooks enforce these grants; an empty native mode map is neither unmanaged nor a refusal.
83
- permission: { modes: {}, grants: { ask: "Claude's own permission rules decide",
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", ownsTerminal: terminal, launch: codexNativeLaunch, version: codexObservedVersion, ...CODEX_LAUNCH_POLICY, conductorStartable: true,
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, modes: { ask: { kind: "native-only", note: "restore the session's captured native approval policy" },
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", ownsTerminal: terminal, launch: ctx => compatibilityLaunch("kimi", ctx), conductorStartable: true, hubSwitches: [], ownedFlags: [] },
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: { modes: { ask: { kind: "acp-mode", modeId: "default" }, "ask-when-needed": { kind: "acp-mode", modeId: "yolo" }, "never-ask": { kind: "acp-mode", modeId: "auto" } },
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 }, init: { mcpFile: ".kimi-code/mcp.json" }, hooks: { recallPlatform: "kimi", recallSource: "kimi" },
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", ownsTerminal: terminal, launch: ctx => compatibilityLaunch("pi", ctx), conductorStartable: true, autoStart: "pi.auto_start", conductorStartArgs: piConductorStartArgs, hubSwitches: [], ownedFlags: [] },
125
- identity: { mcp: "env" },
126
- // Existing daemon permission hooks enforce these grants; an empty native mode map is neither unmanaged nor a refusal.
127
- permission: { modes: {}, grants: { ...hubGrants } },
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", ownsTerminal: headless, launch: ctx => compatibilityLaunch("local", ctx), conductorStartable: true, hubSwitches: [], ownedFlags: [] },
137
- identity: { mcp: "env" },
138
- // Existing daemon permission hooks enforce these grants; an empty native mode map is neither unmanaged nor a refusal.
139
- permission: { modes: {}, grants: { ...hubGrants } },
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", ownsTerminal: terminal, explicitHeadlessMode: true, launch: ctx => compatibilityLaunch("jcode", ctx, true), conductorStartable: true, hubSwitches: [], ownedFlags: [] },
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: { modes: {}, unmanaged: "Jcode native policy is unmanaged; no hub mode mapping", grants: {
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 AdapterDefinition[] = Object.freeze([
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 AdapterDefinition[]);
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
- const launch = buildKimiTuiLaunch(Array.isArray(runningCommand) && runningCommand.every((arg: unknown) => typeof arg === "string") ? runningCommand : projectConfig().kimi_cmd, model);
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 { runAgyEntry } from "../agy/entry.ts";
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
- // Fixed trusted builtins only. No project configuration, caller payload or external module can register an entry.
12
- export const builtinCliEntries: readonly BuiltinCliEntry[] = Object.freeze([
13
- { command: "agy", lifecycleAdmission: true, execute: runAgyEntry },
14
- { command: "claude", lifecycleAdmission: true, execute: runClaudeEntry },
15
- { command: "codex", lifecycleAdmission: true, execute: runCodexEntry },
16
- { command: "jcode", lifecycleAdmission: true, execute: runJcodeEntry },
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