@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,2 @@
1
+ /** Version of the public, person-authorized adapter definition contract. */
2
+ export const ADAPTER_API = 1;
@@ -1,3 +1,4 @@
1
+ import { runAgyEntry } from "./entry.ts";
1
2
  import type { AdapterDefinition, LaunchContext, LaunchPlan } from "../registry-types.ts";
2
3
  import { AGY_REVIEWER_VARIANT } from "../definitions/reviewer-variants.ts";
3
4
  import { AGY_FACTS_HOOK } from "./hooks.ts";
@@ -15,6 +16,7 @@ function agyLaunch(ctx: LaunchContext): LaunchPlan {
15
16
  export const AGY_DEFINITION: AdapterDefinition = {
16
17
  api: 1,
17
18
  kind: "agy",
19
+ projection: { roleRank: 7, helpRank: 4, plannerEligible: false },
18
20
  displayName: "Antigravity",
19
21
  family: "agy",
20
22
  transport: "ws-pull",
@@ -50,7 +52,7 @@ export const AGY_DEFINITION: AdapterDefinition = {
50
52
  recoveryRefusal: () => "Antigravity session continuity and resume are unavailable until native behavior is verified",
51
53
  },
52
54
  turnEnd: "native-hook",
53
- cli: { command: "agy", authority: "person", startWords: () => ["agy"] },
55
+ cli: { command: "agy", entry: { lifecycleAdmission: true, execute: runAgyEntry }, usage: [["ahub agy [--print-command | --dry-run] [args...]", "optional Antigravity CLI 1.4.x TUI (1.4.2 verified) in this terminal (roster.agy, default off): pull-only via hub_inbox, implementer/verifier only; native permissions unmanaged, no hub sandbox"]], authority: "person", startWords: () => ["agy"] },
54
56
  display: DISPLAY,
55
57
  routing: { allowedRoles: ["implementer", "verifier"] },
56
58
  reviewer: { defaultPool: false, declaration: "automatic", unavailable: "agy is an implementer peer (#485)" },
@@ -1,4 +1,5 @@
1
1
  import { publicArg } from "../definitions/native-cli-preview-common.ts";
2
+ import { assessVersion, COMPAT } from "../compat.ts";
2
3
  import { spawn, spawnSync } from "node:child_process";
3
4
  import { resolve } from "node:path";
4
5
  import type { NativeCliEntryContext } from "../definitions/native-cli-entry-types.ts";
@@ -38,10 +39,10 @@ export const AGY_LAUNCH_NOTICE = "Antigravity: pull-only TUI; native permissions
38
39
 
39
40
  /** agy auto-updates, so the 1.4.x line launches; only 1.4.2 has a T0 receipt and other patches warn (#589). */
40
41
  export function agyVersionGate(stdout: string): { ok: true; message?: string } | { ok: false; message: string } {
41
- const version = stdout.trim();
42
- if (version === "1.4.2") return { ok: true };
43
- if (/^1\.4\.\d+$/.test(version)) return { ok: true, message: `agy ${version}: only 1.4.2 is T0-verified; launching on the 1.4.x line unverified` };
44
- return { ok: false, message: "agy P1 requires the Antigravity CLI 1.4.x line (1.4.2 verified)" };
42
+ // #691: the verified versions, the compatible line and the person's trust live in compat.ts.
43
+ const verdict = assessVersion("agy", COMPAT.agy.parse(stdout));
44
+ if (!verdict.ok) return { ok: false, message: verdict.reason };
45
+ return verdict.note ? { ok: true, message: verdict.note } : { ok: true };
45
46
  }
46
47
 
47
48
  export async function runAgyEntry(ctx: NativeCliEntryContext): Promise<void> {
@@ -22,9 +22,16 @@ import { renderHeldHint } from "../hub/delivery-hints.ts";
22
22
  // ControlClient verifies the manifest identity on every connection.
23
23
  const stateDir = process.env.AGENTHUB_STATE_DIR ?? stateDirFor(process.cwd());
24
24
  const projectRoot = process.env.AGENTHUB_PROJECT_DIR ?? process.cwd();
25
- const peerId = process.env.AGENTHUB_PEER_ID ?? "claude";
26
- /** tools mode: the same server, run by Kimi (ACP mcpServers) or Codex (mcp_servers override). Their messages arrive through their own adapters, so no channel here. Not the "tools-only" Claude below. */
25
+ /** tools mode: the same server, run by Kimi (ACP mcpServers), Codex (mcp_servers override) or Jcode. Their messages arrive through their own adapters, so no channel here. Not the "tools-only" Claude below. */
27
26
  const toolsMode = process.env.AGENTHUB_MODE === "tools";
27
+ /**
28
+ * #368: a tools-mode server acts as exactly the peer whose owned launch started it, and the project's Jcode MCP entry names none, so the
29
+ * id comes from the owned process env alone. With none (a person's own `jcode` run in this project outside the hub) it connects as nobody,
30
+ * refuses every tool with a reason, and never defaults to "jcode" or any other id. Only a plain Claude session defaults to "claude".
31
+ */
32
+ const unidentified = toolsMode && !process.env.AGENTHUB_PEER_ID;
33
+ const peerId = process.env.AGENTHUB_PEER_ID ?? (toolsMode ? "" : "claude");
34
+ const UNIDENTIFIED = "agent-hub tools refused: this process was not started by the hub (no AGENTHUB_PEER_ID), so it has no peer identity. Start the agent with ahub jcode (or the hub's own start) to use hub tools.";
28
35
  // Exposure hint only; the daemon verifies the actual adapter before any retry mutation.
29
36
  const deliveryOnly = process.env.AGENTHUB_DELIVERY_ONLY === "true";
30
37
  /**
@@ -84,7 +91,7 @@ const MAX_RECONNECT_DELAY_MS = 30_000;
84
91
  const TERMINAL_CLOSES: Record<number, string> = {
85
92
  4401: "the hub refused the control token",
86
93
  4404: "the hub belongs to a different project or instance; restart this session from the intended project",
87
- 4403: `the hub refused the peer id "${peerId}" (reserved or malformed)`,
94
+ 4403: `the hub refused "${peerId}": the id is reserved or malformed, or this process is not an owned launch of that peer (start it with ahub jcode)`,
88
95
  4409: `the peer id "${peerId}" is taken by a hub-managed adapter`,
89
96
  4430: "this Claude launch ended; start a new session with ahub claude, or use a plain session with hub_inbox",
90
97
  4426: "wire version mismatch with the running hub: update the agent-hub plugin (ahub setup) and restart this session",
@@ -157,6 +164,8 @@ const offline = () => detached ?? "hub is not running for this project (start it
157
164
  * Surfaced on the next tool result; the pull itself still happens only through hub_inbox.
158
165
  */
159
166
  let pendingQueued: { queued: number; important: number } | undefined;
167
+ /** Marks a tool result whose text already states the queued count (#221), so the fresh-count block does not repeat it. Symbol keys never reach the client. */
168
+ const QUEUED_COUNTED = Symbol("queuedCounted");
160
169
  /** Fresh own-peer counts accompany all tool modes; only old plain sessions use advisory queued nudges. */
161
170
  async function withPendingHint<T>(result: T, recommendInbox = true): Promise<T> {
162
171
  const r = result as { content?: { type: string; text?: string }[] };
@@ -169,23 +178,28 @@ async function withPendingHint<T>(result: T, recommendInbox = true): Promise<T>
169
178
  };
170
179
  if (hub && r.content) {
171
180
  let freshCounts = false;
181
+ let hint = "";
172
182
  try {
173
183
  const response = await hub.request({ t: "delivery_holds" }, 1000);
184
+ // A hold that refuses pulls (#221): hub_inbox would only be refused again, so the counts stay and the advice goes.
185
+ hint = response?.ok ? renderHeldHint(response.held, Date.now(), !deliveryOnly) : "";
174
186
  if (response?.ok && Number.isSafeInteger(response.queued) && response.queued >= 0
175
187
  && Number.isSafeInteger(response.important) && response.important >= 0 && response.important <= response.queued) {
176
188
  freshCounts = true;
177
189
  pendingQueued = undefined; // A current zero must never resurrect an earlier queued nudge.
178
190
  if (response.important > 0) {
179
- const next = !recommendInbox ? "" : toolsMode
191
+ const next = !recommendInbox || hint ? "" : toolsMode
180
192
  ? ": use hub_inbox on a pull-capable peer session; otherwise finish the current turn to receive queued deliveries"
181
193
  : ": call hub_inbox before starting long local work";
182
- appendQueued(`(${response.queued} queued hub message(s), ${response.important} important hub message(s) waiting${next})`);
194
+ // One queued count per result (#221): a hub_inbox result that already says how many remain unread adds only the important count.
195
+ const counted = (result as { [QUEUED_COUNTED]?: boolean })[QUEUED_COUNTED] === true;
196
+ appendQueued(counted ? `(${response.important} important hub message(s) waiting${next})` : `(${response.queued} queued hub message(s), ${response.important} important hub message(s) waiting${next})`);
183
197
  }
184
198
  }
185
- const hint = response?.ok ? renderHeldHint(response.held, Date.now(), !deliveryOnly) : "";
186
199
  if (hint) r.content.push({ type: "text", text: hint });
187
200
  } catch { /* advisory metadata can never change a saved tool result or its effect classification */ }
188
- if (!freshCounts && recommendInbox && !channel && !toolsMode && pendingQueued && pendingQueued.important > 0) {
201
+ // A pre-#493 daemon sends held rows without counts; the legacy nudge must not advise a read the hold refuses (#221).
202
+ if (!freshCounts && !hint && recommendInbox && !channel && !toolsMode && pendingQueued && pendingQueued.important > 0) {
189
203
  appendQueued(`(${pendingQueued.important} important hub message(s) waiting: call hub_inbox before starting long local work)`);
190
204
  }
191
205
  }
@@ -244,7 +258,7 @@ async function connectLoop(): Promise<void> {
244
258
  let closeReason: string | undefined;
245
259
  try {
246
260
  const client = await ControlClient.connect(stateDir, { role: toolsMode ? "tools" : "peer", peer: peerId, ...(toolsMode ? {} : { channel }),
247
- ...(!toolsMode && process.env.AGENTHUB_LAUNCH_ID ? { launchId: process.env.AGENTHUB_LAUNCH_ID } : {}),
261
+ ...(process.env.AGENTHUB_LAUNCH_ID ? { launchId: process.env.AGENTHUB_LAUNCH_ID } : {}),
248
262
  ...(!toolsMode && standingBy ? { standby: true } : {}),
249
263
  ...(process.env.AGENTHUB_PROJECT_DIR ? { projectRoot } : {}) });
250
264
  client.onPush = (msg) => {
@@ -368,6 +382,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
368
382
 
369
383
  async function callTool(req: { params: { name: string; arguments?: Record<string, unknown> } }) {
370
384
  const { name, arguments: args } = req.params;
385
+ if (unidentified) return { ...text(UNIDENTIFIED), isError: true };
371
386
  if (name === "hub_delivery_done" && channel) {
372
387
  if (!hub) return text(offline());
373
388
  const a = args ?? {};
@@ -375,7 +390,7 @@ async function callTool(req: { params: { name: string; arguments?: Record<string
375
390
  return text(result.ok ? "delivery completed" : `not completed: ${result.error}`);
376
391
  }
377
392
  if (name === "hub_inbox" && !toolsMode) {
378
- let remote: string[] = [], notice = "";
393
+ let remote: string[] = [], notice = "", polling = "", counted = false;
379
394
  if (hub) {
380
395
  try {
381
396
  const res = await hub.request({ t: "inbox" });
@@ -384,14 +399,19 @@ async function callTool(req: { params: { name: string; arguments?: Record<string
384
399
  else {
385
400
  pendingQueued = { queued: typeof res.waiting === "number" ? res.waiting : 0, important: typeof res.waitingImportant === "number" ? res.waitingImportant : 0 };
386
401
  remote = (res.envs as Envelope[]).map(frame);
387
- if (res.waiting) notice = `(${res.waiting} queued hub message(s) remain unread)`;
402
+ if (Number.isSafeInteger(res.pollingAdvice?.count) && res.pollingAdvice.count >= 3 && Number.isSafeInteger(res.pollingAdvice?.minutes) && res.pollingAdvice.minutes >= 0 && res.pollingAdvice.minutes <= 10) polling = `[polling detected] ${res.pollingAdvice.count} empty reads in ${res.pollingAdvice.minutes} min with no assigned work: end your turn`;
403
+ // #713: a pull drains what fits one read; what is left is said plainly, with what to do about it. The count already says how many remain, so the pending hint adds only the important ones (#221).
404
+ if (res.waiting) { notice = `(${res.waiting} more waiting: call hub_inbox again before you start work)`; counted = true; }
388
405
  }
389
406
  } catch { notice = "not read: daemon inbox unavailable; inspect the connection before another read"; }
390
407
  } else notice = offline();
391
408
  // Only pushes without a journal delivery id enter this buffer. Pull completion belongs to the daemon.
392
409
  const out = [...inbox.splice(0), ...remote];
393
410
  if (notice) out.push(notice);
394
- return text(out.length ? out.join("\n\n") : "(no queued hub messages)");
411
+ const body = out.length ? out.join("\n\n") : "(no queued hub messages)\nNothing is waiting. Do not poll: if you have no assigned task, report and end your turn; new work waits here for your next boundary.";
412
+ const result = text(polling ? `${body}\n${polling}` : body);
413
+ if (counted) Object.defineProperty(result, QUEUED_COUNTED, { value: true });
414
+ return result;
395
415
  }
396
416
  if (name === "hub_send") {
397
417
  const { text: body, to, reply_to } = (args ?? {}) as { text?: string; to?: string[]; reply_to?: string };
@@ -418,4 +438,4 @@ await server.connect(new StdioServerTransport());
418
438
  // The host (Claude Code, Codex, Kimi) talks over stdin; once it is gone nothing can use this server, and a leftover would keep reconnecting.
419
439
  process.stdin.on("end", () => process.exit(0));
420
440
  process.stdin.on("close", () => process.exit(0));
421
- void connectLoop();
441
+ if (unidentified) log(UNIDENTIFIED); else void connectLoop();
@@ -9,6 +9,8 @@ import type { ContextReader } from "./registry-types.ts";
9
9
 
10
10
  const UNIDENTIFIED_CANCELLATION_NOTICE = "Codex cancelled turn has no verified native id; reattach the native session before further hub delivery";
11
11
 
12
+ /** A counter Codex reported: a finite non-negative number, otherwise 0 (an absent kind is not a negative delta). */
13
+ const counter = (value: unknown): number => { const n = Number(value); return Number.isFinite(n) && n >= 0 ? n : 0; };
12
14
  export interface CodexOptions {
13
15
  /** Port the TUI attaches to: `codex --enable tui_app_server --remote ws://127.0.0.1:<proxyPort>`. */
14
16
  proxyPort: number;
@@ -28,6 +30,11 @@ export interface CodexOptions {
28
30
  onUsage?: (rateLimits: unknown, hard: boolean) => void;
29
31
  /** Tokens the thread used since the previous update (see `tokenTotal` for how they are counted). */
30
32
  onTokens?: (added: number) => void;
33
+ /**
34
+ * The same update as `onTokens`, split into the kinds Codex reports (#683 U1): the increase of each `total` counter since the previous
35
+ * update. Cached input is a part of `input`, and `reasoning` a part of `output` (Codex's own definition), so `total` is not their sum.
36
+ */
37
+ onTokenBreakdown?: (delta: { input: number; cachedInput: number; output: number; reasoning: number; total: number }) => void;
31
38
  onContext?: (reading: ContextReading) => void;
32
39
  /** The definition's native occupancy reader; standalone proxies retain the native default. */
33
40
  contextReader?: ContextReader;
@@ -89,6 +96,8 @@ export class CodexPeer extends BasePeer {
89
96
  * resumed thread's first update only sets the baseline: its total holds the history from before (issue #40).
90
97
  */
91
98
  private tokenTotal: number | undefined;
99
+ /** The per-kind counters of the last `total`, for the breakdown deltas (#683 U1). */
100
+ private tokenKinds: { input: number; cachedInput: number; output: number; reasoning: number } | undefined;
92
101
  private freshThread = false;
93
102
  private readonly activeTurns = new Set<string>();
94
103
  private deliveryEpoch = 0;
@@ -488,6 +497,7 @@ export class CodexPeer extends BasePeer {
488
497
  get proxyAttached(): boolean { return !!this.link && this.link.up.readyState === WebSocket.OPEN; }
489
498
 
490
499
  getPermissionMode(): PermissionMode { return this.permissionMode; }
500
+ get permissionModeState(): PermissionMode { return this.getPermissionMode(); }
491
501
 
492
502
  /** Reconcile the daemon's current choice after startup, before the returned proxy URL has a TUI. */
493
503
  setStartupPermissionMode(mode: PermissionMode): void {
@@ -583,7 +593,7 @@ export class CodexPeer extends BasePeer {
583
593
  for (const ids of this.turnDeliveries.values()) for (const id of ids) this.delivery({ id, state: "needs_review", reason: "Codex native thread changed" });
584
594
  for (const id of this.unboundDeliveries) this.delivery({ id, state: "needs_review", reason: "Codex native thread changed" });
585
595
  this.turnDeliveries.clear(); this.unboundDeliveries.clear();
586
- this.tokenTotal = undefined;
596
+ this.tokenTotal = undefined; this.tokenKinds = undefined;
587
597
  this.freshThread = fresh;
588
598
  this.link = link;
589
599
  this.threadId = threadId;
@@ -647,9 +657,16 @@ export class CodexPeer extends BasePeer {
647
657
  this.opts.onContext?.(reader ? reader.read(params.tokenUsage, this.threadId, Date.now()) : codexContext(params.tokenUsage, this.threadId, Date.now()));
648
658
  const total = Number(params.tokenUsage?.total?.totalTokens);
649
659
  if (!Number.isFinite(total)) return;
660
+ const previous = this.tokenKinds;
661
+ const kinds = { input: counter(params.tokenUsage?.total?.inputTokens), cachedInput: counter(params.tokenUsage?.total?.cachedInputTokens),
662
+ output: counter(params.tokenUsage?.total?.outputTokens), reasoning: counter(params.tokenUsage?.total?.reasoningOutputTokens) };
650
663
  const added = this.tokenTotal === undefined ? (this.freshThread ? total : 0) : Math.max(0, total - this.tokenTotal);
651
- this.tokenTotal = total;
664
+ // The same baseline rule as the total: a thread that was resumed counts only what it adds from now on; a new one counts from zero.
665
+ const base = previous ?? (this.freshThread && this.tokenTotal === undefined ? { input: 0, cachedInput: 0, output: 0, reasoning: 0 } : undefined);
666
+ this.tokenTotal = total; this.tokenKinds = kinds;
652
667
  if (added > 0) this.opts.onTokens?.(added);
668
+ if (added > 0 && base) this.opts.onTokenBreakdown?.({ input: Math.max(0, kinds.input - base.input), cachedInput: Math.max(0, kinds.cachedInput - base.cachedInput),
669
+ output: Math.max(0, kinds.output - base.output), reasoning: Math.max(0, kinds.reasoning - base.reasoning), total: added });
653
670
  return;
654
671
  }
655
672
  if (method === "turn/started") {
@@ -1,4 +1,5 @@
1
1
  import { DENY_NAMES, DENY_SEGMENTS } from "@staix/agent-mem/platform";
2
+ import { assessVersion, COMPAT } from "./compat.ts";
2
3
  import { createHash } from "node:crypto";
3
4
  import { spawn } from "node:child_process";
4
5
  import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
@@ -9,7 +10,9 @@ import { peerChildEnv, stopOwnedProcess, trackGroup } from "../hub/child-process
9
10
  import { realPath } from "../hub/project.ts";
10
11
  import type { ReviewerBackend, ReviewerLaunch, ReviewerPrepareContext } from "./reviewer-print.ts";
11
12
 
12
- const VERSION = "0.146.0", TOOLS = ["hub_review", "hub_task_show"];
13
+ const TOOLS = ["hub_review", "hub_task_show"];
14
+ /** A version refusal names only versions and a command, so the daemon may show it (#691). */
15
+ class VersionRefusal extends Error {}
13
16
  const CAP = 128 * 1024, MAX_SERVERS = 64;
14
17
  const digest = (value: string | Buffer) => createHash("sha256").update(value).digest("hex");
15
18
  /** Conservative translation of the portable core's regex sources; unfamiliar syntax refuses the receipt. */
@@ -207,11 +210,14 @@ async function prepare(ctx: ReviewerPrepareContext): Promise<ReviewerLaunch | {
207
210
  child.once("error", () => { failed = true; void finish(null); });
208
211
  child.once("close", code => { void finish(code); });
209
212
  });
210
- if ((await run(["--version"])).trim() !== `codex-cli ${VERSION}`) throw new Error("native version has no isolation receipt");
213
+ // #691: the verified versions and the person's trust live in compat.ts; every proof below still runs after trust.
214
+ const verdict = assessVersion("codex-review", COMPAT["codex-review"].parse(await run(["--version"])));
215
+ if (!verdict.ok) throw new VersionRefusal(verdict.reason);
216
+ const version = verdict.version;
211
217
  if (Buffer.byteLength(ctx.mcpConfig) > CAP || ctx.privatePaths.length > 128) throw new Error("policy bounds exceeded");
212
218
  const hub = ownServer(ctx.mcpConfig);
213
219
  // Apps creates a runtime MCP server outside the configured inventory; independently pin and read back its gate.
214
- const policy = ["--disable", "plugins", "--disable", "hooks", "--disable", "apps", "-c", 'web_search="disabled"', "-c", `projects=${toml({ [cwd]: { trust_level: "untrusted" } })}`];
220
+ const policy = ["-c", "notify=[]", "--disable", "plugins", "--disable", "hooks", "--disable", "apps", "-c", 'web_search="disabled"', "-c", `projects=${toml({ [cwd]: { trust_level: "untrusted" } })}`];
215
221
  proveDisabledFeatures(await run(["features", "list", ...policy]));
216
222
  const before = inventory(await run(["mcp", "list", "--json", ...policy]));
217
223
  if (before.some(row => row.name === "agent-hub")) throw new Error("ambient hub MCP collision");
@@ -268,11 +274,12 @@ async function prepare(ctx: ReviewerPrepareContext): Promise<ReviewerLaunch | {
268
274
  return { command: bound.command, args: ["exec", "--ephemeral", "--ignore-user-config", "--ignore-rules", "--json", "--color", "never", "-C", cwd, ...policy, ...permissions, ...mcp, "-c", `developer_instructions=${toml(ctx.systemPrompt)}`, "-"], env,
269
275
  current: () => { try { return current(); } catch { return false; } },
270
276
  cleanup: () => rmSync(cleanupRoot, { recursive: true, force: true }),
271
- isolation: { backend: "codex", version: VERSION, binary: `sha256:${binaryHash}`, profile: `sha256:${launchProfile}`, readOnly: true, strictMcp: true, ephemeral: true,
272
- evidence: ["model-free-mcp-inventory-disabled-ambient", "hub-tools-exact-allowlist", "plugins-and-hooks-disabled", "apps-disabled-effective-feature-readback", "web-search-disabled", "project-untrusted", "native-public-read-private-deny-write-deny", "native-glob-and-symlink-private-deny", "portable-core-name-and-state-deny", "standard-host-credential-roots-denied", "binary-config-stable", "binary-hash-once-metadata-fence", "session-ephemeral"] } };
277
+ isolation: { backend: "codex", version, binary: `sha256:${binaryHash}`, profile: `sha256:${launchProfile}`, readOnly: true, strictMcp: true, ephemeral: true,
278
+ evidence: ["model-free-mcp-inventory-disabled-ambient", "hub-tools-exact-allowlist", "plugins-and-hooks-disabled", "legacy-notify-disabled", "apps-disabled-effective-feature-readback", "web-search-disabled", "project-untrusted", "native-public-read-private-deny-write-deny", "native-glob-and-symlink-private-deny", "portable-core-name-and-state-deny", "standard-host-credential-roots-denied", "binary-config-stable", "binary-hash-once-metadata-fence", "session-ephemeral"] } };
273
279
  } catch (error) {
274
280
  if (root) { try { rmSync(root, { recursive: true, force: true }); } catch { /* unavailable remains refused */ } }
275
281
  // Name only the path class of an unusable credential root so a stale ~/.docker link is fixable; never a path or contents.
282
+ if (error instanceof VersionRefusal) return { unavailable: error.message };
276
283
  if (error instanceof CredentialRootError) {
277
284
  const why = { dangling: "a dangling link", unreadable: "an unreadable path", unsupported: "unsupported syntax or overlapping the checkout" }[error.kind];
278
285
  return { unavailable: `Codex reviewer isolation receipt unavailable (credential root ${error.label} is ${why}; remove or repair it)` };
@@ -0,0 +1,140 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { hubHome } from "../hub/project.ts";
5
+
6
+ /**
7
+ * #691: the one version policy for native agent CLIs. Every version gate asks `assessVersion`; no other file holds a
8
+ * version constant. `verified` lists versions with a receipt in docs/verification; `line` is a semver window assumed
9
+ * compatible (contract gates only); a person's trust lets an exact other version through on this machine. An isolation
10
+ * gate keeps every runtime proof after trust: trust only lifts the version pin.
11
+ */
12
+ export type CompatGate = "contract" | "isolation";
13
+ export interface CompatPolicy {
14
+ label: string;
15
+ verified: readonly string[];
16
+ line?: { min: string; below: string };
17
+ gate: CompatGate;
18
+ /** The default executable and the argv `ahub agents` uses to read the version. */
19
+ probe: readonly string[];
20
+ /** Turns the probe's stdout into a plain semver, or undefined. */
21
+ parse: (stdout: string) => string | undefined;
22
+ /** What runtime checks still decide after trust (isolation) or what stays unverified (contract). */
23
+ trustNote: string;
24
+ /** The config key that selects this kind's executable, when one exists. */
25
+ config?: "codex_bin" | "jcode_cmd" | "qwen_cmd";
26
+ }
27
+
28
+ const plain = (text: string) => text.length <= 64 && /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(text) ? text : undefined;
29
+ export const COMPAT = {
30
+ // 0.163.0 receipt: docs/verification/2026-10-22-codex-reviewer-isolation.md
31
+ "codex-review": { label: "Codex reviewer (codex-cli)", verified: ["0.146.0", "0.163.0"], gate: "isolation", probe: ["codex", "--version"], config: "codex_bin",
32
+ parse: out => plain(out.trim().replace(/^codex-cli /, "")),
33
+ trustNote: "Every launch still proves the MCP inventory, Apps/plugins/hooks disabled, sandbox read/write denial and the binary fence; a failed leg still refuses. Trust covers only the source-level claims those probes cannot see (docs/verification/2026-10-19-reviewer-isolation.md)." },
34
+ // Kimi 2.3.0 / Qwen 0.26.0 receipt: docs/verification/2026-10-22-kimi-qwen-compat.md
35
+ "kimi-review": { label: "Kimi reviewer (kimi-code)", verified: ["2.2.0", "2.3.0"], gate: "isolation", probe: ["kimi", "--version"],
36
+ parse: out => plain(out.trim()),
37
+ trustNote: "Every launch still builds the private config, exact two-tool agent file and task-bound MCP; the native tool allowlist behaviour of an untested version is not re-proven." },
38
+ jcode: { label: "Jcode", verified: ["0.98.1", "0.99.0"], line: { min: "0.98.0", below: "1.0.0" }, gate: "contract", probe: ["jcode", "--quiet", "version", "--json"], config: "jcode_cmd",
39
+ parse: out => { try { const v = JSON.parse(out).semver; return typeof v === "string" ? plain(v) : undefined; } catch { return undefined; } },
40
+ trustNote: "The ACP, serve and MCP contract was verified on 0.98.1 and steering on 0.99.0; an untested version may change them." },
41
+ agy: { label: "agy", verified: ["1.4.2"], line: { min: "1.4.0", below: "1.5.0" }, gate: "contract", probe: ["agy", "--version"],
42
+ parse: out => plain(out.trim()),
43
+ trustNote: "Only 1.4.2 is T0-verified; the plugin MCP and hook contract of another version is unverified." },
44
+ qwen: { label: "Qwen Code", verified: ["0.25.0", "0.26.0"], gate: "contract", probe: ["qwen", "--version"], config: "qwen_cmd",
45
+ parse: out => plain(out.trim()),
46
+ trustNote: "Native ACP identity and mode ids have dated receipts; 0.26.0 permission callbacks were checked without inference. Another version may change those contracts." },
47
+ } as const satisfies Record<string, CompatPolicy>;
48
+ export type CompatId = keyof typeof COMPAT;
49
+ type CommandConfig = { codex_bin: string; jcode_cmd: string[]; qwen_cmd: string[] };
50
+ /** The configured executables the native gates use; the rest probe their PATH default. */
51
+ export function configuredCommands(config: CommandConfig): Partial<Record<CompatId, readonly string[]>> {
52
+ const out: Partial<Record<CompatId, readonly string[]>> = {};
53
+ for (const [id, policy] of Object.entries(COMPAT) as [CompatId, CompatPolicy][]) {
54
+ if (!policy.config) continue;
55
+ const value = config[policy.config];
56
+ out[id] = typeof value === "string" ? [value] : value;
57
+ }
58
+ return out;
59
+ }
60
+ export const isCompatId = (id: unknown): id is CompatId => typeof id === "string" && Object.hasOwn(COMPAT, id);
61
+
62
+ export type CompatVerdict =
63
+ | { ok: true; state: "verified" | "in-line" | "trusted"; version: string; note?: string }
64
+ | { ok: false; state: "refused" | "unknown"; version?: string; reason: string };
65
+
66
+ const parts = (v: string) => /^(\d+)\.(\d+)\.(\d+)$/.exec(v)?.slice(1).map(Number);
67
+ const compare = (a: number[], b: number[]) => a[0]! - b[0]! || a[1]! - b[1]! || a[2]! - b[2]!;
68
+ function inLine(version: string, line?: { min: string; below: string }): boolean {
69
+ const v = parts(version);
70
+ return !!line && !!v && compare(v, parts(line.min)!) >= 0 && compare(v, parts(line.below)!) < 0;
71
+ }
72
+
73
+ /** A refusal the daemon may show as is: it names only versions and a command. */
74
+ export const VERSION_REFUSAL = "Version not verified: ";
75
+ /** A window of one minor (1.4.0 up to 1.5.0) reads as "the 1.4.x line". */
76
+ function lineText(line: { min: string; below: string }): string {
77
+ const [a, b] = [parts(line.min)!, parts(line.below)!];
78
+ return a[2] === 0 && b[2] === 0 && a[0] === b[0] && b[1] === a[1]! + 1 ? `the ${a[0]}.${a[1]}.x line` : `the line ${line.min} up to ${line.below}`;
79
+ }
80
+ export function describe(id: CompatId): string {
81
+ const p: CompatPolicy = COMPAT[id];
82
+ return `verified ${p.verified.join(", ")}${p.line ? `; compatible: ${lineText(p.line)}` : ""}`;
83
+ }
84
+
85
+ /** `trusted` defaults to this machine's record; tests and the CLI pass their own. */
86
+ export function assessVersion(id: CompatId, observed: unknown, trusted: readonly TrustEntry[] = readTrust()): CompatVerdict {
87
+ const policy: CompatPolicy = COMPAT[id];
88
+ const version = typeof observed === "string" ? plain(observed) : undefined;
89
+ if (!version) return { ok: false, state: "unknown", reason: `${VERSION_REFUSAL}${policy.label} version unknown (${describe(id)}); check that the executable runs` };
90
+ if (policy.verified.includes(version)) return { ok: true, state: "verified", version };
91
+ if (trusted.some(entry => entry.id === id && entry.version === version)) return { ok: true, state: "trusted", version, note: `${policy.label} ${version} is untested; trusted on this machine by the person` };
92
+ if (policy.gate === "contract" && inLine(version, policy.line)) return { ok: true, state: "in-line", version, note: `${policy.label} ${version} is untested (${describe(id)}); launching on the compatible line` };
93
+ return { ok: false, state: "refused", version, reason: `${VERSION_REFUSAL}${policy.label} ${version} (${describe(id)}). Install a verified version, or accept this one on this machine with: ahub agents trust ${id} ${version}` };
94
+ }
95
+
96
+ export interface TrustEntry { id: CompatId; version: string; at: number }
97
+ const MAX_TRUST = 64;
98
+ export const trustFile = (home = hubHome()) => join(home, "agent-compat.json");
99
+
100
+ const MAX_BYTES = 64 * 1024;
101
+ /** An unreadable, linked, oversized or invalid record trusts nothing. */
102
+ export function readTrust(file = trustFile()): TrustEntry[] {
103
+ let fd: number | undefined;
104
+ try {
105
+ // O_NONBLOCK: a FIFO planted at the record path must not stall a synchronous version gate; fstat refuses it.
106
+ fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
107
+ const info = fstatSync(fd);
108
+ if (!info.isFile() || info.size > MAX_BYTES) return [];
109
+ const bytes = Buffer.alloc(info.size);
110
+ if (readSync(fd, bytes, 0, info.size, 0) !== info.size) return [];
111
+ const raw = JSON.parse(bytes.toString("utf8"));
112
+ if (raw?.schemaVersion !== 1 || !Array.isArray(raw.trusted) || raw.trusted.length > MAX_TRUST) return [];
113
+ const rows = raw.trusted.filter((row: unknown): row is TrustEntry => {
114
+ const r = row as TrustEntry;
115
+ return !!r && isCompatId(r.id) && typeof r.version === "string" && plain(r.version) === r.version && Number.isSafeInteger(r.at);
116
+ });
117
+ return rows.length === raw.trusted.length ? rows : [];
118
+ } catch { return []; }
119
+ finally { if (fd !== undefined) closeSync(fd); }
120
+ }
121
+
122
+ /** Person-only (the CLI classifier refuses agent shells). Writes atomically; returns the new record. */
123
+ export function writeTrust(id: CompatId, version: string, revoke: boolean, file = trustFile(), now = Date.now()): TrustEntry[] {
124
+ if (plain(version) !== version) throw new Error("trust needs an exact version such as 1.2.3");
125
+ const rows = readTrust(file).filter(row => !(row.id === id && row.version === version));
126
+ if (!revoke) rows.push({ id, version, at: now });
127
+ if (rows.length > MAX_TRUST) throw new Error(`at most ${MAX_TRUST} trusted versions; revoke some first`);
128
+ const parent = dirname(file);
129
+ mkdirSync(parent, { recursive: true, mode: 0o700 });
130
+ const dir = lstatSync(parent);
131
+ if (!dir.isDirectory() || dir.isSymbolicLink()) throw new Error("the hub home is not a plain directory; trust not written");
132
+ const target = lstatSync(file, { throwIfNoEntry: false });
133
+ if (target && !target.isFile()) throw new Error("the trust record is not a regular file; trust not written");
134
+ // A unique, exclusively created temporary file: an existing name or a planted link fails instead of being followed.
135
+ const temporary = join(parent, `.agent-compat.${randomUUID()}.tmp`);
136
+ writeFileSync(temporary, `${JSON.stringify({ schemaVersion: 1, trusted: rows }, null, 2)}\n`, { mode: 0o600, flag: "wx" });
137
+ try { renameSync(temporary, file); }
138
+ catch (error) { unlinkSync(temporary); throw error; }
139
+ return rows;
140
+ }
@@ -0,0 +1,114 @@
1
+ import { expect, test } from "bun:test";
2
+ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import type { AcpPeer } from "../acp.ts";
6
+ import { newEnvelope } from "../../hub/envelope.ts";
7
+ import type { DeliveryReceipt } from "../../hub/peers.ts";
8
+ import { processTable } from "../../hub/child-process.ts";
9
+ import { until } from "./kit.ts";
10
+ import type { AdapterDefinition } from "../registry-types.ts";
11
+ import { fakePaths } from "./fake-paths.ts";
12
+ import { createFromDefinition, expire } from "./factories.ts";
13
+ import { ACP_LEGS, assertAcpLegEnrollment, type AcpLeg } from "./enrollment.ts";
14
+
15
+ export interface AcpConformanceOptions {
16
+ definition: AdapterDefinition;
17
+ agentArgs?: readonly string[];
18
+ unsupported?: Partial<Record<AcpLeg, string>>;
19
+ permissionPrompts?: { accepted: string; foreign: string };
20
+ startupModeAcknowledgement?: boolean;
21
+ modeAcknowledgement?: string;
22
+ quotaLabel?: string;
23
+ watchdogLabel?: string;
24
+ }
25
+ /** No protocol is run at import; callers explicitly select supported fake protocol legs. */
26
+ export function registerAcpConformance(options: AcpConformanceOptions): void {
27
+ const { definition } = options, kind = definition.kind, fake = fakePaths.acpServer;
28
+ if (definition.transport !== "acp") throw new Error(kind + ": ACP conformance requires an ACP definition");
29
+ const reasons = Object.fromEntries(Object.entries(options.unsupported ?? {}).map(([leg, reason]) => [kind + ":" + leg, reason!]));
30
+ assertAcpLegEnrollment([definition], reasons);
31
+ if (!options.unsupported?.["native-mode-ack"] && !options.modeAcknowledgement) throw new Error(kind + ": mode acknowledgement fixture must be explicit");
32
+ // The catalog chooses every real registration or explicit unsupported outcome; no kind is filtered away.
33
+ const registrations: Record<AcpLeg, (kind: string) => void> = {
34
+ "native-approval-rpc": kind => {
35
+ test(`P4 ${kind}: real permission RPC approves exact hub identity once and asks on a lookalike`, async () => {
36
+ const dir = mkdtempSync(join(tmpdir(), "conformance-approval-")), asked: string[] = [], replies: string[] = [];
37
+ const peer = createFromDefinition<AcpPeer>(definition, { acp: { cwd: dir, cmd: [process.execPath, fake, ...(options.agentArgs ?? [])],
38
+ mcpServers: [{ name: "agent-hub", command: "unused", args: [], env: [] }],
39
+ autoApprove: title => title === "mcp__agent-hub__hub_task_list" || title === "mcp__agent-hub__hub_send",
40
+ onPermission: async request => { asked.push(request.title); return "no"; } } });
41
+ peer.onMessage = text => { replies.push(text); };
42
+ try {
43
+ await peer.start();
44
+ await peer.deliver([newEnvelope("user", options.permissionPrompts?.accepted ?? "PERMISSION HUBTOOL")]);
45
+ await until(() => replies.length === 1 && peer.state === "idle", "autoapproved permission settled");
46
+ expect(replies[0]).toEndWith("permission=yes"); expect(asked).toEqual([]);
47
+ await peer.deliver([newEnvelope("user", options.permissionPrompts?.foreign ?? "PERMISSION SPOOF")]);
48
+ await until(() => replies.length === 2 && peer.state === "idle", "lookalike permission settled");
49
+ expect(asked).toHaveLength(1); expect(replies[1]).toEndWith("permission=no");
50
+ await peer.setPermissionMode("never-ask"); expect(peer.permissionModeState).toBe("never-ask");
51
+ await peer.setPermissionMode("ask"); expect(peer.permissionModeState).toBe("ask");
52
+ } finally { await peer.stop(); rmSync(dir, { recursive: true, force: true }); }
53
+ }, 20_000);
54
+ },
55
+ "native-mode-ack": kind => {
56
+ const modeAcknowledgement = options.modeAcknowledgement;
57
+ if (!modeAcknowledgement) throw new Error(kind + ": mode acknowledgement fixture must be explicit");
58
+ test(`P4 ${kind}: unconfirmed mode change goes unknown/offline despite a late acknowledgement`, async () => {
59
+ const dir = mkdtempSync(join(tmpdir(), "conformance-mode-")), pid = join(dir, "pid"), ack = join(dir, "ack");
60
+ const peer = createFromDefinition<AcpPeer>(definition, { acp: { cwd: dir, cmd: [process.execPath, fake, ...(options.agentArgs ?? []), "--ignore-term", "--mode-delay-ms", "200", "--mode-ack-record", ack, "--record-pid", pid],
61
+ permissionModeTimeoutMs: 40 } });
62
+ try {
63
+ // Qwen reconciles its default at startup; Kimi's unchanged ask default needs no initial request.
64
+ if (options.startupModeAcknowledgement) await expect(peer.start()).rejects.toThrow("unknown");
65
+ else { await peer.start(); await expect(peer.setPermissionMode("never-ask")).rejects.toThrow("unknown"); }
66
+ expect(peer.permissionModeState).toBe("unknown"); expect(peer.state).toBe("offline");
67
+ expect(readFileSync(ack, "utf8").trim()).toBe(modeAcknowledgement);
68
+ const owned = Number(readFileSync(pid, "utf8")), table = processTable();
69
+ expect(table).toBeDefined(); expect(table!.some(row => row.pid === owned || row.pgid === owned)).toBe(false);
70
+ await expect(peer.setPermissionMode("ask")).rejects.toThrow("unknown");
71
+ await expect(peer.deliver([newEnvelope("user", "must not start")])).rejects.toThrow("offline");
72
+ } finally { await peer.stop(); rmSync(dir, { recursive: true, force: true }); }
73
+ }, 20_000);
74
+ },
75
+ "native-quota": kind => {
76
+ test(`P4 ${options.quotaLabel ?? kind}: confirmed pre-activity quota pause settles failed_safe, not an ordinary failure`, async () => {
77
+ const dir = mkdtempSync(join(tmpdir(), "conformance-quota-")), receipts: DeliveryReceipt[] = [], order: string[] = [], failures: string[] = [];
78
+ const peer = createFromDefinition<AcpPeer>(definition, { acp: { cwd: dir, cmd: [process.execPath, fake, ...(options.agentArgs ?? []), "--quota-refusal"],
79
+ onQuotaRefusal: () => { order.push("paused"); return true; }, onTurnFailure: (_envs, reason) => { failures.push(reason); } } });
80
+ peer.onDelivery = receipt => { receipts.push(receipt); order.push(receipt.state); };
81
+ try {
82
+ await peer.start(); await peer.deliver([newEnvelope("user", "QUOTA_REFUSAL")], "quota");
83
+ await until(() => receipts.length > 0, "quota refusal receipt");
84
+ expect(order).toEqual(["paused", "failed_safe"]); expect(receipts[0]).toMatchObject({ id: "quota", state: "failed_safe", quota: true }); expect(failures).toEqual([]);
85
+ } finally { await peer.stop(); rmSync(dir, { recursive: true, force: true }); }
86
+ });
87
+ },
88
+ "watchdog-generation": kind => {
89
+ test(`P4 ${options.watchdogLabel ?? kind}: watchdog cancellation fences the consumed late native result by turn generation`, async () => {
90
+ const dir = mkdtempSync(join(tmpdir(), "conformance-watchdog-")), receipts: DeliveryReceipt[] = [], messages: string[] = [];
91
+ const peer = createFromDefinition<AcpPeer>(definition, { acp: { cwd: dir, cmd: [process.execPath, fakePaths.acpWatchdog, ...(options.agentArgs ?? [])] } });
92
+ peer.onDelivery = receipt => { receipts.push(receipt); }; peer.onMessage = text => { messages.push(text); };
93
+ try {
94
+ await peer.start(); await peer.deliver([newEnvelope("user", "ACK_SLOW")], "old");
95
+ await until(() => receipts.some(receipt => receipt.state === "accepted"), "native acceptance before explicit expiry");
96
+ expire(peer);
97
+ await until(() => receipts.some(receipt => receipt.state === "needs_review"), "watchdog uncertainty");
98
+ // The fake responds to this mode request after sending the old prompt result on the same stdio stream.
99
+ await peer.setPermissionMode("ask");
100
+ expect(receipts.map(receipt => receipt.state)).toEqual(["accepted", "needs_review"]);
101
+ expect(messages).toEqual([]); expect(peer.state).toBe("offline");
102
+ } finally { await peer.stop(); rmSync(dir, { recursive: true, force: true }); }
103
+ });
104
+ },
105
+ };
106
+
107
+ for (const leg of ACP_LEGS) {
108
+ const reason = options.unsupported?.[leg];
109
+ if (reason !== undefined) test(`P4 ${kind}: ${leg} explicitly unsupported by its native transport`, () => {
110
+ expect(reason.trim()).not.toBeEmpty();
111
+ });
112
+ else registrations[leg](kind);
113
+ }
114
+ }