agent-dealer 0.2.0 → 1.0.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 (276) hide show
  1. package/bundle/server/dist/adapters/agent-deck-bind.js +443 -0
  2. package/bundle/server/dist/adapters/agent-deck-bind.test.js +454 -0
  3. package/bundle/server/dist/adapters/agent-deck.js +38 -50
  4. package/bundle/server/dist/adapters/agent-deck.test.js +63 -0
  5. package/bundle/server/dist/adapters/agent-health.js +230 -33
  6. package/bundle/server/dist/adapters/agent-health.test.js +237 -0
  7. package/bundle/server/dist/adapters/authority-lifecycle.js +289 -0
  8. package/bundle/server/dist/adapters/codex-scoped-config.js +103 -0
  9. package/bundle/server/dist/adapters/execution-authority.js +103 -0
  10. package/bundle/server/dist/adapters/execution-authority.test.js +166 -0
  11. package/bundle/server/dist/adapters/external.js +0 -1
  12. package/bundle/server/dist/adapters/git-worktree.js +514 -0
  13. package/bundle/server/dist/adapters/git-worktree.test.js +502 -0
  14. package/bundle/server/dist/adapters/github.js +170 -0
  15. package/bundle/server/dist/adapters/github.test.js +205 -0
  16. package/bundle/server/dist/adapters/linear-inbox.js +199 -30
  17. package/bundle/server/dist/adapters/linear-inbox.test.js +35 -0
  18. package/bundle/server/dist/adapters/linear-sync.js +3 -101
  19. package/bundle/server/dist/adapters/managed-repo.js +168 -0
  20. package/bundle/server/dist/adapters/managed-repo.test.js +63 -0
  21. package/bundle/server/dist/adapters/outbound-delivery.js +83 -0
  22. package/bundle/server/dist/adapters/outbound-delivery.test.js +53 -0
  23. package/bundle/server/dist/adapters/reflect-authority.js +43 -0
  24. package/bundle/server/dist/cli-env.js +26 -0
  25. package/bundle/server/dist/cli-env.test.js +14 -1
  26. package/bundle/server/dist/coordinator/abort.integration.test.js +74 -0
  27. package/bundle/server/dist/coordinator/admission.js +330 -0
  28. package/bundle/server/dist/coordinator/admission.test.js +338 -0
  29. package/bundle/server/dist/coordinator/args.js +128 -0
  30. package/bundle/server/dist/coordinator/args.test.js +132 -0
  31. package/bundle/server/dist/coordinator/authority-recovery.integration.test.js +661 -0
  32. package/bundle/server/dist/coordinator/auto-merge.integration.test.js +363 -0
  33. package/bundle/server/dist/coordinator/auto-merge.js +286 -0
  34. package/bundle/server/dist/coordinator/auto-merge.timeout.test.js +14 -0
  35. package/bundle/server/dist/coordinator/branch-progress.js +81 -0
  36. package/bundle/server/dist/coordinator/clock-jump.js +98 -0
  37. package/bundle/server/dist/coordinator/commands.js +1088 -0
  38. package/bundle/server/dist/coordinator/commands.test.js +554 -0
  39. package/bundle/server/dist/coordinator/deck-outage-config.js +32 -0
  40. package/bundle/server/dist/coordinator/deck-outage.integration.test.js +200 -0
  41. package/bundle/server/dist/coordinator/dependencies.js +242 -0
  42. package/bundle/server/dist/coordinator/dependency-readiness.integration.test.js +535 -0
  43. package/bundle/server/dist/coordinator/developer-effect.js +959 -0
  44. package/bundle/server/dist/coordinator/developer-effect.test.js +1143 -0
  45. package/bundle/server/dist/coordinator/dispatcher.js +58 -0
  46. package/bundle/server/dist/coordinator/dispatcher.test.js +81 -0
  47. package/bundle/server/dist/coordinator/effect-registry.js +17 -0
  48. package/bundle/server/dist/coordinator/execution-env.integration.test.js +122 -0
  49. package/bundle/server/dist/coordinator/failure-reason.js +273 -0
  50. package/bundle/server/dist/coordinator/failure-reason.test.js +234 -0
  51. package/bundle/server/dist/coordinator/guidance.js +33 -0
  52. package/bundle/server/dist/coordinator/guidance.test.js +110 -0
  53. package/bundle/server/dist/coordinator/human-resolution.js +88 -0
  54. package/bundle/server/dist/coordinator/human-resolution.test.js +72 -0
  55. package/bundle/server/dist/coordinator/intent-forecast.js +13 -0
  56. package/bundle/server/dist/coordinator/intent-forecast.test.js +34 -0
  57. package/bundle/server/dist/coordinator/latest-failure.js +100 -0
  58. package/bundle/server/dist/coordinator/latest-failure.test.js +125 -0
  59. package/bundle/server/dist/coordinator/metrics.js +35 -0
  60. package/bundle/server/dist/coordinator/metrics.test.js +52 -0
  61. package/bundle/server/dist/coordinator/permissions.js +127 -0
  62. package/bundle/server/dist/coordinator/permissions.test.js +184 -0
  63. package/bundle/server/dist/coordinator/presumed-dead-budget.integration.test.js +146 -0
  64. package/bundle/server/dist/coordinator/process-liveness.js +191 -0
  65. package/bundle/server/dist/coordinator/profile-snapshot.js +20 -0
  66. package/bundle/server/dist/coordinator/profile-snapshot.test.js +118 -0
  67. package/bundle/server/dist/coordinator/projection.js +147 -0
  68. package/bundle/server/dist/coordinator/projection.test.js +121 -0
  69. package/bundle/server/dist/coordinator/prompts.js +164 -0
  70. package/bundle/server/dist/coordinator/prompts.test.js +177 -0
  71. package/bundle/server/dist/coordinator/queue-by-default.integration.test.js +271 -0
  72. package/bundle/server/dist/coordinator/reclaim-republish.integration.test.js +372 -0
  73. package/bundle/server/dist/coordinator/recovery.js +466 -0
  74. package/bundle/server/dist/coordinator/recovery.test.js +164 -0
  75. package/bundle/server/dist/coordinator/reflect-trigger.js +218 -0
  76. package/bundle/server/dist/coordinator/reflect-trigger.test.js +324 -0
  77. package/bundle/server/dist/coordinator/repair-cycle.integration.test.js +234 -0
  78. package/bundle/server/dist/coordinator/restart-recovery.integration.test.js +363 -0
  79. package/bundle/server/dist/coordinator/reviewer-effect.js +507 -0
  80. package/bundle/server/dist/coordinator/reviewer-effect.test.js +612 -0
  81. package/bundle/server/dist/coordinator/reviewer-result.js +38 -0
  82. package/bundle/server/dist/coordinator/reviewer-result.test.js +45 -0
  83. package/bundle/server/dist/coordinator/routing.js +160 -0
  84. package/bundle/server/dist/coordinator/routing.test.js +336 -0
  85. package/bundle/server/dist/coordinator/session-lifecycle.integration.test.js +127 -0
  86. package/bundle/server/dist/coordinator/session-lifecycle.js +280 -0
  87. package/bundle/server/dist/coordinator/session-lifecycle.test.js +121 -0
  88. package/bundle/server/dist/coordinator/session-progress.js +351 -0
  89. package/bundle/server/dist/coordinator/session-progress.test.js +190 -0
  90. package/bundle/server/dist/coordinator/session-timeouts.js +33 -0
  91. package/bundle/server/dist/coordinator/sleep-recovery.integration.test.js +233 -0
  92. package/bundle/server/dist/coordinator/spawn.js +49 -0
  93. package/bundle/server/dist/coordinator/sync-issue-base-branch.js +18 -0
  94. package/bundle/server/dist/coordinator/usage-cap-config.js +10 -0
  95. package/bundle/server/dist/coordinator/usage-cap-defer.js +189 -0
  96. package/bundle/server/dist/coordinator/usage-cap.integration.test.js +193 -0
  97. package/bundle/server/dist/coordinator/usage.js +40 -0
  98. package/bundle/server/dist/coordinator/usage.test.js +69 -0
  99. package/bundle/server/dist/coordinator/verification-receipt.js +333 -0
  100. package/bundle/server/dist/coordinator/verification-receipt.test.js +382 -0
  101. package/bundle/server/dist/coordinator/worker-loop.js +363 -0
  102. package/bundle/server/dist/coordinator/worker-loop.test.js +327 -0
  103. package/bundle/server/dist/coordinator/workflow-instances.test.js +45 -0
  104. package/bundle/server/dist/coordinator/worktree-owner-liveness.js +82 -0
  105. package/bundle/server/dist/coordinator/worktree-owner-liveness.test.js +199 -0
  106. package/bundle/server/dist/db/artifacts-issue-columns.test.js +18 -0
  107. package/bundle/server/dist/db/authority-attempts-stale-at-migration.test.js +65 -0
  108. package/bundle/server/dist/db/idempotency-index-migration.test.js +57 -0
  109. package/bundle/server/dist/db/index.js +359 -7
  110. package/bundle/server/dist/db/linear-inbox-unlock.test.js +67 -0
  111. package/bundle/server/dist/db/migrate-to-issues.js +824 -0
  112. package/bundle/server/dist/db/migrate-to-issues.test.js +842 -0
  113. package/bundle/server/dist/db/schema.sql +311 -1
  114. package/bundle/server/dist/db/schema.test.js +66 -0
  115. package/bundle/server/dist/db/worker-session-pid-migration.test.js +112 -0
  116. package/bundle/server/dist/dev-review-cli-happy-path.integration.test.js +338 -0
  117. package/bundle/server/dist/direct-start-liveness.integration.test.js +350 -0
  118. package/bundle/server/dist/index.js +58 -5
  119. package/bundle/server/dist/paths.js +40 -4
  120. package/bundle/server/dist/paths.test.js +42 -0
  121. package/bundle/server/dist/pid-marker-claim-child.js +18 -0
  122. package/bundle/server/dist/pid-marker-hold-arbiter-child.js +24 -0
  123. package/bundle/server/dist/pid-marker.js +141 -0
  124. package/bundle/server/dist/pid-marker.test.js +208 -0
  125. package/bundle/server/dist/queue/approve-deliver.js +128 -28
  126. package/bundle/server/dist/queue/reflect-schedule.js +25 -0
  127. package/bundle/server/dist/queue/result-qa.js +1 -1
  128. package/bundle/server/dist/queue/send-gate.test.js +172 -36
  129. package/bundle/server/dist/repository/agents.js +55 -18
  130. package/bundle/server/dist/repository/artifacts-for-issue.js +42 -0
  131. package/bundle/server/dist/repository/artifacts-for-issue.test.js +48 -0
  132. package/bundle/server/dist/repository/artifacts.js +47 -0
  133. package/bundle/server/dist/repository/artifacts.test.js +48 -0
  134. package/bundle/server/dist/repository/authority-attempts.js +189 -0
  135. package/bundle/server/dist/repository/findings.js +70 -0
  136. package/bundle/server/dist/repository/findings.test.js +68 -0
  137. package/bundle/server/dist/repository/human-actions.js +142 -0
  138. package/bundle/server/dist/repository/human-actions.test.js +123 -0
  139. package/bundle/server/dist/repository/intake-settings.js +5 -59
  140. package/bundle/server/dist/repository/issues.js +305 -0
  141. package/bundle/server/dist/repository/issues.test.js +69 -0
  142. package/bundle/server/dist/repository/outbound-drafts.js +28 -0
  143. package/bundle/server/dist/repository/queue-entries.js +162 -0
  144. package/bundle/server/dist/repository/review-publications.js +153 -0
  145. package/bundle/server/dist/repository/review-publications.test.js +78 -0
  146. package/bundle/server/dist/repository/runs-external-id.test.js +5 -3
  147. package/bundle/server/dist/repository/runs.js +4 -211
  148. package/bundle/server/dist/repository/runtime-availability.js +54 -0
  149. package/bundle/server/dist/repository/usage-events.js +60 -0
  150. package/bundle/server/dist/repository/usage-events.test.js +56 -0
  151. package/bundle/server/dist/repository/work-items.js +305 -0
  152. package/bundle/server/dist/repository/work-items.test.js +142 -0
  153. package/bundle/server/dist/repository/worker-sessions.js +219 -0
  154. package/bundle/server/dist/repository/worker-sessions.test.js +60 -0
  155. package/bundle/server/dist/repository/workflow-events.js +193 -0
  156. package/bundle/server/dist/repository/workflow-events.test.js +89 -0
  157. package/bundle/server/dist/resolve-tsx-bin.js +19 -0
  158. package/bundle/server/dist/routes/human-actions.js +68 -0
  159. package/bundle/server/dist/routes/human-actions.test.js +284 -0
  160. package/bundle/server/dist/routes/index.js +29 -496
  161. package/bundle/server/dist/routes/issues.js +240 -0
  162. package/bundle/server/dist/routes/issues.test.js +451 -0
  163. package/bundle/server/dist/routes/queue.js +41 -0
  164. package/bundle/server/dist/routes/queue.test.js +78 -0
  165. package/bundle/server/dist/runners/claude-args.js +18 -30
  166. package/bundle/server/dist/runners/claude.js +19 -163
  167. package/bundle/server/dist/runners/claude.test.js +15 -70
  168. package/bundle/server/dist/runners/codex-args.js +59 -0
  169. package/bundle/server/dist/runners/codex-args.test.js +72 -0
  170. package/bundle/server/dist/runners/codex-jsonl.js +150 -0
  171. package/bundle/server/dist/runners/codex-jsonl.test.js +36 -0
  172. package/bundle/server/dist/runners/codex-resume.test.js +19 -0
  173. package/bundle/server/dist/runners/codex.js +46 -0
  174. package/bundle/server/dist/runners/models-codex.test.js +14 -0
  175. package/bundle/server/dist/runners/models.js +53 -1
  176. package/bundle/server/dist/runners/persist.js +4 -1
  177. package/bundle/server/dist/runners/process-registry.js +11 -0
  178. package/bundle/server/dist/runners/prompts.js +13 -280
  179. package/bundle/server/dist/runners/prompts.test.js +22 -153
  180. package/bundle/server/dist/runners/qa.js +17 -1
  181. package/bundle/server/dist/runners/reflect.js +4 -16
  182. package/bundle/server/dist/runners/spawn-cli.js +197 -0
  183. package/bundle/server/dist/runners/spawn-cli.test.js +130 -0
  184. package/bundle/server/dist/runners/stream-json.js +0 -141
  185. package/bundle/server/dist/runners/usage-cap.js +242 -0
  186. package/bundle/server/dist/runners/usage-cap.test.js +97 -0
  187. package/bundle/server/dist/server-liveness.js +37 -0
  188. package/bundle/server/dist/server-liveness.test.js +82 -0
  189. package/bundle/server/dist/static-ui.test.js +44 -0
  190. package/bundle/server/package.json +4 -2
  191. package/bundle/server/static-ui/assets/index-BTtPGYpY.js +60 -0
  192. package/bundle/server/static-ui/assets/index-BxTx4b1d.css +1 -0
  193. package/bundle/server/static-ui/index.html +2 -2
  194. package/bundle/shared/dist/agents.d.ts +262 -133
  195. package/bundle/shared/dist/agents.js +85 -19
  196. package/bundle/shared/dist/cursor-auth-health.d.ts +12 -0
  197. package/bundle/shared/dist/cursor-auth-health.js +36 -0
  198. package/bundle/shared/dist/cursor-auth-health.test.d.ts +1 -0
  199. package/bundle/shared/dist/cursor-auth-health.test.js +40 -0
  200. package/bundle/shared/dist/findings.d.ts +46 -0
  201. package/bundle/shared/dist/findings.js +17 -0
  202. package/bundle/shared/dist/findings.test.d.ts +1 -0
  203. package/bundle/shared/dist/findings.test.js +20 -0
  204. package/bundle/shared/dist/github-repo.d.ts +28 -0
  205. package/bundle/shared/dist/github-repo.js +98 -0
  206. package/bundle/shared/dist/github-repo.test.d.ts +1 -0
  207. package/bundle/shared/dist/github-repo.test.js +32 -0
  208. package/bundle/shared/dist/human-actions.d.ts +66 -0
  209. package/bundle/shared/dist/human-actions.js +51 -0
  210. package/bundle/shared/dist/human-actions.test.d.ts +1 -0
  211. package/bundle/shared/dist/human-actions.test.js +57 -0
  212. package/bundle/shared/dist/index.d.ts +567 -757
  213. package/bundle/shared/dist/index.js +22 -36
  214. package/bundle/shared/dist/issues.d.ts +385 -0
  215. package/bundle/shared/dist/issues.js +169 -0
  216. package/bundle/shared/dist/issues.test.d.ts +1 -0
  217. package/bundle/shared/dist/issues.test.js +72 -0
  218. package/bundle/shared/dist/outbound-draft.d.ts +6 -0
  219. package/bundle/shared/dist/outbound-draft.js +4 -0
  220. package/bundle/shared/dist/plan-triage.d.ts +6 -6
  221. package/bundle/shared/dist/profile-snapshot.d.ts +154 -0
  222. package/bundle/shared/dist/profile-snapshot.js +145 -0
  223. package/bundle/shared/dist/profile-snapshot.test.d.ts +1 -0
  224. package/bundle/shared/dist/profile-snapshot.test.js +67 -0
  225. package/bundle/shared/dist/queue-entries.d.ts +89 -0
  226. package/bundle/shared/dist/queue-entries.js +19 -0
  227. package/bundle/shared/dist/result-qa.d.ts +2 -2
  228. package/bundle/shared/dist/runtime-auth-health.d.ts +66 -0
  229. package/bundle/shared/dist/runtime-auth-health.js +186 -0
  230. package/bundle/shared/dist/runtime-auth-health.test.d.ts +1 -0
  231. package/bundle/shared/dist/runtime-auth-health.test.js +200 -0
  232. package/bundle/shared/dist/runtime-availability.d.ts +51 -0
  233. package/bundle/shared/dist/runtime-availability.js +13 -0
  234. package/bundle/shared/dist/runtime.d.ts +1 -1
  235. package/bundle/shared/dist/runtime.js +1 -1
  236. package/bundle/shared/dist/usage-events.d.ts +36 -0
  237. package/bundle/shared/dist/usage-events.js +13 -0
  238. package/bundle/shared/dist/worker-sessions.d.ts +140 -0
  239. package/bundle/shared/dist/worker-sessions.js +70 -0
  240. package/bundle/shared/dist/worker-sessions.test.d.ts +1 -0
  241. package/bundle/shared/dist/worker-sessions.test.js +39 -0
  242. package/bundle/shared/dist/workflow.d.ts +78 -0
  243. package/bundle/shared/dist/workflow.js +59 -0
  244. package/bundle/shared/dist/workflow.test.d.ts +1 -0
  245. package/bundle/shared/dist/workflow.test.js +61 -0
  246. package/bundle/shared/package.json +1 -1
  247. package/dist/action.d.ts +1 -0
  248. package/dist/action.js +50 -0
  249. package/dist/action.test.d.ts +1 -0
  250. package/dist/action.test.js +70 -0
  251. package/dist/agent.d.ts +1 -0
  252. package/dist/agent.js +17 -0
  253. package/dist/agent.test.d.ts +1 -0
  254. package/dist/agent.test.js +19 -0
  255. package/dist/cli-check.d.ts +7 -0
  256. package/dist/cli-check.js +40 -0
  257. package/dist/doctor.js +59 -1
  258. package/dist/http.d.ts +5 -0
  259. package/dist/http.js +19 -0
  260. package/dist/index.js +28 -0
  261. package/dist/issue.d.ts +35 -0
  262. package/dist/issue.js +143 -0
  263. package/dist/issue.test.d.ts +1 -0
  264. package/dist/issue.test.js +240 -0
  265. package/dist/lifecycle.contract.test.d.ts +1 -0
  266. package/dist/lifecycle.contract.test.js +135 -0
  267. package/dist/queue.d.ts +11 -0
  268. package/dist/queue.js +60 -0
  269. package/dist/queue.test.d.ts +1 -0
  270. package/dist/queue.test.js +46 -0
  271. package/dist/test-fetch-stub.d.ts +6 -0
  272. package/dist/test-fetch-stub.js +31 -0
  273. package/package.json +2 -1
  274. package/templates/prod.env.example +1 -1
  275. package/bundle/server/static-ui/assets/index-B_7S7xJb.js +0 -79
  276. package/bundle/server/static-ui/assets/index-QYv3zmE6.css +0 -1
@@ -1,9 +1,33 @@
1
- import { spawn } from "node:child_process";
2
- import fs from "node:fs";
3
- import { claudeBinExists, cursorBinExists, resolveClaudeBin, cursorInvokeArgs, resolveCursorBin, } from "../cli-env.js";
4
- import { checkAgentDeckHealth, isAgentDeckMcpRegistered } from "./agent-deck.js";
1
+ import { spawn, spawnSync } from "node:child_process";
2
+ import { CODEX_AUTH_REMEDIATION, cursorAuthIssueFromOutput, runtimeAuthIssueFromOutput, } from "@agent-dealer/shared";
3
+ import { claudeBinExists, cursorBinExists, resolveClaudeBin, cursorInvokeArgs, resolveCursorBin, resolveCodexBin, codexBinExists, } from "../cli-env.js";
4
+ import { checkAgentDeckHealth, fetchDecks, isAgentDeckMcpRegistered } from "./agent-deck.js";
5
+ import { runtimeAvailability } from "../repository/runtime-availability.js";
6
+ const RUNTIME_LABEL = {
7
+ claude_code: "Claude",
8
+ cursor_local: "Cursor",
9
+ codex_local: "Codex",
10
+ };
11
+ function capHealthIssues(runtime) {
12
+ const avail = runtimeAvailability(runtime);
13
+ if (avail.available)
14
+ return [];
15
+ const until = new Date(avail.until).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
16
+ return [
17
+ {
18
+ code: "usage_capped",
19
+ message: `${RUNTIME_LABEL[runtime]} capped until ${until}`,
20
+ },
21
+ ];
22
+ }
5
23
  const RUNTIME_CACHE_MS = 60_000;
6
24
  const runtimeIssueCache = new Map();
25
+ let githubIssueCache = null;
26
+ /** Exported for tests — clears the shared github + runtime health caches. */
27
+ export function clearAgentHealthCaches() {
28
+ runtimeIssueCache.clear();
29
+ githubIssueCache = null;
30
+ }
7
31
  function runCommand(cmd, args, timeoutMs = 8000) {
8
32
  return new Promise((resolve) => {
9
33
  const child = spawn(cmd, args, {
@@ -31,50 +55,216 @@ function runCommand(cmd, args, timeoutMs = 8000) {
31
55
  });
32
56
  });
33
57
  }
34
- async function runtimeIssuesUncached(runtime) {
58
+ /** Exported for direct testing — bypasses the 60s cache in runtimeIssues(). */
59
+ export async function runtimeIssuesUncached(runtime) {
60
+ const issues = [];
35
61
  if (runtime === "claude_code") {
36
- if (claudeBinExists())
37
- return [];
38
- const ver = await runCommand(resolveClaudeBin(), ["--version"]);
39
- if (!ver.ok) {
40
- return [{ code: "cli_missing", message: "Claude CLI not found — install Claude Code" }];
62
+ if (!claudeBinExists()) {
63
+ const ver = await runCommand(resolveClaudeBin(), ["--version"]);
64
+ if (!ver.ok) {
65
+ issues.push({ code: "cli_missing", message: "Claude CLI not found — install Claude Code" });
66
+ return issues;
67
+ }
41
68
  }
42
- return [];
69
+ // NOT-133: Claude had no auth preflight at all, so a logged-out Claude agent was admitted
70
+ // exactly the way a logged-out Cursor one was. `claude auth status` is local, offline and
71
+ // free (it reads the credential store and prints JSON) — unlike `claude -p`, which bills.
72
+ // Its output is what gets classified, not its exit code: logged out it prints
73
+ // `"loggedIn": false` *and* exits 1, so only the positive signal in the body blocks. An
74
+ // older CLI without the subcommand prints an unknown-command error that matches nothing,
75
+ // and must not become a false block.
76
+ if (!claudeUsesThirdPartyProvider()) {
77
+ const auth = await runCommand(resolveClaudeBin(), ["auth", "status"]);
78
+ const authIssue = runtimeAuthIssueFromOutput("claude_code", auth.output);
79
+ if (authIssue)
80
+ issues.push(authIssue);
81
+ }
82
+ return issues;
83
+ }
84
+ if (runtime === "codex_local") {
85
+ if (!codexBinExists()) {
86
+ const ver = await runCommand(resolveCodexBin(), ["--version"]);
87
+ if (!ver.ok) {
88
+ issues.push({ code: "cli_missing", message: "Codex CLI not found — install Codex (`codex`)" });
89
+ return issues;
90
+ }
91
+ }
92
+ // `codex --version` succeeds without auth — use login status for auth health.
93
+ const login = await runCommand(resolveCodexBin(), ["login", "status"]);
94
+ // The classifier runs first: `codex login status` prints "Not logged in" and the
95
+ // exit-code heuristic below would read that as "logged in" on its own, because the
96
+ // substring "logged in" is inside it.
97
+ const authIssue = runtimeAuthIssueFromOutput("codex_local", login.output);
98
+ if (authIssue) {
99
+ issues.push(authIssue);
100
+ return issues;
101
+ }
102
+ const out = login.output.toLowerCase();
103
+ const loggedIn = login.ok &&
104
+ (out.includes("logged in") || out.includes("authenticated") || out.includes("api key"));
105
+ if (!loggedIn) {
106
+ issues.push({ code: "runtime_auth", message: CODEX_AUTH_REMEDIATION });
107
+ }
108
+ return issues;
43
109
  }
44
110
  const status = await runCommand(resolveCursorBin(), cursorInvokeArgs(["status"]));
45
- if (!status.ok && !status.output.trim() && !cursorBinExists()) {
46
- return [{ code: "cli_missing", message: "cursor-agent not found — run: curl https://cursor.com/install -fsS | bash" }];
111
+ // A failed *spawn* resolves with the error message as its output (`spawn cursor-agent
112
+ // ENOENT`), not with empty output — so an absent binary must be recognised here or it
113
+ // falls through to the unconfirmed-auth branch below and names the wrong remedy.
114
+ if (!status.ok && (/\bENOENT\b/.test(status.output) || (!status.output.trim() && !cursorBinExists()))) {
115
+ issues.push({ code: "cli_missing", message: "cursor-agent not found — run: curl https://cursor.com/install -fsS | bash" });
116
+ return issues;
117
+ }
118
+ const authIssue = cursorAuthIssueFromOutput(status.output);
119
+ if (authIssue) {
120
+ issues.push(authIssue);
121
+ return issues;
47
122
  }
123
+ // NOT-133: an unclassified *failure* of the probe itself (non-zero exit, timeout) used to
124
+ // be read as "healthy" and admitted the agent. Silence is not evidence of auth, so this
125
+ // fails closed: the agent stays unhealthy — and its issues stay queued — until the probe
126
+ // succeeds. That is a deliberate trade against the incident, where admitting on a guess
127
+ // cost 12 dead sessions and parked three issues on a human. Reported as runtime_auth so
128
+ // the existing agents-page CLI status renders it, with a message that says plainly the
129
+ // state is unconfirmed rather than asserting the agent is logged out.
130
+ if (!status.ok) {
131
+ const detail = status.output.trim().split("\n").slice(-1)[0] ?? "no output";
132
+ issues.push({
133
+ code: "runtime_auth",
134
+ message: `Could not confirm Cursor auth — \`cursor-agent status\` failed (${detail})`,
135
+ });
136
+ }
137
+ return issues;
138
+ }
139
+ /**
140
+ * Bedrock/Vertex installs authenticate through AWS/GCP credentials instead of a Claude
141
+ * login, so `claude auth status` is not the authority on whether they can run.
142
+ */
143
+ function claudeUsesThirdPartyProvider() {
144
+ return (process.env.CLAUDE_CODE_USE_BEDROCK === "1" || process.env.CLAUDE_CODE_USE_VERTEX === "1");
145
+ }
146
+ /**
147
+ * Issue workflows always use `gh` after a developer session (draft PR + checks). A bad
148
+ * or missing GitHub CLI auth wastes a full agent run and lands as `adapter_failure` —
149
+ * surface it on every agent so Start / intake can refuse before that spend.
150
+ */
151
+ export async function githubIssuesUncached() {
152
+ const status = await runCommand("gh", ["auth", "status"]);
48
153
  const out = status.output.toLowerCase();
49
- if (out.includes("not logged in") || out.includes("login required") || out.includes("not authenticated")) {
50
- return [{ code: "runtime_auth", message: "Run cursor-agent login" }];
154
+ if (out.includes("enoent") ||
155
+ out.includes("not found") ||
156
+ out.includes("no such file") ||
157
+ (out.includes("spawn") && out.includes("gh"))) {
158
+ return [{ code: "github_cli_missing", message: "gh CLI not found — install GitHub CLI (`gh`)" }];
159
+ }
160
+ if (!status.ok ||
161
+ out.includes("not logged in") ||
162
+ out.includes("failed to log in") ||
163
+ out.includes("token in keyring is invalid") ||
164
+ out.includes("re-authenticate") ||
165
+ out.includes("to re-authenticate")) {
166
+ return [
167
+ {
168
+ code: "github_auth",
169
+ message: "Run `gh auth login` — GitHub CLI auth required to open PRs",
170
+ },
171
+ ];
51
172
  }
52
173
  return [];
53
174
  }
175
+ /**
176
+ * Unit/CI tests must not call the real `gh auth status` — runners usually have no
177
+ * usable token, and the startWorkflow preflight would refuse every kick. Set
178
+ * `AGENT_DEALER_SKIP_GITHUB_HEALTH=1` in `test:unit` / `test:ci`. Production and
179
+ * local `npm run dev` leave it unset so Start still refuses bad auth.
180
+ */
181
+ function githubHealthSkippedForTests() {
182
+ return process.env.AGENT_DEALER_SKIP_GITHUB_HEALTH === "1";
183
+ }
184
+ /** Synchronous for startWorkflow — issue kick must refuse before spending a developer round. */
185
+ export function githubIssuesSync() {
186
+ if (githubHealthSkippedForTests())
187
+ return [];
188
+ if (githubIssueCache && Date.now() - githubIssueCache.at < RUNTIME_CACHE_MS) {
189
+ return githubIssueCache.issues;
190
+ }
191
+ const result = spawnSync("gh", ["auth", "status"], {
192
+ encoding: "utf8",
193
+ timeout: 8000,
194
+ env: process.env,
195
+ });
196
+ const output = `${result.stdout ?? ""}${result.stderr ?? ""}${result.error?.message ?? ""}`;
197
+ const out = output.toLowerCase();
198
+ let issues = [];
199
+ if (result.error ||
200
+ out.includes("enoent") ||
201
+ out.includes("not found") ||
202
+ out.includes("no such file")) {
203
+ issues = [{ code: "github_cli_missing", message: "gh CLI not found — install GitHub CLI (`gh`)" }];
204
+ }
205
+ else if (result.status !== 0 ||
206
+ out.includes("not logged in") ||
207
+ out.includes("failed to log in") ||
208
+ out.includes("token in keyring is invalid") ||
209
+ out.includes("re-authenticate") ||
210
+ out.includes("to re-authenticate")) {
211
+ issues = [
212
+ {
213
+ code: "github_auth",
214
+ message: "Run `gh auth login` — GitHub CLI auth required to open PRs",
215
+ },
216
+ ];
217
+ }
218
+ githubIssueCache = { at: Date.now(), issues };
219
+ return issues;
220
+ }
221
+ async function githubIssues() {
222
+ if (githubIssueCache && Date.now() - githubIssueCache.at < RUNTIME_CACHE_MS) {
223
+ return githubIssueCache.issues;
224
+ }
225
+ const issues = await githubIssuesUncached();
226
+ githubIssueCache = { at: Date.now(), issues };
227
+ return issues;
228
+ }
54
229
  async function runtimeIssues(runtime) {
230
+ const capIssues = capHealthIssues(runtime);
55
231
  const cached = runtimeIssueCache.get(runtime);
56
232
  if (cached && Date.now() - cached.at < RUNTIME_CACHE_MS) {
57
- return cached.issues;
233
+ return [...capIssues, ...cached.issues];
58
234
  }
59
235
  const issues = await runtimeIssuesUncached(runtime);
60
- runtimeIssueCache.set(runtime, { at: Date.now(), issues });
61
- return issues;
236
+ const nonCap = issues.filter((i) => i.code !== "usage_capped");
237
+ runtimeIssueCache.set(runtime, { at: Date.now(), issues: nonCap });
238
+ return [...capIssues, ...nonCap];
62
239
  }
63
- function agentSpecificIssues(agent, agentDeckOnline, mcpRegistered) {
240
+ function agentSpecificIssues(agent, agentDeckOnline, mcpRegistered, deckAccessResult) {
64
241
  const issues = [];
65
- if (!agent.workspaceRoot) {
66
- issues.push({ code: "workspace_missing", message: "Set workspace on Agents page" });
67
- }
68
- else if (!fs.existsSync(agent.workspaceRoot)) {
242
+ if (!agent.deckId) {
69
243
  issues.push({
70
- code: "workspace_missing",
71
- message: `Workspace path not found: ${agent.workspaceRoot}`,
244
+ code: "deck_missing",
245
+ message: "Set an Agent Deck on the Agents page — workers never start without one",
72
246
  });
247
+ return issues;
73
248
  }
74
- if (agent.deckId && !agentDeckOnline) {
249
+ if (!agentDeckOnline) {
75
250
  issues.push({ code: "deck_offline", message: "Agent Deck offline — deck MCP unavailable" });
76
251
  }
77
- if (agent.deckId && agent.runtime === "claude_code" && agentDeckOnline && !mcpRegistered) {
252
+ // resolveDeckName silently returns null on this same failure elsewhere (route/index.ts) —
253
+ // surface it here so a bound deck that can no longer be read isn't just a quiet no-op. A
254
+ // *successful* metadata call that simply doesn't include this deck (deleted) is the same
255
+ // user-visible failure as the call itself failing.
256
+ if (agentDeckOnline && deckAccessResult) {
257
+ if (!deckAccessResult.ok) {
258
+ issues.push({ code: "deck_unauthorized", message: deckAccessResult.message });
259
+ }
260
+ else if (!deckAccessResult.decks.some((d) => d.id === agent.deckId)) {
261
+ issues.push({
262
+ code: "deck_unauthorized",
263
+ message: `Deck ${agent.deckName ?? agent.deckId} is not available from Agent Deck (deleted or unreachable)`,
264
+ });
265
+ }
266
+ }
267
+ if (agent.runtime === "claude_code" && agentDeckOnline && !mcpRegistered) {
78
268
  issues.push({
79
269
  code: "mcp_not_registered",
80
270
  message: "Run agent-deck setup --client claude --start (Claude MCP not registered)",
@@ -82,14 +272,16 @@ function agentSpecificIssues(agent, agentDeckOnline, mcpRegistered) {
82
272
  }
83
273
  return issues;
84
274
  }
85
- export async function healthForAgent(agent, agentDeckOnline, runtimeIssuesByRuntime, mcpRegistered) {
275
+ export async function healthForAgent(agent, agentDeckOnline, runtimeIssuesByRuntime, mcpRegistered, deckAccessResult = null, githubIssuesList) {
86
276
  const runtime = runtimeIssuesByRuntime !== undefined
87
277
  ? (runtimeIssuesByRuntime.get(agent.runtime) ?? [])
88
278
  : await runtimeIssues(agent.runtime);
89
279
  const deckMcpOk = mcpRegistered ?? isAgentDeckMcpRegistered();
280
+ const github = githubIssuesList ?? (await githubIssues());
90
281
  const issues = [
91
282
  ...runtime,
92
- ...agentSpecificIssues(agent, agentDeckOnline, deckMcpOk),
283
+ ...github,
284
+ ...agentSpecificIssues(agent, agentDeckOnline, deckMcpOk, deckAccessResult),
93
285
  ];
94
286
  return {
95
287
  ...agent,
@@ -100,10 +292,15 @@ export async function healthForAgent(agent, agentDeckOnline, runtimeIssuesByRunt
100
292
  export async function listAgentsWithHealth(agents) {
101
293
  const agentDeckOnline = await checkAgentDeckHealth();
102
294
  const mcpRegistered = isAgentDeckMcpRegistered();
295
+ const needsDeckAccess = agents.some((a) => a.deckId);
296
+ const deckAccessResult = agentDeckOnline && needsDeckAccess ? await fetchDecks() : null;
103
297
  const runtimes = [...new Set(agents.map((a) => a.runtime))];
104
298
  const runtimeIssuesByRuntime = new Map();
105
- await Promise.all(runtimes.map(async (runtime) => {
106
- runtimeIssuesByRuntime.set(runtime, await runtimeIssues(runtime));
107
- }));
108
- return Promise.all(agents.map((a) => healthForAgent(a, agentDeckOnline, runtimeIssuesByRuntime, mcpRegistered)));
299
+ const [, githubIssuesList] = await Promise.all([
300
+ Promise.all(runtimes.map(async (runtime) => {
301
+ runtimeIssuesByRuntime.set(runtime, await runtimeIssues(runtime));
302
+ })),
303
+ githubIssues(),
304
+ ]);
305
+ return Promise.all(agents.map((a) => healthForAgent(a, agentDeckOnline, runtimeIssuesByRuntime, mcpRegistered, deckAccessResult, githubIssuesList)));
109
306
  }
@@ -0,0 +1,237 @@
1
+ // packages/server/src/adapters/agent-health.test.ts
2
+ //
3
+ // An agent bound to a deck that Agent Deck is reachable for (agentDeckOnline) but whose
4
+ // launch deck-metadata call fails — or succeeds but simply no longer includes this deck
5
+ // (deleted) — must surface a distinct issue. resolveDeckName silently swallows both of
6
+ // the same failures to null elsewhere, so this is the one place an operator can see why
7
+ // deck resolution is broken.
8
+ import { test } from "node:test";
9
+ import assert from "node:assert/strict";
10
+ import fs from "node:fs";
11
+ import os from "node:os";
12
+ import path from "node:path";
13
+ import { randomUUID } from "node:crypto";
14
+ import { fileURLToPath } from "node:url";
15
+ process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-agenthealth-"));
16
+ const { migrate, getDb } = await import("../db/index.js");
17
+ const { createAgent, getAgent } = await import("../repository/agents.js");
18
+ const { healthForAgent, runtimeIssuesUncached, githubIssuesUncached, clearAgentHealthCaches } = await import("./agent-health.js");
19
+ migrate();
20
+ const FAILURE = { ok: false, code: "DECK_UNAVAILABLE", message: "Agent Deck API error: 502" };
21
+ /** Tests inject an empty github list so host `gh auth` does not pollute assertions. */
22
+ const NO_GITHUB = [];
23
+ test("missing deckId: reports deck_missing", async () => {
24
+ const created = createAgent({
25
+ name: "no-deck",
26
+ runtime: "claude_code",
27
+ deckId: "00000000-0000-4000-a000-000000000099",
28
+ });
29
+ getDb().prepare("UPDATE agents SET deck_id = NULL WHERE id = ?").run(created.id);
30
+ const agent = getAgent(created.id);
31
+ const result = await healthForAgent(agent, true, new Map(), true, FAILURE, NO_GITHUB);
32
+ assert.equal(result.issues.some((i) => i.code === "deck_missing"), true);
33
+ assert.equal(result.healthy, false);
34
+ });
35
+ test("agent deck offline: reports deck_offline, not deck_unauthorized", async () => {
36
+ const agent = createAgent({
37
+ name: "offline",
38
+ runtime: "claude_code",
39
+ deckId: randomUUID(),
40
+ });
41
+ const result = await healthForAgent(agent, false, new Map(), true, FAILURE, NO_GITHUB);
42
+ assert.deepEqual(result.issues.map((i) => i.code).sort(), ["deck_offline"]);
43
+ });
44
+ test("agent deck online but metadata unavailable: reports deck_unauthorized with the Deck message", async () => {
45
+ const agent = createAgent({
46
+ name: "unavailable",
47
+ runtime: "claude_code",
48
+ deckId: randomUUID(),
49
+ });
50
+ const result = await healthForAgent(agent, true, new Map(), true, FAILURE, NO_GITHUB);
51
+ const issue = result.issues.find((i) => i.code === "deck_unauthorized");
52
+ assert.ok(issue, "expected a deck_unauthorized issue");
53
+ assert.equal(issue?.message, "Agent Deck API error: 502");
54
+ });
55
+ test("agent deck online, metadata call succeeds, but this deck isn't in the returned set: reports deck_unauthorized", async () => {
56
+ const deckId = randomUUID();
57
+ const agent = createAgent({ name: "stale-deck", runtime: "claude_code", deckId });
58
+ const deckAccessResult = { ok: true, decks: [{ id: randomUUID(), name: "some-other-deck" }] };
59
+ const result = await healthForAgent(agent, true, new Map(), true, deckAccessResult, NO_GITHUB);
60
+ const issue = result.issues.find((i) => i.code === "deck_unauthorized");
61
+ assert.ok(issue, "expected a deck_unauthorized issue for a deck missing from the launch set");
62
+ });
63
+ test("agent deck online and this deck is in the returned set: healthy on the deck axis", async () => {
64
+ const deckId = randomUUID();
65
+ const agent = createAgent({ name: "healthy", runtime: "claude_code", deckId });
66
+ const deckAccessResult = { ok: true, decks: [{ id: deckId, name: "healthy-deck" }] };
67
+ const result = await healthForAgent(agent, true, new Map(), true, deckAccessResult, NO_GITHUB);
68
+ assert.equal(result.issues.some((i) => i.code === "deck_unauthorized" || i.code === "deck_offline"), false);
69
+ });
70
+ test("agent deck online with no deck-access result computed (e.g. no agent needed it): no false positive", async () => {
71
+ const agent = createAgent({
72
+ name: "no-result",
73
+ runtime: "claude_code",
74
+ deckId: randomUUID(),
75
+ });
76
+ const result = await healthForAgent(agent, true, new Map(), true, null, NO_GITHUB);
77
+ assert.equal(result.issues.some((i) => i.code === "deck_unauthorized" || i.code === "deck_offline"), false);
78
+ });
79
+ test("codex_local with a missing CLI reports cli_missing exactly once, not twice", async () => {
80
+ const prevCodexCli = process.env.CODEX_CLI;
81
+ process.env.CODEX_CLI = "/nonexistent/path/codex-does-not-exist";
82
+ try {
83
+ const issues = await runtimeIssuesUncached("codex_local");
84
+ assert.deepEqual(issues.map((i) => i.code), ["cli_missing"]);
85
+ }
86
+ finally {
87
+ if (prevCodexCli === undefined)
88
+ delete process.env.CODEX_CLI;
89
+ else
90
+ process.env.CODEX_CLI = prevCodexCli;
91
+ }
92
+ });
93
+ test("cursor_local with a bound deck: no deck access issue (launch MCP is supported)", async () => {
94
+ const agent = createAgent({
95
+ name: "cursor-with-deck",
96
+ runtime: "cursor_local",
97
+ deckId: randomUUID(),
98
+ });
99
+ const result = await healthForAgent(agent, true, new Map(), true, null, NO_GITHUB);
100
+ assert.equal(result.issues.some((i) => i.code === "deck_unauthorized" || i.code === "deck_offline"), false);
101
+ });
102
+ test("github_auth issues mark the agent unhealthy so Start can refuse before a wasted run", async () => {
103
+ const agent = createAgent({
104
+ name: "needs-gh",
105
+ runtime: "claude_code",
106
+ deckId: "00000000-0000-4000-a000-000000000099",
107
+ });
108
+ const gh = [
109
+ { code: "github_auth", message: "Run `gh auth login` — GitHub CLI auth required to open PRs" },
110
+ ];
111
+ const result = await healthForAgent(agent, true, new Map(), true, null, gh);
112
+ assert.equal(result.healthy, false);
113
+ assert.equal(result.issues.some((i) => i.code === "github_auth"), true);
114
+ });
115
+ test("githubIssuesUncached reports github_auth when gh auth status fails with an invalid token", async () => {
116
+ clearAgentHealthCaches();
117
+ // Exercise the real parser path by stubbing via PATH... we instead unit-test the
118
+ // injected-list path above for healthForAgent. Here we assert the uncached helper
119
+ // returns a known shape when `gh` is present but auth is bad — skip if gh missing.
120
+ const { spawnSync } = await import("node:child_process");
121
+ const probe = spawnSync("gh", ["--version"], { encoding: "utf8" });
122
+ if (probe.error || probe.status !== 0) {
123
+ // No gh on this host — nothing to assert about auth status.
124
+ return;
125
+ }
126
+ const issues = await githubIssuesUncached();
127
+ for (const issue of issues) {
128
+ assert.ok(issue.code === "github_auth" || issue.code === "github_cli_missing");
129
+ assert.ok(issue.message.length > 0);
130
+ }
131
+ });
132
+ test("cursor_keychain status/stderr fixture maps to a blocking health issue via cursorAuthIssueFromOutput", async () => {
133
+ const { cursorAuthIssueFromOutput } = await import("@agent-dealer/shared");
134
+ const fixture = `Cursor couldn't save your login to the macOS keychain (errSecDuplicateItem, security exit code 45).
135
+ The keychain item is stuck. Delete it and sign in again:
136
+ security delete-generic-password -s cursor-access-token -a cursor-user
137
+ agent login
138
+ `;
139
+ const issue = cursorAuthIssueFromOutput(fixture);
140
+ assert.equal(issue?.code, "cursor_keychain");
141
+ assert.match(issue.message, /delete-generic-password/);
142
+ const agent = createAgent({
143
+ name: "cursor-keychain",
144
+ runtime: "cursor_local",
145
+ deckId: "00000000-0000-4000-a000-000000000099",
146
+ });
147
+ const result = await healthForAgent(agent, true, new Map([["cursor_local", [issue]]]), true, null, NO_GITHUB);
148
+ assert.equal(result.healthy, false);
149
+ assert.equal(result.issues.some((i) => i.code === "cursor_keychain"), true);
150
+ });
151
+ // ---------------------------------------------------------------------------
152
+ // NOT-133: the admission preflight classifies each runtime's *captured* logged-out
153
+ // output. Stubs replay the fixtures in packages/shared/src/fixtures/runtime-auth/.
154
+ // ---------------------------------------------------------------------------
155
+ const FIXTURES = path.join(path.dirname(fileURLToPath(import.meta.url)), "../../../shared/src/fixtures/runtime-auth");
156
+ /** A CLI stub that prints a captured fixture verbatim and exits with the captured code. */
157
+ function stubCli(name, fixture, exitCode) {
158
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-cli-stub-"));
159
+ const bin = path.join(dir, name);
160
+ fs.writeFileSync(bin, `#!/bin/sh\ncat ${JSON.stringify(path.join(FIXTURES, fixture))}\nexit ${exitCode}\n`);
161
+ fs.chmodSync(bin, 0o755);
162
+ return bin;
163
+ }
164
+ async function withEnv(key, value, fn) {
165
+ const prev = process.env[key];
166
+ process.env[key] = value;
167
+ clearAgentHealthCaches();
168
+ try {
169
+ return await fn();
170
+ }
171
+ finally {
172
+ if (prev === undefined)
173
+ delete process.env[key];
174
+ else
175
+ process.env[key] = prev;
176
+ clearAgentHealthCaches();
177
+ }
178
+ }
179
+ test("logged-out `cursor-agent status` capture blocks the cursor runtime (exit 0 — output is the only signal)", async () => {
180
+ const issues = await withEnv("CURSOR_CLI", stubCli("cursor-agent", "cursor-agent-status-logged-out.txt", 0), () => runtimeIssuesUncached("cursor_local"));
181
+ assert.deepEqual(issues.map((i) => i.code), ["runtime_auth"]);
182
+ assert.match(issues[0].message, /cursor-agent login/);
183
+ assert.match(issues[0].message, /CURSOR_API_KEY/);
184
+ });
185
+ test("logged-in `cursor-agent status` capture leaves the cursor runtime healthy", async () => {
186
+ const issues = await withEnv("CURSOR_CLI", stubCli("cursor-agent", "cursor-agent-status-logged-in.txt", 0), () => runtimeIssuesUncached("cursor_local"));
187
+ assert.deepEqual(issues, []);
188
+ });
189
+ test("a cursor status probe that fails without a classifiable reason blocks rather than admits", async () => {
190
+ // NOT-133: silence used to be read as health, so an agent that could not be checked was
191
+ // admitted anyway. Unknown auth must wait, not spend infra attempts on ~1s dead sessions.
192
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-cli-stub-"));
193
+ const bin = path.join(dir, "cursor-agent");
194
+ fs.writeFileSync(bin, "#!/bin/sh\necho 'panic: runtime broke' >&2\nexit 7\n");
195
+ fs.chmodSync(bin, 0o755);
196
+ const issues = await withEnv("CURSOR_CLI", bin, () => runtimeIssuesUncached("cursor_local"));
197
+ assert.deepEqual(issues.map((i) => i.code), ["runtime_auth"]);
198
+ assert.match(issues[0].message, /Could not confirm Cursor auth/);
199
+ });
200
+ test("logged-out `codex login status` capture blocks the codex runtime", async () => {
201
+ const issues = await withEnv("CODEX_CLI", stubCli("codex", "codex-login-status-logged-out.txt", 1), () => runtimeIssuesUncached("codex_local"));
202
+ assert.deepEqual(issues.map((i) => i.code), ["runtime_auth"]);
203
+ assert.match(issues[0].message, /codex login/);
204
+ });
205
+ test("logged-in `codex login status` capture leaves the codex runtime healthy", async () => {
206
+ const issues = await withEnv("CODEX_CLI", stubCli("codex", "codex-login-status-logged-in.txt", 0), () => runtimeIssuesUncached("codex_local"));
207
+ assert.deepEqual(issues, []);
208
+ });
209
+ test("a cursor probe that cannot spawn reports the missing CLI, not unconfirmed auth", async () => {
210
+ // A spawn failure resolves with `spawn <bin> ENOENT` as its *output*, so the
211
+ // empty-output test for a missing binary never fires and the operator would be told to
212
+ // check their login when the binary is what is absent.
213
+ const missing = path.join(os.tmpdir(), `dealer-absent-cursor-agent-${randomUUID()}`);
214
+ const issues = await withEnv("CURSOR_CLI", missing, () => runtimeIssuesUncached("cursor_local"));
215
+ assert.deepEqual(issues.map((i) => i.code), ["cli_missing"]);
216
+ assert.match(issues[0].message, /cursor\.com\/install/);
217
+ });
218
+ test("logged-out `claude auth status` capture blocks the claude runtime", async () => {
219
+ // Claude had no auth preflight at all before NOT-133 — a logged-out Claude agent was
220
+ // admitted exactly the way the logged-out Cursor ones were. Exit 1 with a JSON body: the
221
+ // classifier reads `"loggedIn": false`, never the status code.
222
+ const issues = await withEnv("CLAUDE_CLI", stubCli("claude", "claude-auth-status-logged-out.txt", 1), () => runtimeIssuesUncached("claude_code"));
223
+ assert.deepEqual(issues.map((i) => i.code), ["runtime_auth"]);
224
+ assert.match(issues[0].message, /claude auth login/);
225
+ });
226
+ test("logged-in `claude auth status` capture leaves the claude runtime healthy", async () => {
227
+ const issues = await withEnv("CLAUDE_CLI", stubCli("claude", "claude-auth-status-logged-in.txt", 0), () => runtimeIssuesUncached("claude_code"));
228
+ assert.deepEqual(issues, []);
229
+ });
230
+ test("an older claude CLI without `auth status` is not a false block", async () => {
231
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-cli-stub-"));
232
+ const bin = path.join(dir, "claude");
233
+ fs.writeFileSync(bin, "#!/bin/sh\necho \"error: unknown command 'auth'\" >&2\nexit 1\n");
234
+ fs.chmodSync(bin, 0o755);
235
+ const issues = await withEnv("CLAUDE_CLI", bin, () => runtimeIssuesUncached("claude_code"));
236
+ assert.deepEqual(issues, []);
237
+ });