agent-dealer 1.0.2 → 1.0.4

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 (36) hide show
  1. package/bundle/server/dist/adapters/agent-deck.js +68 -13
  2. package/bundle/server/dist/adapters/agent-deck.test.js +91 -1
  3. package/bundle/server/dist/adapters/agent-health.js +175 -51
  4. package/bundle/server/dist/adapters/agent-health.test.js +338 -210
  5. package/bundle/server/dist/adapters/linear-graphql.js +200 -6
  6. package/bundle/server/dist/adapters/linear-graphql.test.js +91 -1
  7. package/bundle/server/dist/coordinator/admission.js +41 -30
  8. package/bundle/server/dist/coordinator/admission.test.js +92 -1
  9. package/bundle/server/dist/coordinator/branch-tip-status.js +120 -0
  10. package/bundle/server/dist/coordinator/branch-tip-status.test.js +189 -0
  11. package/bundle/server/dist/coordinator/commands.js +6 -2
  12. package/bundle/server/dist/coordinator/developer-effect.js +76 -12
  13. package/bundle/server/dist/coordinator/developer-effect.test.js +20 -0
  14. package/bundle/server/dist/coordinator/projection.test.js +26 -0
  15. package/bundle/server/dist/coordinator/prompts.js +4 -1
  16. package/bundle/server/dist/coordinator/prompts.test.js +12 -0
  17. package/bundle/server/dist/coordinator/routing.js +43 -2
  18. package/bundle/server/dist/coordinator/routing.test.js +51 -0
  19. package/bundle/server/dist/coordinator/usage-cap-defer.js +37 -0
  20. package/bundle/server/dist/coordinator/worker-loop.js +20 -2
  21. package/bundle/server/dist/coordinator/worker-loop.test.js +55 -2
  22. package/bundle/server/dist/coordinator/workflows/dev-reviewer-v1.js +8 -0
  23. package/bundle/server/dist/coordinator/workflows/registry.js +14 -0
  24. package/bundle/server/dist/coordinator/workflows/registry.test.js +28 -0
  25. package/bundle/server/dist/coordinator/workflows/types.js +1 -0
  26. package/bundle/server/dist/index.js +2 -0
  27. package/bundle/server/dist/routes/index.js +3 -0
  28. package/bundle/server/dist/routes/issues.js +10 -1
  29. package/bundle/server/dist/routes/issues.test.js +42 -0
  30. package/bundle/server/package.json +2 -2
  31. package/bundle/server/static-ui/assets/{index-hXICi1rX.css → index-CYZRXBZj.css} +1 -1
  32. package/bundle/server/static-ui/assets/index-DXTVo_vz.js +60 -0
  33. package/bundle/server/static-ui/index.html +2 -2
  34. package/bundle/shared/package.json +1 -1
  35. package/package.json +1 -1
  36. package/bundle/server/static-ui/assets/index-0kT1vk6L.js +0 -60
@@ -154,34 +154,89 @@ export async function updatePlaybookBody(playbookId, body) {
154
154
  export function readClaudeMcpConfigPath() {
155
155
  return process.env.CLAUDE_MCP_CONFIG ?? path.join(process.env.HOME ?? "", ".claude.json");
156
156
  }
157
- export function isAgentDeckMcpRegistered() {
157
+ function expectedAgentDeckMcpEndpoint() {
158
+ const expected = new URL(getAgentDeckMcpUrl().replace(/\/mcp\/?$/, "") + "/mcp");
159
+ const port = expected.port || (expected.protocol === "https:" ? "443" : "80");
160
+ return { hostname: expected.hostname, port };
161
+ }
162
+ function isAgentDeckMcpServerName(name) {
163
+ const lower = name.toLowerCase();
164
+ return lower.includes("agent-deck") || name === "agent-deck";
165
+ }
166
+ /** Basename of `agent-deck` (PATH lookup or absolute install path). */
167
+ function commandInvokesAgentDeck(command) {
168
+ if (!command?.trim())
169
+ return false;
170
+ const base = path.basename(command.trim()).replace(/\.(cmd|exe|bat)$/i, "");
171
+ return base === "agent-deck";
172
+ }
173
+ function argsIncludeMcpLaunch(args) {
174
+ return Array.isArray(args) && args.some((a) => a === "mcp-launch");
175
+ }
176
+ /**
177
+ * Classify Claude MCP config for Agent Deck.
178
+ * Accepts legacy HTTP `url` entries and current `agent-deck mcp-launch` stdio + env ports
179
+ * from `agent-deck setup --client claude`.
180
+ */
181
+ export function checkAgentDeckMcpRegistration() {
158
182
  try {
159
183
  const configPath = readClaudeMcpConfigPath();
160
184
  if (!fs.existsSync(configPath))
161
- return false;
185
+ return { status: "missing" };
162
186
  const raw = fs.readFileSync(configPath, "utf8");
163
187
  const config = JSON.parse(raw);
164
- const expected = new URL(getAgentDeckMcpUrl().replace(/\/mcp\/?$/, "") + "/mcp");
188
+ const expected = expectedAgentDeckMcpEndpoint();
189
+ let mismatch = null;
165
190
  for (const [name, server] of Object.entries(config.mcpServers ?? {})) {
166
- if (!name.toLowerCase().includes("agent-deck") && name !== "agent-deck")
191
+ if (!isAgentDeckMcpServerName(name))
167
192
  continue;
168
- if (!server.url)
193
+ if (server.url) {
194
+ try {
195
+ const u = new URL(server.url);
196
+ const foundPort = u.port || (u.protocol === "https:" ? "443" : "80");
197
+ if (u.hostname === expected.hostname && foundPort === expected.port) {
198
+ return { status: "registered" };
199
+ }
200
+ mismatch = {
201
+ status: "endpoint_mismatch",
202
+ expectedHost: expected.hostname,
203
+ expectedPort: expected.port,
204
+ foundHost: u.hostname,
205
+ foundPort,
206
+ };
207
+ }
208
+ catch {
209
+ // skip invalid url
210
+ }
169
211
  continue;
170
- try {
171
- const u = new URL(server.url);
172
- if (u.hostname === expected.hostname && u.port === expected.port)
173
- return true;
174
212
  }
175
- catch {
176
- // skip invalid url
213
+ // Current Claude setup: stdio `agent-deck mcp-launch` with AGENT_DECK_* env.
214
+ if (commandInvokesAgentDeck(server.command) && argsIncludeMcpLaunch(server.args)) {
215
+ const foundHost = server.env?.AGENT_DECK_HOST?.trim() || "127.0.0.1";
216
+ const foundPort = server.env?.AGENT_DECK_MCP_PORT?.trim() ?? "";
217
+ if (foundHost === expected.hostname && foundPort === expected.port) {
218
+ return { status: "registered" };
219
+ }
220
+ mismatch = {
221
+ status: "endpoint_mismatch",
222
+ expectedHost: expected.hostname,
223
+ expectedPort: expected.port,
224
+ foundHost,
225
+ foundPort: foundPort || "(missing)",
226
+ };
177
227
  }
178
228
  }
179
- return false;
229
+ if (mismatch)
230
+ return mismatch;
231
+ return { status: "missing" };
180
232
  }
181
233
  catch {
182
- return false;
234
+ return { status: "missing" };
183
235
  }
184
236
  }
237
+ export function isAgentDeckMcpRegistered() {
238
+ return checkAgentDeckMcpRegistration().status === "registered";
239
+ }
185
240
  /** Map PRD toolCall to deck MCP call_service_tool args (serviceName → serviceId). */
186
241
  export function toCallServiceToolPayload(toolCall) {
187
242
  return {
@@ -8,8 +8,30 @@ import os from "node:os";
8
8
  import path from "node:path";
9
9
  process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-agentdeck-"));
10
10
  const { migrate } = await import("../db/index.js");
11
- const { fetchDecks, fetchAuthorizedDecks } = await import("./agent-deck.js");
11
+ const { fetchDecks, fetchAuthorizedDecks, isAgentDeckMcpRegistered, checkAgentDeckMcpRegistration, } = await import("./agent-deck.js");
12
12
  migrate();
13
+ function writeClaudeMcpFixture(mcpServers) {
14
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "claude-mcp-"));
15
+ const configPath = path.join(dir, ".claude.json");
16
+ fs.writeFileSync(configPath, JSON.stringify({ mcpServers }, null, 2));
17
+ return configPath;
18
+ }
19
+ function withClaudeMcpConfig(configPath, fn) {
20
+ const prev = process.env.CLAUDE_MCP_CONFIG;
21
+ if (configPath === null)
22
+ delete process.env.CLAUDE_MCP_CONFIG;
23
+ else
24
+ process.env.CLAUDE_MCP_CONFIG = configPath;
25
+ try {
26
+ return fn();
27
+ }
28
+ finally {
29
+ if (prev === undefined)
30
+ delete process.env.CLAUDE_MCP_CONFIG;
31
+ else
32
+ process.env.CLAUDE_MCP_CONFIG = prev;
33
+ }
34
+ }
13
35
  test("fetchDecks returns decks on success from /api/launch/decks", async () => {
14
36
  let capturedUrl = "";
15
37
  const fetchMock = mock.method(globalThis, "fetch", async (url, init) => {
@@ -61,3 +83,71 @@ test("fetchDecks reports DECK_UNAVAILABLE on a network error, never an empty lis
61
83
  test("fetchAuthorizedDecks is an alias of fetchDecks", () => {
62
84
  assert.equal(fetchAuthorizedDecks, fetchDecks);
63
85
  });
86
+ // Default Agent Deck MCP endpoint under a fresh AGENT_DEALER_HOME is 127.0.0.1:1110
87
+ // (API port 1111 − 1). Fixtures below match that unless noted.
88
+ test("isAgentDeckMcpRegistered: stdio mcp-launch with matching env is registered (no url)", () => {
89
+ const configPath = writeClaudeMcpFixture({
90
+ "agent-deck": {
91
+ type: "stdio",
92
+ command: "agent-deck",
93
+ args: ["mcp-launch"],
94
+ env: { AGENT_DECK_MCP_PORT: "1110", AGENT_DECK_HOST: "127.0.0.1" },
95
+ },
96
+ });
97
+ withClaudeMcpConfig(configPath, () => {
98
+ assert.equal(isAgentDeckMcpRegistered(), true);
99
+ assert.equal(checkAgentDeckMcpRegistration().status, "registered");
100
+ });
101
+ });
102
+ test("isAgentDeckMcpRegistered: legacy HTTP url with matching host/port is registered", () => {
103
+ const configPath = writeClaudeMcpFixture({
104
+ "agent-deck": { url: "http://127.0.0.1:1110/mcp" },
105
+ });
106
+ withClaudeMcpConfig(configPath, () => {
107
+ assert.equal(isAgentDeckMcpRegistered(), true);
108
+ });
109
+ });
110
+ test("isAgentDeckMcpRegistered: missing entry returns false / missing", () => {
111
+ const configPath = writeClaudeMcpFixture({
112
+ other: { url: "http://127.0.0.1:9999/mcp" },
113
+ });
114
+ withClaudeMcpConfig(configPath, () => {
115
+ assert.equal(isAgentDeckMcpRegistered(), false);
116
+ assert.equal(checkAgentDeckMcpRegistration().status, "missing");
117
+ });
118
+ });
119
+ test("checkAgentDeckMcpRegistration: wrong stdio port is endpoint_mismatch, not registered", () => {
120
+ const configPath = writeClaudeMcpFixture({
121
+ "agent-deck": {
122
+ type: "stdio",
123
+ command: "agent-deck",
124
+ args: ["mcp-launch"],
125
+ env: { AGENT_DECK_MCP_PORT: "9999", AGENT_DECK_HOST: "127.0.0.1" },
126
+ },
127
+ });
128
+ withClaudeMcpConfig(configPath, () => {
129
+ assert.equal(isAgentDeckMcpRegistered(), false);
130
+ const result = checkAgentDeckMcpRegistration();
131
+ assert.equal(result.status, "endpoint_mismatch");
132
+ if (result.status === "endpoint_mismatch") {
133
+ assert.equal(result.expectedPort, "1110");
134
+ assert.equal(result.foundPort, "9999");
135
+ assert.equal(result.expectedHost, "127.0.0.1");
136
+ assert.equal(result.foundHost, "127.0.0.1");
137
+ }
138
+ });
139
+ });
140
+ test("isAgentDeckMcpRegistered: stdio without mcp-launch args is not registered", () => {
141
+ const configPath = writeClaudeMcpFixture({
142
+ "agent-deck": {
143
+ type: "stdio",
144
+ command: "agent-deck",
145
+ args: ["mcp"],
146
+ env: { AGENT_DECK_MCP_PORT: "1110", AGENT_DECK_HOST: "127.0.0.1" },
147
+ },
148
+ });
149
+ withClaudeMcpConfig(configPath, () => {
150
+ assert.equal(isAgentDeckMcpRegistered(), false);
151
+ assert.equal(checkAgentDeckMcpRegistration().status, "missing");
152
+ });
153
+ });
@@ -1,7 +1,7 @@
1
1
  import { spawn, spawnSync } from "node:child_process";
2
2
  import { CODEX_AUTH_REMEDIATION, cursorAuthIssueFromOutput, runtimeAuthIssueFromOutput, } from "@agent-dealer/shared";
3
3
  import { claudeBinExists, cursorBinExists, resolveClaudeBin, cursorInvokeArgs, resolveCursorBin, resolveCodexBin, codexBinExists, } from "../cli-env.js";
4
- import { checkAgentDeckHealth, fetchDecks, isAgentDeckMcpRegistered } from "./agent-deck.js";
4
+ import { checkAgentDeckHealth, checkAgentDeckMcpRegistration, fetchDecks, } from "./agent-deck.js";
5
5
  import { runtimeAvailability } from "../repository/runtime-availability.js";
6
6
  const RUNTIME_LABEL = {
7
7
  claude_code: "Claude",
@@ -21,23 +21,63 @@ function capHealthIssues(runtime) {
21
21
  ];
22
22
  }
23
23
  const RUNTIME_CACHE_MS = 60_000;
24
+ /** Soft probe failures must not stick for the full health TTL — sleep/wake flakes recover on the next tick. */
25
+ const SOFT_PROBE_CACHE_MS = 15_000;
26
+ const DEFAULT_PROBE_TIMEOUT_MS = 8000;
27
+ /** After the first soft fail, retry with these delays before publishing unconfirmed auth. */
28
+ const DEFAULT_SOFT_RETRY_BACKOFFS_MS = [250, 750];
29
+ /** Require this many consecutive soft-fail rounds (each round already retried) before flipping healthy→unhealthy. */
30
+ const SOFT_FAIL_STREAK_TO_UNHEALTHY = 2;
31
+ /** Hold a recent healthy result across a single soft-fail streak after host sleep. */
32
+ const HEALTHY_GRACE_MS = 5 * 60_000;
24
33
  const runtimeIssueCache = new Map();
25
34
  let githubIssueCache = null;
26
- /** Exported for tests clears the shared github + runtime health caches. */
27
- export function clearAgentHealthCaches() {
28
- runtimeIssueCache.clear();
29
- githubIssueCache = null;
35
+ /** Cursor soft-fail streak across health ticks (NOT-157). Reset on hard auth or success. */
36
+ let cursorSoftFailStreak = 0;
37
+ let cursorLastHealthyAt = null;
38
+ let probeTimeoutMsForTests = null;
39
+ let softRetryBackoffsMsForTests = null;
40
+ /**
41
+ * Shorten probe timeout / retry backoff in unit tests so sleep-stub scenarios stay fast.
42
+ * Pass `null` to restore production defaults.
43
+ */
44
+ export function setCursorProbeTimingForTests(opts) {
45
+ if (opts == null) {
46
+ probeTimeoutMsForTests = null;
47
+ softRetryBackoffsMsForTests = null;
48
+ return;
49
+ }
50
+ probeTimeoutMsForTests = opts.timeoutMs === undefined ? probeTimeoutMsForTests : opts.timeoutMs;
51
+ softRetryBackoffsMsForTests =
52
+ opts.retryBackoffsMs === undefined ? softRetryBackoffsMsForTests : opts.retryBackoffsMs;
53
+ }
54
+ function probeTimeoutMs() {
55
+ return probeTimeoutMsForTests ?? DEFAULT_PROBE_TIMEOUT_MS;
30
56
  }
31
- function runCommand(cmd, args, timeoutMs = 8000) {
57
+ function softRetryBackoffsMs() {
58
+ return softRetryBackoffsMsForTests ?? DEFAULT_SOFT_RETRY_BACKOFFS_MS;
59
+ }
60
+ function sleep(ms) {
61
+ return new Promise((resolve) => setTimeout(resolve, ms));
62
+ }
63
+ function defaultRunCommand(cmd, args, timeoutMs = DEFAULT_PROBE_TIMEOUT_MS) {
32
64
  return new Promise((resolve) => {
33
65
  const child = spawn(cmd, args, {
34
66
  stdio: ["ignore", "pipe", "pipe"],
35
67
  env: process.env,
36
68
  });
37
69
  let output = "";
70
+ let settled = false;
71
+ const finish = (result) => {
72
+ if (settled)
73
+ return;
74
+ settled = true;
75
+ clearTimeout(timer);
76
+ resolve(result);
77
+ };
38
78
  const timer = setTimeout(() => {
39
79
  child.kill("SIGTERM");
40
- resolve({ ok: false, output: output || "timeout" });
80
+ finish({ ok: false, output: output || "timeout", timedOut: true });
41
81
  }, timeoutMs);
42
82
  child.stdout?.on("data", (d) => {
43
83
  output += d.toString();
@@ -46,15 +86,102 @@ function runCommand(cmd, args, timeoutMs = 8000) {
46
86
  output += d.toString();
47
87
  });
48
88
  child.on("error", (err) => {
49
- clearTimeout(timer);
50
- resolve({ ok: false, output: err.message });
89
+ finish({ ok: false, output: err.message, timedOut: false });
51
90
  });
52
91
  child.on("close", (code) => {
53
- clearTimeout(timer);
54
- resolve({ ok: code === 0, output });
92
+ finish({ ok: code === 0, output, timedOut: false });
55
93
  });
56
94
  });
57
95
  }
96
+ let runCommandImpl = defaultRunCommand;
97
+ /**
98
+ * Replace the process spawner in unit tests (sequence injection for soft-fail / timeout).
99
+ * Pass `null` to restore the real spawner.
100
+ */
101
+ export function setRunCommandForTests(fn) {
102
+ runCommandImpl = fn ?? defaultRunCommand;
103
+ }
104
+ function runCommand(cmd, args, timeoutMs = DEFAULT_PROBE_TIMEOUT_MS) {
105
+ return runCommandImpl(cmd, args, timeoutMs);
106
+ }
107
+ /** Exported for tests — clears the shared github + runtime health caches and soft-fail streak. */
108
+ export function clearAgentHealthCaches() {
109
+ runtimeIssueCache.clear();
110
+ githubIssueCache = null;
111
+ cursorSoftFailStreak = 0;
112
+ cursorLastHealthyAt = null;
113
+ }
114
+ function isSoftCursorProbeIssue(issue) {
115
+ return (issue.code === "runtime_auth" &&
116
+ (/probe timed out/i.test(issue.message) || /probe failed/i.test(issue.message)));
117
+ }
118
+ function softCursorProbeIssue(result) {
119
+ if (result.timedOut || result.output.trim() === "timeout") {
120
+ return {
121
+ code: "runtime_auth",
122
+ message: "Could not confirm Cursor auth — `cursor-agent status` probe timed out",
123
+ };
124
+ }
125
+ const detail = result.output.trim().split("\n").slice(-1)[0] ?? "no output";
126
+ return {
127
+ code: "runtime_auth",
128
+ message: `Could not confirm Cursor auth — \`cursor-agent status\` probe failed (${detail})`,
129
+ };
130
+ }
131
+ /**
132
+ * NOT-157: classified logged-out / keychain is a hard fail (immediate). Probe timeout /
133
+ * unclassified non-zero exit is soft — retry with backoff, and do not flip a recent healthy
134
+ * result on a single soft-fail streak after host sleep.
135
+ */
136
+ async function cursorRuntimeIssues() {
137
+ const backoffs = softRetryBackoffsMs();
138
+ const attempts = 1 + backoffs.length;
139
+ let last = { ok: false, output: "no probe", timedOut: false };
140
+ for (let i = 0; i < attempts; i++) {
141
+ if (i > 0)
142
+ await sleep(backoffs[i - 1]);
143
+ last = await runCommand(resolveCursorBin(), cursorInvokeArgs(["status"]), probeTimeoutMs());
144
+ // A failed *spawn* resolves with the error message as its output (`spawn cursor-agent
145
+ // ENOENT`), not with empty output — so an absent binary must be recognised here or it
146
+ // falls through to the unconfirmed-auth branch below and names the wrong remedy.
147
+ if (!last.ok && (/\bENOENT\b/.test(last.output) || (!last.output.trim() && !cursorBinExists()))) {
148
+ cursorSoftFailStreak = 0;
149
+ cursorLastHealthyAt = null;
150
+ return [
151
+ {
152
+ code: "cli_missing",
153
+ message: "cursor-agent not found — run: curl https://cursor.com/install -fsS | bash",
154
+ },
155
+ ];
156
+ }
157
+ const authIssue = cursorAuthIssueFromOutput(last.output);
158
+ if (authIssue) {
159
+ cursorSoftFailStreak = 0;
160
+ cursorLastHealthyAt = null;
161
+ console.warn(`[agent-health] cursor-agent status: classified auth failure (${authIssue.code})`);
162
+ return [authIssue];
163
+ }
164
+ if (last.ok) {
165
+ cursorSoftFailStreak = 0;
166
+ cursorLastHealthyAt = Date.now();
167
+ return [];
168
+ }
169
+ // Soft fail this attempt — try again before publishing.
170
+ const kind = last.timedOut || last.output.trim() === "timeout" ? "timed out" : "failed";
171
+ console.warn(`[agent-health] cursor-agent status probe ${kind} (attempt ${i + 1}/${attempts})`);
172
+ }
173
+ // Retries exhausted with only soft failures.
174
+ cursorSoftFailStreak += 1;
175
+ const softIssue = softCursorProbeIssue(last);
176
+ const kind = last.timedOut || last.output.trim() === "timeout" ? "timed out" : "failed";
177
+ console.warn(`[agent-health] cursor-agent status probe ${kind} after retries (streak=${cursorSoftFailStreak})`);
178
+ const withinGrace = cursorLastHealthyAt != null && Date.now() - cursorLastHealthyAt < HEALTHY_GRACE_MS;
179
+ if (withinGrace && cursorSoftFailStreak < SOFT_FAIL_STREAK_TO_UNHEALTHY) {
180
+ // Hold the recent healthy result — one post-sleep timeout must not park the queue.
181
+ return [];
182
+ }
183
+ return [softIssue];
184
+ }
58
185
  /** Exported for direct testing — bypasses the 60s cache in runtimeIssues(). */
59
186
  export async function runtimeIssuesUncached(runtime) {
60
187
  const issues = [];
@@ -107,34 +234,10 @@ export async function runtimeIssuesUncached(runtime) {
107
234
  }
108
235
  return issues;
109
236
  }
110
- const status = await runCommand(resolveCursorBin(), cursorInvokeArgs(["status"]));
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;
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;
237
+ // NOT-157: Cursor auth probe distinguishes hard (classified logged-out / keychain) from
238
+ // soft (timeout / unclassified probe flake). Soft path retries with backoff and holds a
239
+ // recent healthy result across a single post-sleep streak.
240
+ return cursorRuntimeIssues();
138
241
  }
139
242
  /**
140
243
  * Bedrock/Vertex installs authenticate through AWS/GCP credentials instead of a Claude
@@ -229,15 +332,28 @@ async function githubIssues() {
229
332
  async function runtimeIssues(runtime) {
230
333
  const capIssues = capHealthIssues(runtime);
231
334
  const cached = runtimeIssueCache.get(runtime);
232
- if (cached && Date.now() - cached.at < RUNTIME_CACHE_MS) {
335
+ const ttl = cached?.softProbeFailure ? SOFT_PROBE_CACHE_MS : RUNTIME_CACHE_MS;
336
+ if (cached && Date.now() - cached.at < ttl) {
233
337
  return [...capIssues, ...cached.issues];
234
338
  }
235
339
  const issues = await runtimeIssuesUncached(runtime);
236
340
  const nonCap = issues.filter((i) => i.code !== "usage_capped");
237
- runtimeIssueCache.set(runtime, { at: Date.now(), issues: nonCap });
341
+ // Soft fail (published or grace-held) uses a short TTL so a wake retry can clear quickly;
342
+ // a sticky 60s cache of "Could not confirm" is what parked the queue after sleep (NOT-157).
343
+ const softProbeFailure = nonCap.some(isSoftCursorProbeIssue) ||
344
+ (runtime === "cursor_local" && cursorSoftFailStreak > 0);
345
+ runtimeIssueCache.set(runtime, { at: Date.now(), issues: nonCap, softProbeFailure });
238
346
  return [...capIssues, ...nonCap];
239
347
  }
240
- function agentSpecificIssues(agent, agentDeckOnline, mcpRegistered, deckAccessResult) {
348
+ function resolveMcpRegistration(mcpRegistered) {
349
+ if (mcpRegistered === undefined)
350
+ return checkAgentDeckMcpRegistration();
351
+ if (typeof mcpRegistered === "boolean") {
352
+ return mcpRegistered ? { status: "registered" } : { status: "missing" };
353
+ }
354
+ return mcpRegistered;
355
+ }
356
+ function agentSpecificIssues(agent, agentDeckOnline, mcpRegistration, deckAccessResult) {
241
357
  const issues = [];
242
358
  if (!agent.deckId) {
243
359
  issues.push({
@@ -264,11 +380,19 @@ function agentSpecificIssues(agent, agentDeckOnline, mcpRegistered, deckAccessRe
264
380
  });
265
381
  }
266
382
  }
267
- if (agent.runtime === "claude_code" && agentDeckOnline && !mcpRegistered) {
268
- issues.push({
269
- code: "mcp_not_registered",
270
- message: "Run agent-deck setup --client claude --start (Claude MCP not registered)",
271
- });
383
+ if (agent.runtime === "claude_code" && agentDeckOnline) {
384
+ if (mcpRegistration.status === "endpoint_mismatch") {
385
+ issues.push({
386
+ code: "mcp_not_registered",
387
+ message: `Claude MCP points at ${mcpRegistration.foundHost}:${mcpRegistration.foundPort}, expected ${mcpRegistration.expectedHost}:${mcpRegistration.expectedPort} — update AGENT_DECK_HOST/AGENT_DECK_MCP_PORT (or the HTTP url) in Claude MCP config`,
388
+ });
389
+ }
390
+ else if (mcpRegistration.status === "missing") {
391
+ issues.push({
392
+ code: "mcp_not_registered",
393
+ message: "Run agent-deck setup --client claude --start (Claude MCP not registered)",
394
+ });
395
+ }
272
396
  }
273
397
  return issues;
274
398
  }
@@ -276,12 +400,12 @@ export async function healthForAgent(agent, agentDeckOnline, runtimeIssuesByRunt
276
400
  const runtime = runtimeIssuesByRuntime !== undefined
277
401
  ? (runtimeIssuesByRuntime.get(agent.runtime) ?? [])
278
402
  : await runtimeIssues(agent.runtime);
279
- const deckMcpOk = mcpRegistered ?? isAgentDeckMcpRegistered();
403
+ const mcpRegistration = resolveMcpRegistration(mcpRegistered);
280
404
  const github = githubIssuesList ?? (await githubIssues());
281
405
  const issues = [
282
406
  ...runtime,
283
407
  ...github,
284
- ...agentSpecificIssues(agent, agentDeckOnline, deckMcpOk, deckAccessResult),
408
+ ...agentSpecificIssues(agent, agentDeckOnline, mcpRegistration, deckAccessResult),
285
409
  ];
286
410
  return {
287
411
  ...agent,
@@ -291,7 +415,7 @@ export async function healthForAgent(agent, agentDeckOnline, runtimeIssuesByRunt
291
415
  }
292
416
  export async function listAgentsWithHealth(agents) {
293
417
  const agentDeckOnline = await checkAgentDeckHealth();
294
- const mcpRegistered = isAgentDeckMcpRegistered();
418
+ const mcpRegistration = checkAgentDeckMcpRegistration();
295
419
  const needsDeckAccess = agents.some((a) => a.deckId);
296
420
  const deckAccessResult = agentDeckOnline && needsDeckAccess ? await fetchDecks() : null;
297
421
  const runtimes = [...new Set(agents.map((a) => a.runtime))];
@@ -302,5 +426,5 @@ export async function listAgentsWithHealth(agents) {
302
426
  })),
303
427
  githubIssues(),
304
428
  ]);
305
- return Promise.all(agents.map((a) => healthForAgent(a, agentDeckOnline, runtimeIssuesByRuntime, mcpRegistered, deckAccessResult, githubIssuesList)));
429
+ return Promise.all(agents.map((a) => healthForAgent(a, agentDeckOnline, runtimeIssuesByRuntime, mcpRegistration, deckAccessResult, githubIssuesList)));
306
430
  }