@staix/agent-hub 0.12.44 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (197) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +83 -116
  3. package/THIRD_PARTY_NOTICES.md +29 -0
  4. package/docs/adding-an-ai-tool.md +102 -14
  5. package/docs/agent-notes/README.md +15 -0
  6. package/docs/agent-notes/adapters.md +63 -46
  7. package/docs/agent-notes/budget.md +7 -2
  8. package/docs/agent-notes/bus.md +6 -4
  9. package/docs/agent-notes/daemon.md +15 -8
  10. package/docs/agent-notes/local-worker.md +6 -4
  11. package/docs/agent-notes/tasks.md +18 -5
  12. package/docs/agent-notes/tests.md +11 -9
  13. package/docs/dogfood.md +92 -0
  14. package/docs/events.md +14 -1
  15. package/docs/operations.md +349 -26
  16. package/docs/profiles.md +2 -2
  17. package/docs/quickstart.md +12 -2
  18. package/docs/sdlc.md +61 -13
  19. package/docs/security.md +24 -12
  20. package/docs/smoke.md +9 -4
  21. package/docs/specs/2026-09-19-agent-hub-design.md +873 -13
  22. package/docs/verification/2026-10-19-reviewer-isolation.md +2 -0
  23. package/docs/verification/2026-10-21-469-r1-dashboard-tokens.md +43 -0
  24. package/docs/verification/2026-10-21-469-r2-dashboard-shell.md +54 -0
  25. package/docs/verification/2026-10-21-469-r3-dashboard-board.md +51 -0
  26. package/docs/verification/2026-10-21-469-r4-dashboard-views.md +77 -0
  27. package/docs/verification/2026-10-21-469-r6-dashboard-motion-states.md +82 -0
  28. package/docs/verification/2026-10-22-0.12.45.md +60 -0
  29. package/docs/verification/2026-10-22-code-facts.md +78 -0
  30. package/docs/verification/2026-10-22-codex-reviewer-isolation.md +47 -0
  31. package/docs/verification/2026-10-22-kimi-qwen-compat.md +79 -0
  32. package/docs/verification/2026-10-23-0.13.0.md +89 -0
  33. package/docs/verification/assets/469-r1-board-1440-dark.png +0 -0
  34. package/docs/verification/assets/469-r1-board-1440-light.png +0 -0
  35. package/docs/verification/assets/469-r1-board-390-dark.png +0 -0
  36. package/docs/verification/assets/469-r1-board-390-light.png +0 -0
  37. package/docs/verification/assets/469-r2-agents-1440-dark.png +0 -0
  38. package/docs/verification/assets/469-r2-agents-768-dark.png +0 -0
  39. package/docs/verification/assets/469-r2-board-1024-light.png +0 -0
  40. package/docs/verification/assets/469-r2-board-1440-dark.png +0 -0
  41. package/docs/verification/assets/469-r2-board-1440-light.png +0 -0
  42. package/docs/verification/assets/469-r2-board-390-dark.png +0 -0
  43. package/docs/verification/assets/469-r2-board-390-light.png +0 -0
  44. package/docs/verification/assets/469-r2-board-768-light.png +0 -0
  45. package/docs/verification/assets/469-r3-board-1440-dark.png +0 -0
  46. package/docs/verification/assets/469-r3-board-1440-light.png +0 -0
  47. package/docs/verification/assets/469-r3-board-390-light.png +0 -0
  48. package/docs/verification/assets/469-r3-detail-1440-dark.png +0 -0
  49. package/docs/verification/assets/469-r3-detail-1440-light.png +0 -0
  50. package/docs/verification/assets/469-r3-detail-390-dark.png +0 -0
  51. package/docs/verification/assets/469-r3-plan-1440-light.png +0 -0
  52. package/docs/verification/assets/469-r3-plan-390-light.png +0 -0
  53. package/docs/verification/assets/469-r4-agents-1440-dark.png +0 -0
  54. package/docs/verification/assets/469-r4-agents-1440-light.png +0 -0
  55. package/docs/verification/assets/469-r4-agents-390-dark.png +0 -0
  56. package/docs/verification/assets/469-r4-memory-1440-dark.png +0 -0
  57. package/docs/verification/assets/469-r4-memory-1440-light.png +0 -0
  58. package/docs/verification/assets/469-r4-memory-390-dark.png +0 -0
  59. package/docs/verification/assets/469-r4-message-1440-light.png +0 -0
  60. package/docs/verification/assets/469-r4-office-1440-dark.png +0 -0
  61. package/docs/verification/assets/469-r4-office-1440-light.png +0 -0
  62. package/docs/verification/assets/469-r4-settings-1440-dark.png +0 -0
  63. package/docs/verification/assets/469-r4-settings-1440-light.png +0 -0
  64. package/docs/verification/assets/469-r4-stream-1440-light.png +0 -0
  65. package/docs/verification/assets/469-r4-stream-390-light.png +0 -0
  66. package/docs/verification/assets/469-r6-agents-empty-390-dark.png +0 -0
  67. package/docs/verification/assets/469-r6-agents-empty-390-light.png +0 -0
  68. package/docs/verification/assets/469-r6-board-1440-light.png +0 -0
  69. package/docs/verification/assets/469-r6-board-390-dark.png +0 -0
  70. package/docs/verification/assets/469-r6-board-empty-1440-dark.png +0 -0
  71. package/docs/verification/assets/469-r6-board-empty-1440-light.png +0 -0
  72. package/docs/verification/assets/469-r6-disconnected-1440-dark.png +0 -0
  73. package/docs/verification/assets/469-r6-disconnected-1440-light.png +0 -0
  74. package/docs/verification/assets/469-r6-disconnected-390-dark.png +0 -0
  75. package/docs/verification/assets/469-r6-skeleton-1440-light.png +0 -0
  76. package/docs/verification/assets/469-r6-skeleton-390-dark.png +0 -0
  77. package/docs/verification/assets/469-r6-toasts-1440-dark.png +0 -0
  78. package/docs/verification/assets/469-r6-toasts-1440-light.png +0 -0
  79. package/docs/verification/assets/469-r6-toasts-390-light.png +0 -0
  80. package/docs/verification/dogfood/2026-10-22.md +3109 -0
  81. package/docs/verified.json +19 -16
  82. package/package.json +5 -1
  83. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  84. package/plugins/agent-hub/server.js +10120 -9623
  85. package/src/adapters/adapter-api.ts +2 -0
  86. package/src/adapters/agy/definition.ts +3 -1
  87. package/src/adapters/agy/entry.ts +5 -4
  88. package/src/adapters/claude-channel.ts +32 -12
  89. package/src/adapters/codex-appserver.ts +19 -2
  90. package/src/adapters/codex-review-isolation.ts +12 -5
  91. package/src/adapters/compat.ts +140 -0
  92. package/src/adapters/conformance/acp.ts +114 -0
  93. package/src/adapters/conformance/codex.ts +191 -0
  94. package/src/adapters/conformance/enrollment.ts +43 -0
  95. package/src/adapters/conformance/factories.ts +17 -0
  96. package/src/adapters/conformance/fake-paths.ts +12 -0
  97. package/src/adapters/conformance/fakes/acp-server.ts +267 -0
  98. package/src/adapters/conformance/fakes/acp-watchdog-agent.ts +10 -0
  99. package/src/adapters/conformance/fakes/app-server.ts +119 -0
  100. package/src/adapters/conformance/fakes/codex-bin.ts +15 -0
  101. package/src/adapters/conformance/fakes/jcode-agent.ts +91 -0
  102. package/src/adapters/conformance/fakes/model-server.ts +39 -0
  103. package/src/adapters/conformance/fakes/pi-rpc.ts +32 -0
  104. package/src/adapters/conformance/index.ts +11 -0
  105. package/src/adapters/conformance/kit.ts +50 -0
  106. package/src/adapters/conformance/local-pi.ts +100 -0
  107. package/src/adapters/conformance/owned-acp.ts +44 -0
  108. package/src/adapters/definitions/builtin-metadata.ts +25 -7
  109. package/src/adapters/definitions/native-cli-doctor.ts +12 -5
  110. package/src/adapters/definitions/native-cli-entry-jcode.ts +9 -6
  111. package/src/adapters/definitions/native-cli-entry-kimi.ts +7 -1
  112. package/src/adapters/definitions/native-cli-entry.ts +7 -19
  113. package/src/adapters/definitions/native-cli-jcode-mcp.ts +9 -2
  114. package/src/adapters/definitions/native-cli-preview-common.ts +2 -0
  115. package/src/adapters/definitions/native-cli-preview-jcode.ts +8 -2
  116. package/src/adapters/definitions/native-cli-preview-qwen.ts +2 -1
  117. package/src/adapters/definitions/native-cli-preview.ts +2 -11
  118. package/src/adapters/definitions/qwen-native.ts +7 -3
  119. package/src/adapters/definitions/qwen.ts +5 -1
  120. package/src/adapters/external-metadata.ts +48 -0
  121. package/src/adapters/external-module-scan.ts +126 -0
  122. package/src/adapters/external-modules.ts +240 -0
  123. package/src/adapters/jcode.ts +72 -31
  124. package/src/adapters/kimi-review.ts +6 -3
  125. package/src/adapters/pi.ts +2 -0
  126. package/src/adapters/public.ts +7 -0
  127. package/src/adapters/registry-types.ts +13 -1
  128. package/src/adapters/registry.ts +52 -15
  129. package/src/cli/agents.ts +40 -0
  130. package/src/cli/console-review.ts +54 -0
  131. package/src/cli/console-state.ts +124 -29
  132. package/src/cli/console-symbols.ts +74 -0
  133. package/src/cli/console-usage.ts +89 -0
  134. package/src/cli/console.ts +26 -1
  135. package/src/cli/help.ts +36 -21
  136. package/src/cli/identity.ts +30 -7
  137. package/src/cli/init.ts +109 -11
  138. package/src/cli/main.ts +145 -16
  139. package/src/cli/output.ts +44 -1
  140. package/src/cli/preview.ts +1 -1
  141. package/src/cli/sdlc-release-guard.ts +82 -0
  142. package/src/cli/upgrade-labels.ts +1 -1
  143. package/src/cli/upgrade-presentation.ts +1 -1
  144. package/src/cli/upgrade-restore-state.ts +3 -2
  145. package/src/cli/upgrade-runtime.ts +4 -1
  146. package/src/hub/action-class.ts +362 -0
  147. package/src/hub/action-policy.ts +106 -0
  148. package/src/hub/board.ts +72 -11
  149. package/src/hub/budget.ts +71 -2
  150. package/src/hub/bus.ts +43 -3
  151. package/src/hub/checks.ts +80 -31
  152. package/src/hub/code-facts.ts +223 -0
  153. package/src/hub/config-apply.ts +28 -11
  154. package/src/hub/config-reload.ts +8 -5
  155. package/src/hub/config-trust.ts +7 -4
  156. package/src/hub/control-client.ts +4 -1
  157. package/src/hub/daemon.ts +332 -61
  158. package/src/hub/dogfood-read.ts +199 -0
  159. package/src/hub/dogfood.ts +163 -0
  160. package/src/hub/envelope.ts +1 -1
  161. package/src/hub/events.ts +11 -2
  162. package/src/hub/facts.ts +76 -13
  163. package/src/hub/hub-tools.ts +45 -7
  164. package/src/hub/inbox-polling.ts +25 -0
  165. package/src/hub/instance-config.ts +17 -5
  166. package/src/hub/instance-execution.ts +20 -8
  167. package/src/hub/instances.ts +75 -2
  168. package/src/hub/peers.ts +9 -0
  169. package/src/hub/plan-conformance.ts +83 -0
  170. package/src/hub/profiles.ts +7 -2
  171. package/src/hub/release-worktree.ts +56 -0
  172. package/src/hub/review-brief.ts +36 -0
  173. package/src/hub/review-findings.ts +104 -0
  174. package/src/hub/roster.ts +31 -5
  175. package/src/hub/sdlc-queue.ts +48 -11
  176. package/src/hub/sdlc-release-authorization.ts +173 -0
  177. package/src/hub/sdlc-release.ts +16 -1
  178. package/src/hub/sdlc.ts +71 -18
  179. package/src/hub/settings.ts +10 -3
  180. package/src/hub/snapshots.ts +101 -14
  181. package/src/hub/symbol-normalizer.ts +26 -0
  182. package/src/hub/task-code-symbols.ts +90 -0
  183. package/src/hub/task-sweep.ts +3 -2
  184. package/src/hub/tasks.ts +556 -88
  185. package/src/hub/test-integrity.ts +162 -0
  186. package/src/hub/ui.ts +2 -2
  187. package/src/hub/usage-rollup.ts +361 -0
  188. package/src/local/sandbox.ts +5 -3
  189. package/src/ui/index.html +560 -78
  190. package/src/ui/office-model.ts +3 -1
  191. package/src/ui/peer-palette.ts +44 -0
  192. package/templates/AGENTS.block.md +1 -1
  193. package/templates/profiles/dev.json +6 -1
  194. package/templates/profiles/general.json +6 -1
  195. package/templates/profiles/research.json +6 -1
  196. package/templates/profiles/writing.json +6 -1
  197. package/templates/release-guard.block.md +6 -0
@@ -0,0 +1,119 @@
1
+ // Fake `codex app-server` (v2 shapes from codex-cli 0.154.0 generate-json-schema). One thread, echo turns.
2
+ export function startFakeAppServer(
3
+ delayMs = 30,
4
+ port = 0,
5
+ onRevert?: (params: { threadId: string; beforeTurnId: string }) => void,
6
+ usedPercent = 93,
7
+ reportApprovalPolicy = true,
8
+ onRequest?: (msg: any) => void,
9
+ ) {
10
+ let turnSeq = 0;
11
+ let threadTotal = 0; // the thread's running token total, as Codex keeps it
12
+ const usage = (n: number) => ({ totalTokens: n, inputTokens: n - 10, outputTokens: 10, cachedInputTokens: 0, reasoningOutputTokens: 0 });
13
+ let active = false;
14
+ let activeTurnId = "";
15
+ let steered: string[] = [];
16
+ const requests: any[] = [];
17
+ const reverted: { threadId: string; beforeTurnId: string }[] = [];
18
+ const server = Bun.serve({
19
+ hostname: "127.0.0.1",
20
+ port,
21
+ fetch(req, srv) {
22
+ if (new URL(req.url).pathname === "/healthz") return new Response("ok");
23
+ return srv.upgrade(req) ? undefined : new Response("no", { status: 400 });
24
+ },
25
+ websocket: {
26
+ async message(ws, data) {
27
+ const msg = JSON.parse(String(data));
28
+ requests.push(msg); onRequest?.(msg);
29
+ const reply = (result: unknown) => void ws.send(JSON.stringify({ id: msg.id, result }));
30
+ const note = (method: string, params: unknown) => void ws.send(JSON.stringify({ method, params }));
31
+ if (msg.method === "initialize") return reply({ userAgent: "fake-codex/0.154.0" });
32
+ if (msg.method === "thread/start") return (threadTotal = 0), reply({ thread: { id: "th1" }, model: "fake", ...(reportApprovalPolicy ? { approvalPolicy: msg.params.approvalPolicy ?? "untrusted" } : {}) });
33
+ if (msg.method === "thread/resume") {
34
+ // A thread with 5000 tokens of history; Codex 0.156 replays its saved usage to the connection that attaches.
35
+ threadTotal = 5000;
36
+ reply({ thread: { id: msg.params.threadId }, model: "fake", ...(reportApprovalPolicy ? { approvalPolicy: msg.params.approvalPolicy ?? "untrusted" } : {}) });
37
+ return note("thread/tokenUsage/updated", { threadId: msg.params.threadId, turnId: "old", tokenUsage: { total: usage(threadTotal), last: usage(800) } });
38
+ }
39
+ if (msg.method === "account/rateLimits/read") return reply({ rateLimits: { primary: { usedPercent, windowDurationMins: 300, resetsAt: 1_900_000_000 }, secondary: null } });
40
+ if (msg.method === "thread/revert") {
41
+ reverted.push(msg.params);
42
+ onRevert?.(msg.params);
43
+ reply({ thread: { id: msg.params.threadId, turns: [] }, itemsBackwardsCursor: null, turnsBackwardsCursor: null });
44
+ return note("thread/reverted", { threadId: msg.params.threadId });
45
+ }
46
+ if (msg.method === "turn/steer") {
47
+ if (!active || msg.params.expectedTurnId !== activeTurnId) {
48
+ return void ws.send(JSON.stringify({ id: msg.id, error: { code: -32000, message: "no active turn to steer" } }));
49
+ }
50
+ if (msg.params.input[0].text.includes("SILENT")) return; // app-server that never answers the steer
51
+ steered.push(msg.params.input[0].text.split("\n").at(-1));
52
+ reply({ turnId: activeTurnId });
53
+ // As app-server 0.159 does: the steered input becomes a user message item of the running turn.
54
+ return note("item/completed", { threadId: msg.params.threadId, turnId: activeTurnId, completedAtMs: Date.now(), item: { type: "userMessage", id: `u${steered.length}`, content: msg.params.input } });
55
+ }
56
+ if (msg.method !== "turn/start") return;
57
+ if (msg.params.input[0].text.includes("REFUSE_TURN")) return void ws.send(JSON.stringify({ id: msg.id, error: { code: -32000, message: "turn rejected" } }));
58
+ if (active) return void ws.send(JSON.stringify({ id: msg.id, error: { code: -32000, message: "turn in progress" } }));
59
+ active = true;
60
+ const turn = { id: `turn${++turnSeq}`, items: [], status: "inProgress" };
61
+ activeTurnId = turn.id;
62
+ steered = [];
63
+ const threadId = msg.params.threadId;
64
+ const text: string = msg.params.input[0].text.split("\n").at(-1);
65
+ if (text.includes("QUOTA")) {
66
+ // what a limited account does: refuse the turn
67
+ reply({ turn });
68
+ note("turn/started", { threadId, turn });
69
+ note("account/rateLimits/updated", { rateLimits: { primary: null, secondary: null, rateLimitReachedType: "usageLimitExceeded" } });
70
+ note("error", { error: { message: "You've hit your usage limit.", codexErrorInfo: "usageLimitExceeded" }, willRetry: false, threadId, turnId: turn.id });
71
+ active = false;
72
+ return note("turn/completed", { threadId, turn: { ...turn, status: "failed", error: { message: "usage limit" } } });
73
+ }
74
+ reply({ turn });
75
+ note("turn/started", { threadId, turn });
76
+ await Bun.sleep(delayMs);
77
+ const slow = /SLOW:(\d+)/.exec(text); // a turn that keeps running for this many ms
78
+ if (slow) await Bun.sleep(Number(slow[1]));
79
+ if (text.includes("EDIT:")) {
80
+ // `GATE:<file>` holds the edit until the test has created that file (#634): the hub captures the turn's start
81
+ // tree synchronously on `turn/started`, and a slow git there must finish before the edit lands, or the start
82
+ // tree already contains it and the turn reads as having changed nothing. A gate that never opens skips the
83
+ // edit altogether, so the test fails on its missing change instead of passing on a late, unsynchronized edit.
84
+ const gate = /GATE:(\S+)/.exec(text)?.[1];
85
+ if (gate) for (let attempt = 0; attempt < 1500 && !(await Bun.file(gate).exists()); attempt++) await Bun.sleep(10);
86
+ if (!gate || await Bun.file(gate).exists()) {
87
+ // A real patch (issue #108): one line appended to the file, reported with a diff that matches it.
88
+ const path = /EDIT:(\S+)/.exec(text)![1]!;
89
+ const before = await Bun.file(path).text();
90
+ await Bun.write(path, `${before}codex line\n`);
91
+ const n = before.split("\n").length - 1;
92
+ note("item/completed", { threadId, turnId: turn.id, completedAtMs: Date.now(), item: { type: "fileChange", id: "f2", status: "completed", changes: [{ path, kind: { type: "update", move_path: null }, diff: `@@ -${n} +${n},2 @@\n+codex line` }] } });
93
+ }
94
+ await Bun.sleep(Math.max(delayMs, 250)); // room for a steer from the item handler, on a slow CI runner too
95
+ }
96
+ if (text.includes("ITEMS")) {
97
+ // Tool items as Codex 0.159 reports them (issue #108): a patch, then a read the CLI parsed into an action.
98
+ // `ITEMS:<path>` names the file both items are about; the patch itself is up to the test.
99
+ const path = /ITEMS:(\S+)/.exec(text)?.[1];
100
+ note("item/completed", { threadId, turnId: turn.id, completedAtMs: Date.now(), item: { type: "fileChange", id: "f1", status: "completed", changes: [{ path: path ?? "/abs/src/a.ts", kind: { type: "update", move_path: null }, diff: "@@ -1 +1 @@\n-a\n+b" }] } });
101
+ note("item/completed", { threadId, turnId: turn.id, completedAtMs: Date.now(), item: { type: "commandExecution", id: "c1", status: "completed", command: "sed -n 1,5p src/b.ts", commandActions: [{ type: "read", command: "sed -n 1,5p src/b.ts", name: "b.ts", path: path ?? "/abs/src/b.ts" }] } });
102
+ await Bun.sleep(Math.max(delayMs, 250)); // room for a steer from the item handler, on a slow CI runner too
103
+ }
104
+ const item = (id: string, phase: string, t: string) =>
105
+ note("item/completed", { threadId, turnId: turn.id, completedAtMs: Date.now(), item: { type: "agentMessage", id, phase, text: t } });
106
+ item("m1", "commentary", "thinking out loud");
107
+ item("m2", "final_answer", `echo: ${text}${steered.map((s) => ` +steered: ${s}`).join("")}`);
108
+ // As Codex 0.156 reports it: the thread's running total and what this update added, 100 tokens per turn.
109
+ threadTotal += 100;
110
+ note("thread/tokenUsage/updated", { threadId, turnId: turn.id, tokenUsage: { total: usage(threadTotal), last: usage(100) } });
111
+ // Compaction: an estimate of the retained history in `last`, the total unchanged.
112
+ if (text.includes("COMPACT")) note("thread/tokenUsage/updated", { threadId, turnId: turn.id, tokenUsage: { total: usage(threadTotal), last: usage(4000) } });
113
+ active = false;
114
+ note("turn/completed", { threadId, turn: { ...turn, status: "completed" } });
115
+ },
116
+ },
117
+ });
118
+ return { url: `ws://127.0.0.1:${server.port}`, stop: () => server.stop(true), reverted, requests };
119
+ }
@@ -0,0 +1,15 @@
1
+ // Stands in for the codex binary in daemon tests: `codex app-server --listen ws://127.0.0.1:<port> ...` runs the fake
2
+ // app-server on that port. A test's wrapper script passes `--record <file>`, where each thread/revert is appended.
3
+ import { appendFileSync } from "node:fs";
4
+ import { startFakeAppServer } from "./app-server.ts";
5
+
6
+ const arg = (name: string) => {
7
+ const i = process.argv.indexOf(name);
8
+ return i >= 0 ? process.argv[i + 1] : undefined;
9
+ };
10
+ const record = arg("--record");
11
+ // Daemon integration tests exercise pause behavior separately; start this fake below the default quota gate.
12
+ startFakeAppServer(30, Number(new URL(arg("--listen") ?? "ws://127.0.0.1:0").port), (params) => {
13
+ if (record) appendFileSync(record, `${JSON.stringify(params)}\n`);
14
+ }, 50);
15
+ await new Promise(() => {}); // until the hub stops it
@@ -0,0 +1,91 @@
1
+ import { createServer } from "node:net";
2
+ import { basename, dirname, join } from "node:path";
3
+ import { writeSync, appendFileSync, mkdirSync, writeFileSync, existsSync, rmSync } from "node:fs";
4
+ import { createInterface } from "node:readline";
5
+ const args = process.argv.slice(2), value = (key: string) => args[args.indexOf(key) + 1];
6
+ const record = args.includes("--record") ? value("--record") : undefined;
7
+ if (args.includes("version")) {
8
+ if (record) appendFileSync(record, JSON.stringify({ kind: "version", args, telemetry: process.env.JCODE_NO_TELEMETRY }) + "\n");
9
+ console.log(JSON.stringify({ semver: args.includes("--semver") ? value("--semver") : "0.98.1" }));
10
+ } else if (args.includes("serve")) {
11
+ // Native jcode (0.94.0 through 0.98.1, socket.rs debug_socket_path) also binds a debug socket named by replacing
12
+ // ".sock" with "-debug.sock": a name without ".sock" makes both binds the same path, and serve exits on EADDRINUSE.
13
+ const main = value("--socket") ?? "", debugPath = join(dirname(main), basename(main).replace(".sock", "-debug.sock"));
14
+ const debug = createServer(socket => socket.end());
15
+ // #572: the native server socket speaks one JSON object per line. Only what the hub's steer uses is modelled, as observed
16
+ // on 0.99.0: ping (capabilities), subscribe (session), state (is_processing) and soft_interrupt (ack, then injected).
17
+ // `--steer-file F`: the ACP process holds its prompt until F exists and marks the turn busy with F.busy.
18
+ const steerFile = args.includes("--steer-file") ? value("--steer-file")! : undefined;
19
+ const server = createServer(socket => {
20
+ let buffer = "";
21
+ socket.on("error", () => {});
22
+ socket.on("data", chunk => {
23
+ buffer += chunk.toString();
24
+ const lines = buffer.split("\n"); buffer = lines.pop()!;
25
+ for (const line of lines) {
26
+ let msg: any; try { msg = JSON.parse(line); } catch { continue; }
27
+ const reply = (value: unknown) => socket.write(JSON.stringify(value) + "\n");
28
+ if (msg.type === "ping") reply({ type: "pong", id: msg.id, capabilities: args.includes("--no-soft-interrupt") ? ["session_tools"] : ["session_tools", "soft_interrupt_ids"] });
29
+ else if (msg.type === "subscribe") { reply({ type: "ack", id: msg.id }); reply({ type: "session", session_id: args.includes("--wrong-session") ? "other-session" : msg.target_session_id ?? "jcode-s1" }); reply({ type: "done", id: msg.id }); }
30
+ else if (msg.type === "state") {
31
+ // `--state-reply-gate F`: the reply is held until F exists, so a test can change the turn while the hub waits (no timing).
32
+ const respond = () => reply({ type: "state", id: msg.id, session_id: msg.session_id ?? "jcode-s1", is_processing: !!steerFile && existsSync(`${steerFile}.busy`), pending_soft_interrupts: 0 });
33
+ if (args.includes("--state-reply-gate")) {
34
+ const stateGate = value("--state-reply-gate")!;
35
+ writeFileSync(`${stateGate}.waiting`, "held");
36
+ const timer = setInterval(() => { if (existsSync(stateGate)) { clearInterval(timer); respond(); } }, 5);
37
+ socket.once("close", () => clearInterval(timer));
38
+ } else respond();
39
+ }
40
+ else if (msg.type === "soft_interrupt") {
41
+ if (record) appendFileSync(record, JSON.stringify({ kind: "soft_interrupt", content: msg.content, client_id: msg.client_id, urgent: msg.urgent }) + "\n");
42
+ if (args.includes("--no-ack")) continue;
43
+ // `--ack-delay-ms N`: the native server takes the message (the running turn is released at once) but answers late.
44
+ const acknowledge = () => { reply({ type: "ack", id: msg.id }); reply({ type: "ack", id: msg.id }); };
45
+ if (steerFile) writeFileSync(steerFile, "steered");
46
+ if (args.includes("--ack-delay-ms")) setTimeout(acknowledge, Number(value("--ack-delay-ms"))); else acknowledge();
47
+ }
48
+ }
49
+ });
50
+ });
51
+ debug.once("error", () => process.exit(1));
52
+ server.listen(main, () => debug.listen(debugPath, () => {
53
+ if (record) appendFileSync(record, JSON.stringify({ kind: "serve", pid: process.pid, socket: value("--socket"), home: process.env.JCODE_HOME, telemetry: process.env.JCODE_NO_TELEMETRY }) + "\n");
54
+ writeSync(3, "1");
55
+ }));
56
+ process.on("SIGTERM", () => {
57
+ if (record) appendFileSync(record, JSON.stringify({ kind: "term" }) + "\n");
58
+ debug.close();
59
+ server.close(() => setTimeout(() => process.exit(0), args.includes("--exit-delay-ms") ? Number(value("--exit-delay-ms")) : 0));
60
+ });
61
+ } else if (args.includes("--resume") && !args.includes("acp")) {
62
+ if (record) appendFileSync(record, JSON.stringify({ kind: "tui", args, peer: process.env.AGENTHUB_PEER_ID, home: process.env.JCODE_HOME, socket: process.env.JCODE_SOCKET, telemetry: process.env.JCODE_NO_TELEMETRY }) + "\n");
63
+ } else {
64
+ let session = "jcode-s1";
65
+ const send = (message: unknown) => process.stdout.write(JSON.stringify(message) + "\n");
66
+ createInterface({ input: process.stdin }).on("line", line => {
67
+ const msg = JSON.parse(line);
68
+ if (record) appendFileSync(record, JSON.stringify({ kind: "rpc", method: msg.method, params: msg.params, home: process.env.JCODE_HOME, telemetry: process.env.JCODE_NO_TELEMETRY }) + "\n");
69
+ if (msg.method === "initialize") send({ jsonrpc: "2.0", id: msg.id, result: { protocolVersion: 1, agentInfo: { name: "jcode", version: args.includes("--version-id") ? value("--version-id") : "0.98.1" }, agentCapabilities: { loadSession: true } } });
70
+ else if (msg.method === "session/new") send({ jsonrpc: "2.0", id: msg.id, result: { sessionId: session } });
71
+ else if (msg.method === "session/load") { session = msg.params.sessionId; send({ jsonrpc: "2.0", id: msg.id, result: args.includes("--no-load-echo") ? {} : { sessionId: session } }); }
72
+ else if (msg.method === "session/prompt") {
73
+ if (args.includes("--save-native-session")) {
74
+ const directory = join(process.env.JCODE_HOME!, "sessions"); mkdirSync(directory, { recursive: true });
75
+ writeFileSync(join(directory, `${session}.json`), JSON.stringify({ id: session, messages: [{ role: "assistant", content: "JCODE_REPLY" }] }));
76
+ }
77
+ const finish = () => {
78
+ send({ jsonrpc: "2.0", method: "session/update", params: { sessionId: session, update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "JCODE_REPLY" } } } });
79
+ send({ jsonrpc: "2.0", id: msg.id, result: { stopReason: args.includes("--max-tokens-stop") ? "max_tokens" : "end_turn" } });
80
+ if (args.includes("--exit-after-prompt")) setTimeout(() => process.exit(0), 30);
81
+ };
82
+ const steerFile = args.includes("--steer-file") ? value("--steer-file")! : undefined;
83
+ if (steerFile) {
84
+ // A turn that stays busy until the hub's steer reaches the native server (the server writes the file).
85
+ writeFileSync(`${steerFile}.busy`, "1");
86
+ const timer = setInterval(() => { if (existsSync(steerFile)) { clearInterval(timer); rmSync(`${steerFile}.busy`, { force: true }); finish(); } }, 20);
87
+ } else finish();
88
+ } else if (msg.method === "session/cancel") send({ jsonrpc: "2.0", id: msg.id, result: {} });
89
+ else send({ jsonrpc: "2.0", id: msg.id, error: { code: -32601, message: "no native mode mapping" } });
90
+ });
91
+ }
@@ -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
+ }