@staix/agent-hub 0.12.43 → 0.12.45

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (180) hide show
  1. package/CHANGELOG.md +38 -3
  2. package/README.md +3 -3
  3. package/THIRD_PARTY_NOTICES.md +29 -0
  4. package/docs/adding-an-ai-tool.md +228 -0
  5. package/docs/agent-notes/README.md +14 -0
  6. package/docs/agent-notes/adapters.md +61 -42
  7. package/docs/agent-notes/budget.md +2 -2
  8. package/docs/agent-notes/daemon.md +16 -10
  9. package/docs/agent-notes/local-worker.md +4 -3
  10. package/docs/agent-notes/tasks.md +13 -6
  11. package/docs/agent-notes/tests.md +9 -7
  12. package/docs/operations.md +163 -12
  13. package/docs/quickstart.md +10 -2
  14. package/docs/sdlc.md +26 -8
  15. package/docs/security.md +11 -6
  16. package/docs/specs/2026-09-19-agent-hub-design.md +273 -8
  17. package/docs/verification/2026-10-21-0.12.44.md +54 -0
  18. package/docs/verification/2026-10-21-469-r1-dashboard-tokens.md +43 -0
  19. package/docs/verification/2026-10-21-469-r2-dashboard-shell.md +54 -0
  20. package/docs/verification/2026-10-21-469-r3-dashboard-board.md +51 -0
  21. package/docs/verification/2026-10-21-469-r4-dashboard-views.md +77 -0
  22. package/docs/verification/2026-10-21-469-r6-dashboard-motion-states.md +82 -0
  23. package/docs/verification/2026-10-21-grok-config-acp-t0.md +30 -0
  24. package/docs/verification/2026-10-22-0.12.45.md +60 -0
  25. package/docs/verification/assets/469-r1-board-1440-dark.png +0 -0
  26. package/docs/verification/assets/469-r1-board-1440-light.png +0 -0
  27. package/docs/verification/assets/469-r1-board-390-dark.png +0 -0
  28. package/docs/verification/assets/469-r1-board-390-light.png +0 -0
  29. package/docs/verification/assets/469-r2-agents-1440-dark.png +0 -0
  30. package/docs/verification/assets/469-r2-agents-768-dark.png +0 -0
  31. package/docs/verification/assets/469-r2-board-1024-light.png +0 -0
  32. package/docs/verification/assets/469-r2-board-1440-dark.png +0 -0
  33. package/docs/verification/assets/469-r2-board-1440-light.png +0 -0
  34. package/docs/verification/assets/469-r2-board-390-dark.png +0 -0
  35. package/docs/verification/assets/469-r2-board-390-light.png +0 -0
  36. package/docs/verification/assets/469-r2-board-768-light.png +0 -0
  37. package/docs/verification/assets/469-r3-board-1440-dark.png +0 -0
  38. package/docs/verification/assets/469-r3-board-1440-light.png +0 -0
  39. package/docs/verification/assets/469-r3-board-390-light.png +0 -0
  40. package/docs/verification/assets/469-r3-detail-1440-dark.png +0 -0
  41. package/docs/verification/assets/469-r3-detail-1440-light.png +0 -0
  42. package/docs/verification/assets/469-r3-detail-390-dark.png +0 -0
  43. package/docs/verification/assets/469-r3-plan-1440-light.png +0 -0
  44. package/docs/verification/assets/469-r3-plan-390-light.png +0 -0
  45. package/docs/verification/assets/469-r4-agents-1440-dark.png +0 -0
  46. package/docs/verification/assets/469-r4-agents-1440-light.png +0 -0
  47. package/docs/verification/assets/469-r4-agents-390-dark.png +0 -0
  48. package/docs/verification/assets/469-r4-memory-1440-dark.png +0 -0
  49. package/docs/verification/assets/469-r4-memory-1440-light.png +0 -0
  50. package/docs/verification/assets/469-r4-memory-390-dark.png +0 -0
  51. package/docs/verification/assets/469-r4-message-1440-light.png +0 -0
  52. package/docs/verification/assets/469-r4-office-1440-dark.png +0 -0
  53. package/docs/verification/assets/469-r4-office-1440-light.png +0 -0
  54. package/docs/verification/assets/469-r4-settings-1440-dark.png +0 -0
  55. package/docs/verification/assets/469-r4-settings-1440-light.png +0 -0
  56. package/docs/verification/assets/469-r4-stream-1440-light.png +0 -0
  57. package/docs/verification/assets/469-r4-stream-390-light.png +0 -0
  58. package/docs/verification/assets/469-r6-agents-empty-390-dark.png +0 -0
  59. package/docs/verification/assets/469-r6-agents-empty-390-light.png +0 -0
  60. package/docs/verification/assets/469-r6-board-1440-light.png +0 -0
  61. package/docs/verification/assets/469-r6-board-390-dark.png +0 -0
  62. package/docs/verification/assets/469-r6-board-empty-1440-dark.png +0 -0
  63. package/docs/verification/assets/469-r6-board-empty-1440-light.png +0 -0
  64. package/docs/verification/assets/469-r6-disconnected-1440-dark.png +0 -0
  65. package/docs/verification/assets/469-r6-disconnected-1440-light.png +0 -0
  66. package/docs/verification/assets/469-r6-disconnected-390-dark.png +0 -0
  67. package/docs/verification/assets/469-r6-skeleton-1440-light.png +0 -0
  68. package/docs/verification/assets/469-r6-skeleton-390-dark.png +0 -0
  69. package/docs/verification/assets/469-r6-toasts-1440-dark.png +0 -0
  70. package/docs/verification/assets/469-r6-toasts-1440-light.png +0 -0
  71. package/docs/verification/assets/469-r6-toasts-390-light.png +0 -0
  72. package/docs/verified.json +38 -20
  73. package/package.json +5 -1
  74. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  75. package/plugins/agent-hub/server.js +10287 -9560
  76. package/src/adapters/acp-owned-group.ts +124 -0
  77. package/src/adapters/acp.ts +92 -25
  78. package/src/adapters/adapter-api.ts +2 -0
  79. package/src/adapters/agy/definition.ts +4 -4
  80. package/src/adapters/agy/entry.ts +4 -1
  81. package/src/adapters/channel-peer.ts +143 -0
  82. package/src/adapters/claude-channel.ts +21 -12
  83. package/src/adapters/codex-appserver.ts +17 -2
  84. package/src/adapters/configured-acp.ts +124 -0
  85. package/src/adapters/conformance/acp.ts +114 -0
  86. package/src/adapters/conformance/codex.ts +191 -0
  87. package/src/adapters/conformance/enrollment.ts +43 -0
  88. package/src/adapters/conformance/factories.ts +17 -0
  89. package/src/adapters/conformance/fake-paths.ts +12 -0
  90. package/src/adapters/conformance/fakes/acp-server.ts +267 -0
  91. package/src/adapters/conformance/fakes/acp-watchdog-agent.ts +10 -0
  92. package/src/adapters/conformance/fakes/app-server.ts +119 -0
  93. package/src/adapters/conformance/fakes/codex-bin.ts +15 -0
  94. package/src/adapters/conformance/fakes/jcode-agent.ts +91 -0
  95. package/src/adapters/conformance/fakes/model-server.ts +39 -0
  96. package/src/adapters/conformance/fakes/pi-rpc.ts +32 -0
  97. package/src/adapters/conformance/index.ts +11 -0
  98. package/src/adapters/conformance/kit.ts +50 -0
  99. package/src/adapters/conformance/local-pi.ts +100 -0
  100. package/src/adapters/conformance/owned-acp.ts +44 -0
  101. package/src/adapters/definitions/builtin-metadata.ts +60 -39
  102. package/src/adapters/definitions/native-channel-state.ts +41 -0
  103. package/src/adapters/definitions/native-cli-entry-kimi.ts +9 -1
  104. package/src/adapters/definitions/native-cli-entry.ts +7 -19
  105. package/src/adapters/definitions/native-cli-preview-qwen.ts +1 -1
  106. package/src/adapters/definitions/native-cli-preview.ts +2 -11
  107. package/src/adapters/definitions/native-identity-defaults.ts +14 -0
  108. package/src/adapters/definitions/qwen.ts +7 -9
  109. package/src/adapters/definitions/reviewer-variants.ts +3 -7
  110. package/src/adapters/external-metadata.ts +48 -0
  111. package/src/adapters/external-module-scan.ts +126 -0
  112. package/src/adapters/external-modules.ts +240 -0
  113. package/src/adapters/jcode.ts +4 -0
  114. package/src/adapters/local-worker.ts +4 -0
  115. package/src/adapters/pi.ts +6 -0
  116. package/src/adapters/public.ts +7 -0
  117. package/src/adapters/registry-types.ts +28 -16
  118. package/src/adapters/registry.ts +115 -17
  119. package/src/adapters/reviewer-print.ts +4 -0
  120. package/src/cli/agy-mcp.ts +3 -2
  121. package/src/cli/configured-acp-entry.ts +58 -0
  122. package/src/cli/console-state.ts +9 -4
  123. package/src/cli/facts-hook.ts +2 -1
  124. package/src/cli/help.ts +27 -16
  125. package/src/cli/identity.ts +8 -7
  126. package/src/cli/launch.ts +5 -4
  127. package/src/cli/main.ts +101 -32
  128. package/src/cli/memory.ts +13 -10
  129. package/src/cli/output.ts +1 -0
  130. package/src/cli/preview.ts +9 -0
  131. package/src/cli/recovery-guidance.ts +1 -1
  132. package/src/cli/terminal-recovery.ts +16 -13
  133. package/src/cli/upgrade-labels.ts +1 -1
  134. package/src/cli/upgrade-presentation.ts +1 -1
  135. package/src/cli/upgrade-restore-state.ts +6 -4
  136. package/src/cli/upgrade-runtime.ts +25 -7
  137. package/src/cli/upgrade.ts +3 -2
  138. package/src/hub/board.ts +37 -8
  139. package/src/hub/budget.ts +9 -2
  140. package/src/hub/bus.ts +6 -2
  141. package/src/hub/claude-state.ts +2 -37
  142. package/src/hub/conductor.ts +8 -6
  143. package/src/hub/config-apply.ts +37 -17
  144. package/src/hub/config-reload.ts +42 -11
  145. package/src/hub/config-trust.ts +1 -1
  146. package/src/hub/context-window.ts +6 -0
  147. package/src/hub/control-client.ts +4 -1
  148. package/src/hub/crash.ts +8 -6
  149. package/src/hub/daemon.ts +412 -328
  150. package/src/hub/facts.ts +76 -13
  151. package/src/hub/hub-tools.ts +57 -5
  152. package/src/hub/instance-config.ts +33 -12
  153. package/src/hub/instance-execution.ts +23 -11
  154. package/src/hub/instances.ts +47 -22
  155. package/src/hub/lifecycle-schema.ts +5 -3
  156. package/src/hub/memory-profile.ts +16 -3
  157. package/src/hub/no-writes.ts +50 -0
  158. package/src/hub/peers.ts +9 -0
  159. package/src/hub/permission-mode.ts +9 -7
  160. package/src/hub/plan-conformance.ts +83 -0
  161. package/src/hub/review-findings.ts +104 -0
  162. package/src/hub/roster.ts +35 -6
  163. package/src/hub/routing.ts +21 -20
  164. package/src/hub/sdlc-queue.ts +19 -4
  165. package/src/hub/sdlc-release.ts +16 -1
  166. package/src/hub/sdlc.ts +27 -12
  167. package/src/hub/settings.ts +54 -16
  168. package/src/hub/snapshots.ts +32 -6
  169. package/src/hub/start-mode.ts +10 -8
  170. package/src/hub/task-sweep.ts +8 -2
  171. package/src/hub/tasks.ts +358 -60
  172. package/src/hub/ui.ts +2 -2
  173. package/src/memory/capture.ts +2 -1
  174. package/src/ui/index.html +563 -77
  175. package/src/ui/office-model.ts +3 -1
  176. package/src/ui/peer-palette.ts +44 -0
  177. package/templates/AGENTS.block.md +1 -1
  178. package/templates/profiles/general.json +1 -1
  179. package/templates/profiles/research.json +1 -1
  180. package/templates/profiles/writing.json +1 -1
@@ -13,7 +13,7 @@ import { readFileSync } from "node:fs";
13
13
  import { join } from "node:path";
14
14
  import { ControlClient, stateDirFor } from "../hub/control-client.ts";
15
15
  import { VERSION } from "../version.ts";
16
- import { ACTIONABLE_MESSAGE_INSTRUCTION, CONDUCTOR_TOOLS, CONDUCTOR_TOOL_NAMES, DEFAULT_ROLES, roleContract, TASK_TOOL_NAMES, TASK_TOOLS, profileTaskTools } from "../hub/hub-tools.ts";
16
+ import { ACTIONABLE_MESSAGE_INSTRUCTION, projectConductorTools, projectToolList, CONDUCTOR_TOOL_NAMES, DEFAULT_ROLES, roleContract, TASK_TOOL_NAMES, TASK_TOOLS, profileTaskTools } from "../hub/hub-tools.ts";
17
17
  import { frame, replyParent, sanitize, HUB_MESSAGE_INSTRUCTION, type Envelope } from "../hub/envelope.ts";
18
18
  import { renderHeldHint } from "../hub/delivery-hints.ts";
19
19
 
@@ -46,8 +46,8 @@ function roles(): Record<string, string[]> {
46
46
  };
47
47
  const configured = { ...DEFAULT_ROLES, ...read("config.json"), ...read("config.local.json") };
48
48
  try {
49
- const declarations = readInstanceConfig(projectRoot).instances;
50
- for (const id of Object.keys(declarations ?? {})) configured[id] = effectiveInstanceRoles(id, declarations, configured);
49
+ const { instances: declarations, catalog } = readInstanceConfig(projectRoot);
50
+ for (const id of Object.keys(declarations ?? {})) configured[id] = effectiveInstanceRoles(id, declarations, configured, catalog);
51
51
  } catch { /* Invalid declarations authorize no extra role; the daemon remains the write authority. */ }
52
52
  return configured;
53
53
  }
@@ -157,6 +157,8 @@ const offline = () => detached ?? "hub is not running for this project (start it
157
157
  * Surfaced on the next tool result; the pull itself still happens only through hub_inbox.
158
158
  */
159
159
  let pendingQueued: { queued: number; important: number } | undefined;
160
+ /** 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. */
161
+ const QUEUED_COUNTED = Symbol("queuedCounted");
160
162
  /** Fresh own-peer counts accompany all tool modes; only old plain sessions use advisory queued nudges. */
161
163
  async function withPendingHint<T>(result: T, recommendInbox = true): Promise<T> {
162
164
  const r = result as { content?: { type: string; text?: string }[] };
@@ -169,23 +171,28 @@ async function withPendingHint<T>(result: T, recommendInbox = true): Promise<T>
169
171
  };
170
172
  if (hub && r.content) {
171
173
  let freshCounts = false;
174
+ let hint = "";
172
175
  try {
173
176
  const response = await hub.request({ t: "delivery_holds" }, 1000);
177
+ // A hold that refuses pulls (#221): hub_inbox would only be refused again, so the counts stay and the advice goes.
178
+ hint = response?.ok ? renderHeldHint(response.held, Date.now(), !deliveryOnly) : "";
174
179
  if (response?.ok && Number.isSafeInteger(response.queued) && response.queued >= 0
175
180
  && Number.isSafeInteger(response.important) && response.important >= 0 && response.important <= response.queued) {
176
181
  freshCounts = true;
177
182
  pendingQueued = undefined; // A current zero must never resurrect an earlier queued nudge.
178
183
  if (response.important > 0) {
179
- const next = !recommendInbox ? "" : toolsMode
184
+ const next = !recommendInbox || hint ? "" : toolsMode
180
185
  ? ": use hub_inbox on a pull-capable peer session; otherwise finish the current turn to receive queued deliveries"
181
186
  : ": call hub_inbox before starting long local work";
182
- appendQueued(`(${response.queued} queued hub message(s), ${response.important} important hub message(s) waiting${next})`);
187
+ // One queued count per result (#221): a hub_inbox result that already says how many remain unread adds only the important count.
188
+ const counted = (result as { [QUEUED_COUNTED]?: boolean })[QUEUED_COUNTED] === true;
189
+ 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
190
  }
184
191
  }
185
- const hint = response?.ok ? renderHeldHint(response.held, Date.now(), !deliveryOnly) : "";
186
192
  if (hint) r.content.push({ type: "text", text: hint });
187
193
  } catch { /* advisory metadata can never change a saved tool result or its effect classification */ }
188
- if (!freshCounts && recommendInbox && !channel && !toolsMode && pendingQueued && pendingQueued.important > 0) {
194
+ // A pre-#493 daemon sends held rows without counts; the legacy nudge must not advise a read the hold refuses (#221).
195
+ if (!freshCounts && !hint && recommendInbox && !channel && !toolsMode && pendingQueued && pendingQueued.important > 0) {
189
196
  appendQueued(`(${pendingQueued.important} important hub message(s) waiting: call hub_inbox before starting long local work)`);
190
197
  }
191
198
  }
@@ -318,7 +325,7 @@ async function buildToolList() { return toolListFor(await activeLifecycle(stateD
318
325
 
319
326
  function toolListFor(lifecycle: Awaited<ReturnType<typeof activeLifecycle>>) {
320
327
  return {
321
- tools: [
328
+ tools: projectToolList([
322
329
  {
323
330
  name: "hub_send",
324
331
  description:
@@ -355,8 +362,8 @@ function toolListFor(lifecycle: Awaited<ReturnType<typeof activeLifecycle>>) {
355
362
  : []),
356
363
  ]),
357
364
  ...profileTaskTools(profile, lifecycle?.stages, lifecycle?.criteria).filter(tool => (lifecycle !== undefined || tool.name !== "hub_sdlc_record") && (!deliveryOnly || tool.name !== "hub_delivery_retry")),
358
- ...CONDUCTOR_TOOLS,
359
- ],
365
+ ...projectConductorTools(lifecycle?.startPeers),
366
+ ], lifecycle?.readOnly),
360
367
  };
361
368
  }
362
369
 
@@ -391,7 +398,9 @@ async function callTool(req: { params: { name: string; arguments?: Record<string
391
398
  // Only pushes without a journal delivery id enter this buffer. Pull completion belongs to the daemon.
392
399
  const out = [...inbox.splice(0), ...remote];
393
400
  if (notice) out.push(notice);
394
- return text(out.length ? out.join("\n\n") : "(no queued hub messages)");
401
+ const result = text(out.length ? out.join("\n\n") : "(no queued hub messages)");
402
+ if (notice.endsWith("remain unread)")) Object.defineProperty(result, QUEUED_COUNTED, { value: true });
403
+ return result;
395
404
  }
396
405
  if (name === "hub_send") {
397
406
  const { text: body, to, reply_to } = (args ?? {}) as { text?: string; to?: string[]; reply_to?: string };
@@ -406,7 +415,7 @@ async function callTool(req: { params: { name: string; arguments?: Record<string
406
415
  if (TASK_TOOL_NAMES.has(name) || CONDUCTOR_TOOL_NAMES.has(name)) {
407
416
  if (!hub) return text(offline());
408
417
  const res = await hub.request({ t: "task", op: name, args: args ?? {} });
409
- return text(res.ok ? res.text : `error: ${res.error}`);
418
+ return { ...text(res.ok ? res.text : `error: ${res.error}`), ...(res.ok ? {} : { isError: true }) };
410
419
  }
411
420
  return text(`unknown tool ${name}`);
412
421
  }
@@ -5,6 +5,9 @@ import type { PermissionMode } from "../hub/permission-mode.ts";
5
5
  import { BasePeer } from "../hub/peers.ts";
6
6
  import { codexContext, type ContextReading } from "../hub/context-window.ts";
7
7
  import { peerChildEnv, stopOwnedProcess, trackGroup } from "../hub/child-process.ts";
8
+ import type { ContextReader } from "./registry-types.ts";
9
+
10
+ const UNIDENTIFIED_CANCELLATION_NOTICE = "Codex cancelled turn has no verified native id; reattach the native session before further hub delivery";
8
11
 
9
12
  export interface CodexOptions {
10
13
  /** Port the TUI attaches to: `codex --enable tui_app_server --remote ws://127.0.0.1:<proxyPort>`. */
@@ -26,6 +29,8 @@ export interface CodexOptions {
26
29
  /** Tokens the thread used since the previous update (see `tokenTotal` for how they are counted). */
27
30
  onTokens?: (added: number) => void;
28
31
  onContext?: (reading: ContextReading) => void;
32
+ /** The definition's native occupancy reader; standalone proxies retain the native default. */
33
+ contextReader?: ContextReader;
29
34
  /** The native id of each turn as it starts, after the peer turned busy (issue #33: `ahub undo --context`). */
30
35
  onTurn?: (turnId: string) => void;
31
36
  /** Each thread the TUI starts (`fresh`) or resumes, as it is adopted (#215: which turns belong to which thread). */
@@ -90,6 +95,10 @@ export class CodexPeer extends BasePeer {
90
95
  /** Cancellation tombstones last until that exact native completion or session replacement. */
91
96
  private readonly retiredTurns = new Set<string>();
92
97
  private unidentifiedCancellation = false;
98
+ /** Fixed operator guidance only; native identifiers and agent text never enter status. */
99
+ get statusNotice(): { code: "unidentified-cancellation"; message: string } | undefined {
100
+ return this.unidentifiedCancellation ? { code: "unidentified-cancellation", message: UNIDENTIFIED_CANCELLATION_NOTICE } : undefined;
101
+ }
93
102
  private unboundAccepted = false;
94
103
  private nextId = -1;
95
104
  private readonly pending = new Map<number, { resolve: (result?: any) => void; reject: (e: Error) => void; deliveryId?: string; kind?: "deliver" | "steer"; expectedTurnId?: string; afterTurn?: () => void; invalidate?: (reason: string) => void }>();
@@ -342,7 +351,7 @@ export class CodexPeer extends BasePeer {
342
351
  // ponytail: after one unidentifiable cancellation, lifecycle attribution requires a session reattach.
343
352
  // Never guess that an unnamed late report or a later named start belongs to a new turn.
344
353
  this.unidentifiedCancellation = true;
345
- this.opts.log?.("Codex cancelled turn has no verified native id; reattach the native session before further hub delivery");
354
+ this.opts.log?.(UNIDENTIFIED_CANCELLATION_NOTICE);
346
355
  }
347
356
  for (const turnId of known) this.retireTurn(turnId);
348
357
  for (const pending of pendingStarts) pending.invalidate?.("Codex turn watchdog timeout before native acceptance was confirmed");
@@ -479,6 +488,7 @@ export class CodexPeer extends BasePeer {
479
488
  get proxyAttached(): boolean { return !!this.link && this.link.up.readyState === WebSocket.OPEN; }
480
489
 
481
490
  getPermissionMode(): PermissionMode { return this.permissionMode; }
491
+ get permissionModeState(): PermissionMode { return this.getPermissionMode(); }
482
492
 
483
493
  /** Reconcile the daemon's current choice after startup, before the returned proxy URL has a TUI. */
484
494
  setStartupPermissionMode(mode: PermissionMode): void {
@@ -634,7 +644,8 @@ export class CodexPeer extends BasePeer {
634
644
  if (this.unidentifiedCancellation) return; // native traffic is still forwarded to its TUI; hub attribution is fenced
635
645
  const unknownTurn = () => [...this.activeTurns].some(id => id.startsWith("unknown:"));
636
646
  if (method === "thread/tokenUsage/updated") {
637
- this.opts.onContext?.(codexContext(params.tokenUsage, this.threadId, Date.now()));
647
+ const reader = this.opts.contextReader;
648
+ this.opts.onContext?.(reader ? reader.read(params.tokenUsage, this.threadId, Date.now()) : codexContext(params.tokenUsage, this.threadId, Date.now()));
638
649
  const total = Number(params.tokenUsage?.total?.totalTokens);
639
650
  if (!Number.isFinite(total)) return;
640
651
  const added = this.tokenTotal === undefined ? (this.freshThread ? total : 0) : Math.max(0, total - this.tokenTotal);
@@ -707,3 +718,7 @@ function parse(raw: string): any {
707
718
  return undefined;
708
719
  }
709
720
  }
721
+
722
+ export function isCodexPeer(value: unknown): value is CodexPeer {
723
+ return value instanceof CodexPeer;
724
+ }
@@ -0,0 +1,124 @@
1
+ import { basename } from "node:path";
2
+ import { types } from "node:util";
3
+ import { AcpPeer } from "./acp.ts";
4
+ import type { AdapterDefinition } from "./registry-types.ts";
5
+ import type { PermissionMode } from "../hub/permission-mode.ts";
6
+ import { shellQuote } from "../cli/shell-quote.ts";
7
+
8
+ export interface ConfiguredAcpAdapterConfig {
9
+ transport: "acp";
10
+ command: string;
11
+ args?: readonly string[];
12
+ displayName: string;
13
+ roles: readonly string[];
14
+ identity?: { name: string; version: string };
15
+ permission?: Partial<Record<PermissionMode, string>>;
16
+ readOnly?: string;
17
+ }
18
+ const fail = (field: string, reason: string): never => { throw new Error(`${field}: ${reason}`); };
19
+ /** Core command namespace is reserved; configured kinds cannot shadow operational CLI actions. */
20
+ export const CONFIGURED_ACP_RESERVED_COMMANDS = Object.freeze(["help", "version", "completion", "projects", "ui", "manager", "setup", "init", "daemon", "up", "console", "recovery-run", "upgrade", "restart", "recovery", "models", "say", "queue", "watch", "tail", "budget", "backlog", "release", "board", "sdlc", "task", "review", "remember", "ask", "route", "pause", "resume", "stop", "permission", "roster", "settings", "permit", "pet", "status", "logs", "export", "bench", "memory", "goal", "research", "report", "facts", "check-path", "turns", "undo", "kill", "reset", "role", "doctor"]);
21
+ const MODES = ["ask", "ask-when-needed", "never-ask"];
22
+ const ROLES = ["planner", "implementer", "verifier", "reviewer"];
23
+ function fields(raw: unknown, at: string): Record<string, PropertyDescriptor> {
24
+ if (!raw || typeof raw !== "object" || types.isProxy(raw) || Array.isArray(raw) || ![Object.prototype, null].includes(Object.getPrototypeOf(raw)) || Object.getOwnPropertySymbols(raw).length) return fail(at, "must be a plain own-data object");
25
+ const result: Record<string, PropertyDescriptor> = Object.assign(Object.create(null), Object.getOwnPropertyDescriptors(raw));
26
+ if (Object.values(result).some(d => !Object.hasOwn(d, "value") || !d.enumerable)) return fail(at, "enumerable data fields required");
27
+ return result;
28
+ }
29
+ function text(raw: unknown, at: string, max = 4096): string {
30
+ if (typeof raw !== "string" || !raw.trim() || Buffer.byteLength(raw) > max || /[\p{Cc}\p{Cf}]/u.test(raw)) return fail(at, `must be nonempty control-free text of at most ${max} bytes`);
31
+ return raw;
32
+ }
33
+ function list(raw: unknown, at: string, max: number): string[] {
34
+ if (!Array.isArray(raw) || types.isProxy(raw) || Object.getPrototypeOf(raw) !== Array.prototype || Object.getOwnPropertySymbols(raw).length) return fail(at, "must be a string array");
35
+ const descriptors: Record<string, PropertyDescriptor> = Object.assign(Object.create(null), Object.getOwnPropertyDescriptors(raw));
36
+ const length = descriptors.length?.value;
37
+ if (!Number.isSafeInteger(length) || length > max || Object.keys(descriptors).length !== length + 1) return fail(at, "array is sparse or too large");
38
+ return Array.from({ length }, (_, index) => {
39
+ const d = descriptors[String(index)];
40
+ if (!d || !Object.hasOwn(d, "value") || !d.enumerable) return fail(at, "array data fields required");
41
+ return text(d.value, `${at}.${index}`);
42
+ });
43
+ }
44
+ function freeze<T>(value: T): T {
45
+ if (value && typeof value === "object") { for (const item of Object.values(value)) freeze(item); Object.freeze(value); }
46
+ return value;
47
+ }
48
+
49
+ /** Pure local declaration compiler; file provenance and runtime process ownership remain host checks. */
50
+ export function configuredAcpDefinitions(raw: unknown, builtinKinds: readonly string[]): readonly AdapterDefinition[] {
51
+ if (raw === undefined) return Object.freeze([]);
52
+ const declarations = fields(raw, "adapters");
53
+ if (Object.keys(declarations).length > 64) return fail("adapters", "at most 64 configured kinds supported");
54
+ const reserved = new Set([...builtinKinds, ...CONFIGURED_ACP_RESERVED_COMMANDS, "user", "hub", "digest", "constructor", "prototype", "__proto__"]);
55
+ return Object.freeze(Object.entries(declarations).map(([kind, descriptor]) => {
56
+ const at = `adapters.${kind}`;
57
+ if (!/^[a-z][a-z0-9-]{0,31}$/.test(kind) || reserved.has(kind)) return fail(at, "kind is invalid, reserved or collides with a builtin");
58
+ const input = fields(descriptor.value, at);
59
+ if (Object.keys(input).some(key => !["transport", "command", "args", "displayName", "roles", "identity", "permission", "readOnly"].includes(key))) return fail(at, "unknown or privileged field");
60
+ if (input.transport?.value !== "acp") return fail(`${at}.transport`, "must be acp");
61
+ const command = text(input.command?.value, `${at}.command`), args = input.args ? list(input.args.value, `${at}.args`, 128) : [];
62
+ // The supported contract is a foreground owned stdio process, never an attachment/proxy selector.
63
+ if ([command, ...args].some(arg => /^--(?:leader|attach|connect|remote)(?:-|=|$)/.test(arg))) return fail(`${at}.args`, "attach/leader selectors are unsupported; command must run foreground owned ACP stdio");
64
+ const displayName = text(input.displayName?.value, `${at}.displayName`, 256), roles = list(input.roles?.value, `${at}.roles`, ROLES.length);
65
+ if (roles.includes("conductor")) return fail(`${at}.roles`, "conductor requires explicit project role policy, not adapter defaults");
66
+ if (!roles.length || new Set(roles).size !== roles.length || roles.some(role => !ROLES.includes(role))) return fail(`${at}.roles`, "roles must be nonempty, known and unique");
67
+ let identity: ConfiguredAcpAdapterConfig["identity"];
68
+ if (input.identity) {
69
+ const value = fields(input.identity.value, `${at}.identity`);
70
+ if (Object.keys(value).some(key => !["name", "version"].includes(key))) return fail(`${at}.identity`, "unknown identity field");
71
+ identity = { name: text(value.name?.value, `${at}.identity.name`, 256), version: text(value.version?.value, `${at}.identity.version`, 256) };
72
+ }
73
+ const grokContract = kind === "grok" || /^grok(?:\.exe)?$/i.test(basename(command)) || identity?.name.toLowerCase() === "grok";
74
+ const noLeader = args.indexOf("--no-leader"), endOptions = args.indexOf("--");
75
+ if (grokContract && (noLeader < 0 || (endOptions >= 0 && noLeader > endOptions) || args.some(arg => arg.startsWith("--no-leader=")))) {
76
+ return fail(`${at}.args`, "Grok requires explicit --no-leader before argv termination; valued opt-outs are unsupported");
77
+ }
78
+ let modes: ConfiguredAcpAdapterConfig["permission"];
79
+ if (input.permission) {
80
+ const value = fields(input.permission.value, `${at}.permission`);
81
+ modes = {};
82
+ for (const [mode, d] of Object.entries(value)) {
83
+ if (!MODES.includes(mode)) return fail(`${at}.permission.${mode}`, "unknown hub mode");
84
+ modes[mode as PermissionMode] = text(d.value, `${at}.permission.${mode}`, 256);
85
+ }
86
+ if (!Object.keys(modes).length) modes = undefined; // `permission: {}` maps nothing: unmanaged, exactly like an omitted map
87
+ }
88
+ const readOnly = input.readOnly ? text(input.readOnly.value, `${at}.readOnly`, 256) : undefined;
89
+ const nativePolicy = freeze({ ...(modes ? { modes } : {}), ...(readOnly ? { readOnly } : {}) });
90
+ const argv = freeze([command, ...args]);
91
+ identity = identity && freeze(identity);
92
+ const definition: AdapterDefinition = {
93
+ api: 1, kind, family: kind, displayName, transport: "acp", roles, optional: true, defaultOnRoster: false,
94
+ trust: { onPrem: false, hubSandboxed: false }, identity: { mcp: "acp-session" }, instances: { mode: "headless" },
95
+ start: {
96
+ daemonPort: "acp", forms: ["headless"], defaultForm: "headless", conductorStartable: true, hubSwitches: [], ownedFlags: [],
97
+ exclusiveStartRefusal: "configured ACP start already in progress; retry after it settles",
98
+ preflight: request => request.form !== undefined && request.form !== "headless" ? `${kind}: configured ACP supports headless only`
99
+ : !identity ? `${kind}: native identity is unverified; configure the observed name/version pin before starting` : undefined,
100
+ launch: context => {
101
+ if (context.form !== "headless") throw new Error(`${kind}: configured ACP supports headless only`);
102
+ return { cmd: command, args: [...args], metadata: { packageEntrypoint: context.entrypoint, argv: [...argv], command: argv.map(shellQuote).join(" "), env: {} } };
103
+ },
104
+ create: context => {
105
+ if (!("acp" in context.daemon)) throw new Error(`${kind}: ACP transport options required`);
106
+ if (context.permission === "read-only" && !readOnly) throw new Error(`${kind}: read-only native mode is not configured`);
107
+ if (!identity) throw new Error(`${kind}: native identity is unverified; configure the observed name/version pin before starting`);
108
+ return new AcpPeer(context.instance, { ...context.daemon.acp, kind, cmd: [...argv], qwen025: false,
109
+ expectedAgent: identity, requireLoadSessionEcho: true, requireOwnedGroup: true,
110
+ permissionMode: context.permission === "read-only" ? "ask" : context.permission,
111
+ nativePolicy: { ...nativePolicy, lockedReadOnly: context.permission === "read-only" }, mcpServers: context.mcpServers });
112
+ },
113
+ conductorStartArgs: args => args.length ? new Error("configured ACP starts accept no launch overrides") : {},
114
+ },
115
+ permission: { genericAcp: nativePolicy, grants: Object.fromEntries(MODES.map(mode => [mode, modes?.[mode as PermissionMode] ? `${kind}: asks native ACP to select configured ${mode} mode; native tool policy applies; NO hub sandbox` : `${kind}: no configured native mapping for ${mode}; NO hub sandbox`])), ...(!modes || !Object.keys(modes).length ? { unmanaged: `${kind}: native policy unmanaged; no configured permission map; NO hub sandbox` } : {}) },
116
+ // ponytail: configured ACP has no verified controlled-recovery driver. Add catalog-bound restoration
117
+ // with native session/group receipts before enabling controlled recovery; explicit/crash load stays fenced.
118
+ session: { idField: "sessionId", currentId: peer => { const id = peer.recoveryMetadata?.().sessionId; return typeof id === "string" ? id : undefined; }, launchKind: "acp", resumeById: true, recoveryRefusal: () => "configured ACP has no verified controlled-recovery driver; stop it explicitly and restart with its recorded native session id" },
119
+ turnEnd: "adapter", cli: { command: kind, launchConfigKeys: [`adapters.${kind}.command`, `adapters.${kind}.args`], startWords: () => [kind] },
120
+ display: { palette: { fg: "white", bg: "black" } },
121
+ };
122
+ return freeze(definition);
123
+ }));
124
+ }
@@ -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
+ }
@@ -0,0 +1,191 @@
1
+ import { expect, test } from "bun:test";
2
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import type { CodexPeer } from "../codex-appserver.ts";
6
+ import { newEnvelope } from "../../hub/envelope.ts";
7
+ import type { DeliveryReceipt } from "../../hub/peers.ts";
8
+ import { startFakeAppServer } from "./fakes/app-server.ts";
9
+ import { processTable } from "../../hub/child-process.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
+ /** Register only when called; native protocol and processes stay inside the test bodies. */
16
+ export function registerCodexConformance(definition: AdapterDefinition, { label = definition.displayName }: { label?: string } = {}): void {
17
+ if (definition.start.daemonPort !== "codex-appserver") throw new Error(definition.kind + ": conformance requires codex-appserver transport factory");
18
+ async function fixture(fake: Pick<ReturnType<typeof startFakeAppServer>, "url" | "requests" | "stop"> = startFakeAppServer()) {
19
+ const dir = mkdtempSync(join(tmpdir(), "conformance-codex-")), messages: string[] = [], receipts: DeliveryReceipt[] = [], usage: boolean[] = [];
20
+ const peer = createFromDefinition<CodexPeer>(definition, { codex: { cwd: dir, upstreamUrl: fake.url, appPort: 0, proxyPort: 0, onUsage: (_reading, hard) => { usage.push(hard); } } });
21
+ peer.onMessage = text => { messages.push(text); }; peer.onDelivery = receipt => { receipts.push(receipt); };
22
+ await peer.start();
23
+ const seen: any[] = [], tui = new WebSocket(peer.proxyUrl);
24
+ tui.onmessage = event => seen.push(JSON.parse(String(event.data)));
25
+ await new Promise<void>((resolve, reject) => { tui.onopen = () => resolve(); tui.onerror = () => reject(new Error("fake Codex TUI connection")); });
26
+ tui.send(JSON.stringify({ id: 1, method: "initialize", params: { clientInfo: { name: "conformance-tui" } } }));
27
+ await until(() => seen.some(row => row.id === 1), "Codex initialize consumed");
28
+ tui.send(JSON.stringify({ id: 2, method: "thread/start", params: { approvalPolicy: "untrusted" } }));
29
+ await until(() => peer.state === "idle", "Codex thread adopted");
30
+ return { peer, fake, messages, receipts, usage, tui, seen, async close() { try { tui.close(); await peer.stop(); } finally { fake.stop(); rmSync(dir, { recursive: true, force: true }); } } };
31
+ }
32
+
33
+ test(`P4 ${label}: permission overlays reach real turn RPC and ask restores the native baseline`, async () => {
34
+ const f = await fixture();
35
+ try {
36
+ for (const [mode, policy] of [["never-ask", "never"], ["ask", "untrusted"], ["ask-when-needed", "on-request"], ["ask", "untrusted"]] as const) {
37
+ await f.peer.setPermissionMode(mode);
38
+ const completed = f.messages.length;
39
+ await f.peer.deliver([newEnvelope("user", `mode ${mode}`)]);
40
+ await until(() => f.messages.length > completed && f.peer.state === "idle", "Codex policy turn settled");
41
+ expect(f.fake.requests.filter(row => row.method === "turn/start").at(-1).params.approvalPolicy).toBe(policy);
42
+ }
43
+ } finally { await f.close(); }
44
+ });
45
+
46
+ test(`P4 ${label}: rejected admission is failed_safe; accepted quota failure remains needs_review`, async () => {
47
+ const f = await fixture();
48
+ try {
49
+ const refusal = f.peer.deliver([newEnvelope("user", "REFUSE_TURN")], "refusal").catch(error => error);
50
+ await refusal;
51
+ await until(() => f.receipts.some(receipt => receipt.id === "refusal"), "definitive rejection receipt");
52
+ expect(f.receipts.find(receipt => receipt.id === "refusal")?.state).toBe("failed_safe");
53
+ await until(() => f.peer.state === "idle", "Codex refusal releases occupancy");
54
+ await f.peer.deliver([newEnvelope("user", "QUOTA")], "quota");
55
+ await until(() => f.receipts.some(receipt => receipt.id === "quota" && receipt.state === "needs_review"), "accepted quota failure");
56
+ expect(f.usage).toContain(true);
57
+ expect(f.receipts.filter(receipt => receipt.id === "quota").map(receipt => receipt.state)).toEqual(["accepted", "needs_review"]);
58
+ expect(f.messages).toEqual([]);
59
+ } finally { await f.close(); }
60
+ });
61
+
62
+ test(`P4 ${label}: watchdog fences a late result after observed native acceptance`, async () => {
63
+ const f = await fixture();
64
+ try {
65
+ await f.peer.deliver([newEnvelope("user", "SLOW:100")], "old");
66
+ await until(() => f.receipts.some(receipt => receipt.state === "accepted"), "Codex native acceptance");
67
+ expire(f.peer);
68
+ // Forwarded turn/completed is a transport consumption barrier, not a guessed grace delay.
69
+ await until(() => f.seen.some(row => row.method === "turn/completed"), "late result consumed");
70
+ expect(f.messages).toEqual([]);
71
+ expect(f.receipts.map(receipt => receipt.state)).toEqual(["accepted", "needs_review"]);
72
+ expect(f.peer.state).toBe("idle");
73
+ } finally { await f.close(); }
74
+ });
75
+
76
+ test(`P4 ${label}: stopping owns the launcher's group and its native app-server child`, async () => {
77
+ const dir = mkdtempSync(join(tmpdir(), "conformance-codex-group-")), bin = join(dir, "codex"), launcher = join(dir, "launcher.pid"), native = join(dir, "native.pid");
78
+ const quote = (value: string) => "'" + value.replaceAll("'", "'\\''") + "'";
79
+ writeFileSync(bin, `#!/bin/sh\necho $$ > ${quote(launcher)}\ntrap '' TERM\n${quote(process.execPath)} ${quote(fakePaths.codexBin)} "$@" &\necho $! > ${quote(native)}\nwhile :; do sleep 1; done\n`, { mode: 0o755 });
80
+ const reservation = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response() });
81
+ const appPort = reservation.port!; reservation.stop(true);
82
+ const peer = createFromDefinition<CodexPeer>(definition, { codex: { cwd: dir, bin, appPort, proxyPort: 0 } });
83
+ try {
84
+ await peer.start();
85
+ const leader = Number(readFileSync(launcher, "utf8")), child = Number(readFileSync(native, "utf8"));
86
+ const before = processTable(); expect(before).toBeDefined();
87
+ expect(before!.some(row => row.pid === child && row.pgid === leader)).toBe(true);
88
+ await peer.stop();
89
+ const after = processTable(); expect(after).toBeDefined();
90
+ expect(after!.some(row => row.pid === child || row.pid === leader || row.pgid === leader)).toBe(false);
91
+ } finally { await peer.stop(); rmSync(dir, { recursive: true, force: true }); }
92
+ }, 20_000);
93
+
94
+ /** Every release and the consuming-proxy barrier are controlled by the test, never by wall-clock sleeps. */
95
+ function controlledAppServer() {
96
+ const requests: any[] = [];
97
+ let socket: { send(data: string): unknown } | undefined;
98
+ const send = (message: unknown) => { if (!socket) throw new Error("controlled app-server not attached"); socket.send(JSON.stringify(message)); };
99
+ const server = Bun.serve({ hostname: "127.0.0.1", port: 0,
100
+ fetch(request, server) { return server.upgrade(request) ? undefined : new Response("not a websocket", { status: 400 }); },
101
+ websocket: { message(ws, raw) {
102
+ socket = ws;
103
+ const request = JSON.parse(String(raw)); requests.push(request);
104
+ if (request.method === "initialize") send({ id: request.id, result: { userAgent: "fake-codex/0.154.0" } });
105
+ else if (request.method === "thread/start" || request.method === "thread/resume") send({ id: request.id, result: { thread: { id: request.params.threadId ?? "th1" }, approvalPolicy: "untrusted" } });
106
+ else if (request.method === "account/rateLimits/read") send({ id: request.id, result: { rateLimits: {} } });
107
+ else if (request.method === "conformance/barrier") send({ id: request.id, result: {} });
108
+ } },
109
+ });
110
+ return { url: `ws://127.0.0.1:${server.port}`, requests, stop: () => server.stop(true), send,
111
+ note(method: string, params: Record<string, unknown>) { send({ method, params: { threadId: "th1", ...params } }); },
112
+ };
113
+ }
114
+ async function barrier(f: Awaited<ReturnType<typeof fixture>>, id: number): Promise<void> {
115
+ f.tui.send(JSON.stringify({ id, method: "conformance/barrier", params: {} }));
116
+ await until(() => f.seen.some(row => row.id === id), "ordered controlled-app-server barrier consumed");
117
+ }
118
+
119
+ test(`P4 ${label}: accepted-before-start watchdog tombstone survives delayed native start until exact completion`, async () => {
120
+ const native = controlledAppServer(), f = await fixture(native);
121
+ try {
122
+ const delivery = f.peer.deliver([newEnvelope("user", "old accepted prompt")], "old-accepted");
123
+ await until(() => native.requests.some(row => row.method === "turn/start"), "native turn request observed");
124
+ const request = native.requests.find(row => row.method === "turn/start");
125
+ native.send({ id: request.id, result: { turn: { id: "retired" } } });
126
+ await delivery; // The fake has deliberately emitted no turn/started.
127
+ expire(f.peer);
128
+ native.note("turn/started", { turn: { id: "retired" } });
129
+ native.note("item/completed", { turnId: "retired", item: { id: "old-answer", type: "agentMessage", text: "must not publish" } });
130
+ await barrier(f, 50);
131
+ expect(f.peer.state).toBe("idle"); expect(f.messages).toEqual([]);
132
+ expect(f.receipts.map(receipt => receipt.state)).toEqual(["accepted", "needs_review"]);
133
+ native.note("turn/completed", { turn: { id: "retired", status: "completed" } });
134
+ await barrier(f, 51);
135
+ expect(f.messages).toEqual([]); expect(f.receipts).toHaveLength(2);
136
+ } finally { await f.close(); }
137
+ });
138
+
139
+ for (const acceptance of [true, false]) test(`P4 ${label}: invalidated pending ${acceptance ? "acceptance" : "refusal"} never contaminates a newer personal turn or duplicates uncertainty`, async () => {
140
+ const native = controlledAppServer(), f = await fixture(native);
141
+ try {
142
+ const delivery = f.peer.deliver([newEnvelope("user", "cancelled audience")], "pending").catch(error => error as Error);
143
+ await until(() => native.requests.some(row => row.method === "turn/start"), "pending start observed");
144
+ const request = native.requests.find(row => row.method === "turn/start");
145
+ native.note("turn/started", { turn: { id: "old-pending" } });
146
+ await barrier(f, 60);
147
+ expire(f.peer);
148
+ expect((await delivery as Error).message).toContain("watchdog");
149
+ expect(f.receipts.map(receipt => receipt.state)).toEqual(["needs_review"]);
150
+ native.note("turn/started", { turn: { id: "personal-current" } });
151
+ await barrier(f, 61);
152
+ expect(f.peer.state).toBe("busy");
153
+ native.send(acceptance ? { id: request.id, result: { turn: { id: "old-pending" } } } : { id: request.id, error: { message: "turn rejected" } });
154
+ native.note("item/completed", { turnId: "old-pending", item: { id: "old-answer", type: "agentMessage", text: "stale answer" } });
155
+ native.note("turn/completed", { turn: { id: "old-pending", status: "completed" } });
156
+ await barrier(f, 62);
157
+ expect(f.peer.state).toBe("busy"); expect(f.messages).toEqual([]);
158
+ expect(f.receipts.map(receipt => receipt.state)).toEqual(["needs_review"]);
159
+ const replies: unknown[] = [];
160
+ f.peer.onMessage = (text, options) => { f.messages.push(text); replies.push(options?.inReplyTo); };
161
+ native.note("item/completed", { turnId: "personal-current", item: { id: "current-answer", type: "agentMessage", text: "current personal answer" } });
162
+ native.note("turn/completed", { turn: { id: "personal-current", status: "completed" } });
163
+ await barrier(f, 63);
164
+ expect(f.messages).toEqual(["current personal answer"]); expect(replies).toEqual([undefined]);
165
+ expect(f.peer.state).toBe("idle"); expect(f.receipts).toHaveLength(1);
166
+ } finally { await f.close(); }
167
+ });
168
+
169
+ test(`P4 ${label}: legacy unnamed turn works until cancellation makes its identity ambiguous`, async () => {
170
+ const native = controlledAppServer(), f = await fixture(native);
171
+ try {
172
+ native.note("turn/started", { turn: {} });
173
+ native.note("item/completed", { turnId: "legacy", item: { id: "legacy-answer", type: "agentMessage", text: "legacy answer" } });
174
+ native.note("turn/completed", { turn: { id: "legacy", status: "completed" } });
175
+ await barrier(f, 70);
176
+ expect(f.messages).toEqual(["legacy answer"]); expect(f.peer.state).toBe("idle");
177
+ native.note("turn/started", { turn: {} }); await barrier(f, 71);
178
+ expire(f.peer); expect(f.peer.state).toBe("offline");
179
+ native.note("turn/started", { turn: { id: "unattributable-late" } });
180
+ native.note("item/completed", { item: { id: "late", type: "agentMessage", text: "unattributable answer" } });
181
+ native.note("turn/completed", { turn: { id: "unattributable-late", status: "completed" } });
182
+ await barrier(f, 72);
183
+ expect(f.messages).toEqual(["legacy answer"]); expect(f.peer.state).toBe("offline");
184
+ // Adopting a verified session again is the explicit boundary that clears the attribution ceiling.
185
+ f.tui.send(JSON.stringify({ id: 73, method: "thread/resume", params: { threadId: "th1" } }));
186
+ await until(() => f.seen.some(row => row.id === 73), "session reattachment consumed");
187
+ expect(f.peer.state).toBe("idle");
188
+ } finally { await f.close(); }
189
+ });
190
+
191
+ }