@thehammer/danx-dashboard-mcp 0.1.81 → 0.1.83

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.
package/README.md CHANGED
@@ -60,10 +60,10 @@ CLAUDE_CODE_SESSION_ID=<session> npx -y @thehammer/danx-dashboard-mcp@<version>
60
60
  ```
61
61
 
62
62
  - **Credential — the session's own, never the ambient one (DX-2862).** The session id is the only thing `bridge` takes from its environment; `--resume-ids` is the only argument and holds no secret. Everything else comes from the connection record this session's own danx-dashboard MCP server wrote on `plan_connect` (`src/session-connection.ts`): the dashboard URL, the credential's SOURCE, and its FINGERPRINT. `bridge` resolves that source through the same resolver the server used and refuses to run unless the result fingerprints identically — `credential_mismatch`. Before that fix it used whatever `DANXBOT_DISPATCH_TOKEN` the session's environment held, and on a machine where that differed from the server's credential the dashboard admitted the stream and dropped every event, with nothing logged anywhere.
63
- - **Reach check.** After minting, `bridge` proves the credential can read every board the connected plan's cards live on (`GET /api/plans/mine?fields=cards`, then `GET /api/issues/:id/problems` for one card per board — the routes that run the same board-allowlist + `read` check the stream itself applies). A board it cannot read stops it with `board_unreadable` rather than streaming on and dropping that board's events. Then, once, `{"type":"ready","boards":[…]}`.
63
+ - **No client-side reach check (DX-2920).** `bridge` used to prove board reach itself, before ever opening the stream (`GET /api/plans/mine?fields=cards` + a per-board `GET /api/issues/:id/problems`). That check is DELETED — the dashboard (DX-2863) now enforces the identical requirement server-side, and a second client-side copy of the same check was a duplicate source of truth, not a safety net: it refuses a mint (`403 issuer_cannot_read_plan_boards`), refuses stream admission (the SAME 403), and ends a live stream (`event: end`, `{"reason":"scope_narrowed","boards":[…]}`) the instant it stops being true. `bridge` only MAPS those refusals now — see "Stopping" below — it never independently verifies anything before streaming. Once a ticket is minted, `bridge` emits, once, `{"type":"ready"}` — no `boards` field, since reach is no longer proven client-side.
64
64
  - **Ticket.** `bridge` mints the session's listener ticket (`POST /api/plan-sessions/me/stream-ticket`, 10 s timeout) and keeps it in the process. A ticket authorizes reading that one session's event stream and nothing else, and is only issued while the session is connected to a plan. Minting a new one ends the previous listener.
65
65
  - **Output — JSON Lines.** One `{"type":"event","id":<id|null>,"text":"…"}` per event on the connected plan's cards, where `text` is `[DX-8 "Title" repo:board] newms87 answered "Which rollout order?": chose "Pause E2E" — note: "only this week"`, `… answered "…": "<free-form answer>"`, `… commented: "…"`, `… opened a problem: "…"`, `… blocked the card: "…"`, or `… unblocked the card`. An event it cannot read still produces one, with a `could not read event` text. Nothing for keep-alives, reconnects or re-mints. The session's own writes are never echoed back to it.
66
- - **Stopping.** Last, one `{"type":"stopped","reason":"…","detail":"…","fix":"…"}` and exit, only on a terminal outcome. `fix` is the remedy shown to the session (`STOP_FIXES` in `src/bridge.ts` — never a log-only hint), so a session that hits a stop it cannot otherwise see still knows what to do about it. Terminal reasons: `no_connection_record` / `credential_unavailable` / `credential_mismatch` (the start-time checks above), `not_connected`, `unauthorized` (401/403), `mint_refused` (any other non-transient refusal), `mint_bad_response`, `board_unreadable` / `scope_check_failed` (the reach check above), `superseded` / `replaced` (exit 0 — a newer or replacing connection already serves this session, so `fix` says no action is needed), `revoked`, or `refused` (two freshly minted tickets refused in a row). A transient mint failure (network, timeout, 408, 429, 5xx) backs off and retries; a lapsed ticket lease re-mints.
66
+ - **Stopping.** Last, one `{"type":"stopped","reason":"…","detail":"…","fix":"…"}` and exit, only on a terminal outcome. `fix` is the remedy shown to the session (`STOP_FIXES` in `src/bridge.ts` — never a log-only hint), so a session that hits a stop it cannot otherwise see still knows what to do about it. Terminal reasons: `no_connection_record` / `credential_unavailable` / `credential_mismatch` (the start-time checks above), `not_connected` (a `409 session_not_connected` at mint OR at stream admission, or a live `end("not_connected")` — all three mapped identically), `unauthorized` (401/403 for a reason OTHER than an unreadable board), `mint_refused` (any other non-transient mint refusal), `mint_bad_response`, `board_unreadable` (a `403 issuer_cannot_read_plan_boards` at mint OR at stream admission, naming the unreadable boards — DX-2920), `scope_narrowed` (a LIVE stream's `end("scope_narrowed", boards)` — the server's `end()` REQUIRES `boards` for this reason at the type level, `bridge` parses them off the wire and names them in `detail`/`fix`, never a generic message), `bad_end_payload` (a `scope_narrowed` end whose `boards` array was missing or malformed — a protocol error surfaced loudly, never silently downgraded to a boards-less `scope_narrowed`), `superseded` / `replaced` (exit 0 — a newer or replacing connection already serves this session, so `fix` says no action is needed), `revoked`, or `refused` (two freshly minted tickets refused in a row with no more specific classification). A transient mint failure (network, timeout, 408, 429, 5xx) backs off and retries; a lapsed ticket lease re-mints.
67
67
  - **Reconnect and resume.** A read-idle timeout (three missed keep-alives) turns a silently dead connection into a drop. Capped exponential backoff (1s → 30s) that resets only after a healthy connection, with `Last-Event-ID`, so a dashboard restart replays what was missed and nothing is emitted twice. `--resume-ids` carries the same guarantee across a process restart: the ids already delivered seed the duplicate guard, and the highest is the first `Last-Event-ID`. The dashboard floors that replay at the later of the session's first ticket and when it joined its current plan, so a restart loses nothing and a plan move replays nothing from before the move.
68
68
 
69
69
  ## Build + test
package/dist/bridge.js CHANGED
@@ -20,29 +20,59 @@
20
20
  * credential from its session's tools streamed happily and relayed nothing.
21
21
  * 3. Mints the session's listener ticket (`POST /api/plan-sessions/me/stream-ticket`).
22
22
  * The ticket stays in this process.
23
- * 4. VERIFIES THE REACH IT JUST BOUGHT: the credential must be able to read
24
- * every board the connected plan's cards live on, because the stream admits
25
- * an event only when the ticket issuer's board allowlist covers that
26
- * event's board (`isVisibleToStream`, dashboard). A board it cannot read is
27
- * a board whose events would vanish without a word, so it is a terminal,
28
- * named outcome instead.
23
+ * 4. DX-2920 — board-coverage reach is a SERVER-ENFORCED property now, never a
24
+ * client-side verify: the stream admits an event only when the ticket
25
+ * issuer's board allowlist covers that event's board (`isVisibleToStream`,
26
+ * dashboard), and the dashboard itself (DX-2863) refuses to hand out a
27
+ * ticket, refuses to admit a connection, and ends a live stream the instant
28
+ * that stops being true — `403 issuer_cannot_read_plan_boards` at MINT
29
+ * (`mintTicket`) AND at stream ADMISSION (`classifyAdmissionRefusal`,
30
+ * reading `listen.ts`'s structured `refusal`) are BOTH mapped to
31
+ * `board_unreadable`, naming the boards; `409 session_not_connected` at
32
+ * mint AND at admission are both mapped to `not_connected`; and
33
+ * `end("scope_narrowed", boards)` / `end("not_connected")` cover the
34
+ * same two failures once the stream is already live — `end`'s own
35
+ * overloaded signature in `plan-session-stream.ts` REQUIRES `boards` for
36
+ * `scope_narrowed` at the type level (round 3), and `listen.ts` parses
37
+ * them off the wire the same way it parses `reason`, so this module can
38
+ * name them in `scope_narrowed`'s detail/fix exactly like it already
39
+ * does for `board_unreadable`. This module no longer runs its own
40
+ * duplicate board-by-board check before streaming — see DX-2920 and the
41
+ * deleted `verifyPlanBoardsReadable`.
29
42
  * 5. Streams through `runListener` (`listen.ts`), which reconnects within the
30
43
  * ticket, and re-mints when that ticket's lease runs out or a fresh ticket
31
44
  * is refused once.
32
- * 6. Writes JSON Lines to stdout: ONE `{type:"ready", boards}` once the stream
33
- * is live and verified, `{type:"event", id, text}` per event, and ONE final
34
- * `{type:"stopped", reason, detail, fix}` before exiting.
45
+ * 6. Writes JSON Lines to stdout: ONE `{type:"ready"}` once a ticket has been
46
+ * minted and streaming is about to start, `{type:"event", id, text}` per
47
+ * event, and ONE final `{type:"stopped", reason, detail, fix}` before exiting.
35
48
  *
36
49
  * TERMINAL OUTCOMES — each ends the process with its stop record, never a retry:
37
50
  * - `no_connection_record` this session has no connection record to read;
38
51
  * - `credential_unavailable` its declared credential cannot be resolved here;
39
52
  * - `credential_mismatch` what resolved is not the server's credential;
40
- * - `not_connected` the mint answered 409 `session_not_connected`;
41
- * - `unauthorized` the mint or a scope read answered 401 / 403;
53
+ * - `not_connected` the mint, or the stream's admission / keep-alive,
54
+ * answered `session_not_connected` (409 at mint and
55
+ * at admission; `end("not_connected")` once live);
56
+ * - `unauthorized` the mint answered 401 / 403 for a reason OTHER
57
+ * than an unreadable board;
42
58
  * - `mint_refused` any other non-transient mint refusal (400, 404, …);
43
59
  * - `mint_bad_response` a 2xx mint whose body is not a ticket;
44
- * - `board_unreadable` the credential cannot read a board of this plan;
45
- * - `scope_check_failed` the plan's boards could not be established;
60
+ * - `board_unreadable` the mint OR the stream's admission was refused
61
+ * `403 issuer_cannot_read_plan_boards`, naming the
62
+ * boards this credential cannot read (DX-2920);
63
+ * - `scope_narrowed` a LIVE stream ended because the plan grew, or the
64
+ * credential's allowlist shrank past, a board this
65
+ * credential can no longer read — `boards` names
66
+ * them, parsed from the dashboard's own `end`
67
+ * payload by `listen.ts` the same way `reason`
68
+ * itself is parsed, and `detail`/`fix` are built
69
+ * dynamically from them (DX-2920 round 3 —
70
+ * `boardUnreadableDetail` / `scopeNarrowedFix`),
71
+ * never a static generic message;
72
+ * - `bad_end_payload` a `scope_narrowed` end whose `boards` array was
73
+ * missing or malformed — a PROTOCOL error, never
74
+ * silently downgraded to a boards-less
75
+ * `scope_narrowed` stop (DX-2920 round 3);
46
76
  * - `superseded` / `replaced` (exit 0) — another listener now holds the session;
47
77
  * - `revoked` the dashboard revoked the ticket or its issuer;
48
78
  * - `refused` freshly minted tickets were refused twice in a row.
@@ -54,14 +84,13 @@
54
84
  * session's own MCP server declared, resolved here from that declaration.
55
85
  */
56
86
  import { homedir } from "node:os";
87
+ import { errorCodeOf } from "./json-body.js";
57
88
  import { oneLine } from "./one-line.js";
58
89
  import { credentialFingerprint, describeCredentialSource, readCredential, } from "./credential.js";
59
90
  import { readSessionConnection } from "./session-connection.js";
60
91
  import { DELIVERED_ID_MEMORY, HEALTHY_CONNECTION_MS, READ_IDLE_TIMEOUT_MS, runListener, } from "./listen.js";
61
92
  export const BRIDGE_SUBCOMMAND = "bridge";
62
93
  export const STREAM_TICKET_PATH = "/api/plan-sessions/me/stream-ticket";
63
- export const PLAN_MINE_PATH = "/api/plans/mine";
64
- export const ISSUES_PATH = "/api/issues";
65
94
  export const SESSION_ID_HEADER = "x-danx-session-id";
66
95
  /** How long one dashboard request may take before it counts as a transient failure. */
67
96
  export const REQUEST_TIMEOUT_MS = 10_000;
@@ -69,10 +98,7 @@ export const MINT_INITIAL_BACKOFF_MS = 1_000;
69
98
  export const MINT_MAX_BACKOFF_MS = 60_000;
70
99
  /** A freshly minted ticket refused this many times in a row is a terminal outcome, not a loop. */
71
100
  export const MAX_REFUSED_TICKETS_IN_A_ROW = 2;
72
- /** One page of the connected plan's cards, for the board-reach check. The server's ceiling. */
73
- export const PLAN_CARDS_PAGE_SIZE = 1_000;
74
101
  const TRANSIENT_CLIENT_STATUSES = new Set([408, 429]);
75
- const TAKEOVER_REASONS = new Set(["superseded", "replaced"]);
76
102
  const USAGE = `usage: CLAUDE_CODE_SESSION_ID=<session-id> danx-dashboard-mcp ${BRIDGE_SUBCOMMAND} ` +
77
103
  `[--resume-ids <event-id>[,<event-id>...]]`;
78
104
  /**
@@ -95,7 +121,11 @@ export const STOP_FIXES = {
95
121
  mint_refused: "check that this session is connected to a plan on the dashboard its MCP server points at, then call plan_connect again",
96
122
  mint_bad_response: "check that DANXBOT_DASHBOARD_URL for this session's MCP server points at a danxbot dashboard",
97
123
  board_unreadable: "scope this session's dashboard credential to read that board, or connect a plan whose cards all live on boards it can read",
98
- scope_check_failed: "check the dashboard is reachable and this session is connected to a plan, then call plan_connect again",
124
+ // DX-2920 (round 3) — a `scope_narrowed` end whose `boards` array was
125
+ // missing or invalid (`listen.ts`'s parse-time check). Named separately
126
+ // from `scope_narrowed` itself, which always carries boards and never
127
+ // reaches this generic map at all.
128
+ bad_end_payload: "call plan_connect again to mint a new listener ticket for this session",
99
129
  // A newer ticket exists (superseded) or another connection is already using
100
130
  // this one (replaced) — THIS process is ending because the session is
101
131
  // already served elsewhere, not because anything is broken.
@@ -127,6 +157,11 @@ export function parseBridgeArgs(argv, env) {
127
157
  }
128
158
  /** A start this process cannot make, named the way the session will be told about it. */
129
159
  export class BridgeStartError extends Error {
160
+ // DX-2920 (round 4) — `ListenStopReason`, not `string`: a start-time failure
161
+ // is always one of `no_connection_record` / `credential_unavailable` /
162
+ // `credential_mismatch`, never `scope_narrowed` (there is no stream yet), so
163
+ // this can be the closed reason type the eventual `write({type:"stopped",
164
+ // reason: start.reason, ...})` needs it to be.
130
165
  reason;
131
166
  fix;
132
167
  constructor(reason, detail, fix) {
@@ -175,15 +210,43 @@ export function resolveBridgeOptions(args, env, options = {}) {
175
210
  resumeIds: args.resumeIds,
176
211
  };
177
212
  }
178
- function errorCodeOf(body) {
179
- return typeof body === "object" && body !== null && typeof body.error === "string"
180
- ? body.error
181
- : null;
213
+ /**
214
+ * DX-2920 — `body.boards` off a `403 issuer_cannot_read_plan_boards` refusal
215
+ * (`unreadableBoardsRefusal`, dashboard `plan-scope.ts`), or `[]` when the body
216
+ * doesn't carry a readable list (defensive — never trust the wire shape blindly).
217
+ */
218
+ function boardsOf(body) {
219
+ if (typeof body !== "object" || body === null)
220
+ return [];
221
+ const boards = body.boards;
222
+ return Array.isArray(boards) ? boards.filter((b) => typeof b === "string") : [];
223
+ }
224
+ /**
225
+ * DX-2920 — the ONE `board_unreadable` detail-line builder, shared by the mint
226
+ * 403 classification and the stream admission's identical 403, so the two can
227
+ * never drift into two different ideas of how to phrase "these boards".
228
+ */
229
+ function boardUnreadableDetail(boards, fallback) {
230
+ return boards.length > 0
231
+ ? `this session's dashboard credential cannot read board(s) ${boards.join(", ")} on the connected plan`
232
+ : fallback;
233
+ }
234
+ /**
235
+ * DX-2920 (round 3) — `scope_narrowed`'s fix, built dynamically from the
236
+ * boards `listen.ts` parsed off the dashboard's own `end` payload (AC 30661:
237
+ * "naming the missing boards and how to widen scope"), replacing the static
238
+ * generic `STOP_FIXES` entry round 2 shipped before the server actually sent
239
+ * a board list.
240
+ */
241
+ function scopeNarrowedFix(boards) {
242
+ return boards.length > 0
243
+ ? `widen this session's dashboard credential to cover board(s) ${boards.join(", ")}, or connect a plan whose cards all live on boards it can read`
244
+ : "widen this session's dashboard credential to cover every board the connected plan's cards live on, or connect a plan whose cards all live on boards it can read";
182
245
  }
183
246
  /**
184
247
  * One authenticated request, with a timeout, classifying the failures that are
185
- * about THIS MOMENT (network, timeout, 408/429, 5xx) as transient. Shared by the
186
- * mint and the board-reach check so the two can never disagree about which
248
+ * about THIS MOMENT (network, timeout, 408/429, 5xx) as transient. Shared by
249
+ * every request this module makes so they can never disagree about which
187
250
  * failures are worth retrying.
188
251
  */
189
252
  async function request(options, deps, what, url, init = { method: "GET" }) {
@@ -259,6 +322,19 @@ export async function mintTicket(options, deps) {
259
322
  if (status === 409 && errorCodeOf(body) === "session_not_connected") {
260
323
  return { kind: "terminal", reason: "not_connected", detail: "the session is not connected to a plan" };
261
324
  }
325
+ // DX-2920 (AC 30660) — this credential IS accepted; it just cannot read every
326
+ // board the connected plan covers (`unreadableBoardsRefusal`, dashboard
327
+ // `plan-scope.ts`). Calling that `unauthorized` told the session to fix its
328
+ // credential, which was the wrong remedy for a scope problem — this is
329
+ // `board_unreadable`, naming the boards, checked BEFORE the generic 401/403
330
+ // fallback below so it never falls into it.
331
+ if (status === 403 && errorCodeOf(body) === "issuer_cannot_read_plan_boards") {
332
+ return {
333
+ kind: "terminal",
334
+ reason: "board_unreadable",
335
+ detail: boardUnreadableDetail(boardsOf(body), `ticket mint HTTP 403: issuer_cannot_read_plan_boards ${oneLine(text, 300)}`),
336
+ };
337
+ }
262
338
  if (status === 401 || status === 403) {
263
339
  return { kind: "terminal", reason: "unauthorized", detail: `ticket mint HTTP ${status} ${oneLine(text, 300)}` };
264
340
  }
@@ -277,121 +353,60 @@ export async function mintTicket(options, deps) {
277
353
  leaseMs: ticket.leaseMs,
278
354
  };
279
355
  }
280
- /** One card per board of the connected plan, read a page at a time. */
281
- function cardsPageOf(body) {
282
- if (typeof body !== "object" || body === null)
283
- return "the body is not a JSON object";
284
- const b = body;
285
- if (!Array.isArray(b.cards))
286
- return "cards is not an array";
287
- if (typeof b.cards_total !== "number" || !Number.isSafeInteger(b.cards_total))
288
- return "cards_total is not an integer";
289
- const cards = [];
290
- for (const card of b.cards) {
291
- if (typeof card !== "object" || card === null)
292
- return "a card is not an object";
293
- const c = card;
294
- if (typeof c.id !== "string" || typeof c.boardId !== "string")
295
- return "a card has no id / boardId";
296
- cards.push({ id: c.id, boardId: c.boardId });
297
- }
298
- return { cards, total: b.cards_total };
299
- }
300
356
  /**
301
- * Prove this credential can read every board the connected plan's cards live on.
357
+ * DX-2920 — a stream admission refusal (`listen.ts`'s STRUCTURED `refused`
358
+ * outcome — `{status, errorCode, body}`, parsed once in `connectOnce`) whose
359
+ * shape matches one the mint route already gives its OWN name to. Two cases:
302
360
  *
303
- * WHY THIS EXISTS AT ALL. The stream admits an event only when the ticket
304
- * issuer's board allowlist covers the event's board and the issuer still holds
305
- * `read` (`isVisibleToStream` / `resolveIssuerScope`, dashboard). A credential
306
- * short of that streams exactly as happily as a correct one and simply never
307
- * relays those boards' events — silence that looks identical to "nothing has
308
- * happened yet".
361
+ * - `409 session_not_connected` — the SAME shape the mint route uses for "no
362
+ * plan connected" (`plan-session-stream.ts`, DX-2863 review finding M2). A
363
+ * session with no plan connected is the identical terminal state whether
364
+ * the dashboard says so at mint or at the stream's own admission check,
365
+ * and the session should see ONE remedy either way.
366
+ * - `403 issuer_cannot_read_plan_boards` — the SAME shape the mint route's
367
+ * board-coverage refusal uses (AC 30660's fix 4 — a ticket minted before a
368
+ * board became unreadable can still be refused ADMISSION for it later; that
369
+ * refusal deserves the same `board_unreadable` naming as the mint-time one,
370
+ * not a re-mint-and-retry loop that just hits the identical refusal again).
309
371
  *
310
- * WHY THESE TWO ROUTES. `GET /api/plans/mine?fields=cards` is the only read that
311
- * names the connected plan's cards, and `GET /api/issues/:id/problems` is a card
312
- * read that runs the SAME board-allowlist + `read` check the stream does
313
- * (`withV2Handler` + `resolveIssueBoardId`). Deliberately NOT `GET /api/issues`
314
- * or `GET /api/issues/:id`: neither checks the caller's board allowlist at all,
315
- * so a check built on them would pass precisely where the stream drops events.
372
+ * Classifies by STATUS + ERROR CODE, never by re-deriving them from the
373
+ * flattened `detail` string — the second-source-of-truth shape this card's fix
374
+ * round closed (the regex-based `isSessionNotConnectedRefusal` it replaced).
316
375
  */
317
- export async function verifyPlanBoardsReadable(options, deps) {
318
- const firstCardPerBoard = new Map();
319
- let offset = 0;
320
- for (;;) {
321
- const url = `${options.dashboardUrl}${PLAN_MINE_PATH}?fields=cards&cards_limit=${PLAN_CARDS_PAGE_SIZE}&cards_offset=${offset}`;
322
- const outcome = await request(options, deps, "plan read", url);
323
- if (outcome.kind === "transient")
324
- return outcome;
325
- const { status, text, body } = outcome;
326
- if (status === 409 && errorCodeOf(body) === "session_not_connected") {
327
- return { kind: "terminal", reason: "not_connected", detail: "the session is not connected to a plan" };
328
- }
329
- if (status === 401 || status === 403) {
330
- return { kind: "terminal", reason: "unauthorized", detail: `plan read HTTP ${status} ${oneLine(text, 300)}` };
331
- }
332
- if (status < 200 || status >= 300) {
333
- return { kind: "terminal", reason: "scope_check_failed", detail: `plan read HTTP ${status} ${oneLine(text, 300)}` };
334
- }
335
- const page = cardsPageOf(body);
336
- if (typeof page === "string") {
337
- return { kind: "terminal", reason: "scope_check_failed", detail: `plan read HTTP ${status}: ${page}` };
338
- }
339
- for (const card of page.cards) {
340
- if (!firstCardPerBoard.has(card.boardId))
341
- firstCardPerBoard.set(card.boardId, card.id);
342
- }
343
- offset += page.cards.length;
344
- if (page.cards.length === 0 || offset >= page.total)
345
- break;
376
+ function classifyAdmissionRefusal(refusal) {
377
+ if (refusal === undefined)
378
+ return null;
379
+ if (refusal.status === 409 && refusal.errorCode === "session_not_connected") {
380
+ return { reason: "not_connected", detail: "the session is not connected to a plan" };
346
381
  }
347
- for (const [boardId, cardId] of firstCardPerBoard) {
348
- // `?board=` is not read by this route's auth check (it derives the board from
349
- // `cardId`'s own `board_id` — `resolveIssueBoardId`) — kept only so the URL and
350
- // the `board read of ${boardId}` label above it name the same board at a glance.
351
- const url = `${options.dashboardUrl}${ISSUES_PATH}/${encodeURIComponent(cardId)}/problems?board=${encodeURIComponent(boardId)}`;
352
- const outcome = await request(options, deps, `board read of ${boardId}`, url);
353
- if (outcome.kind === "transient")
354
- return outcome;
355
- const { status, text } = outcome;
356
- if (status === 403) {
357
- return {
358
- kind: "terminal",
359
- reason: "board_unreadable",
360
- detail: `this session's dashboard credential cannot read board ${boardId}, which the connected plan's card ` +
361
- `${cardId} lives on — the event stream drops that board's events silently (${oneLine(text, 200)})`,
362
- };
363
- }
364
- if (status === 401) {
365
- return { kind: "terminal", reason: "unauthorized", detail: `board read of ${boardId} HTTP 401 ${oneLine(text, 200)}` };
366
- }
367
- if (status === 404) {
368
- // The card moved or was deleted between the two reads. Nothing is wrong
369
- // with the credential — re-read the plan rather than accuse it.
370
- return { kind: "transient", detail: `card ${cardId} vanished between the plan read and the board read` };
371
- }
372
- if (status < 200 || status >= 300) {
373
- return {
374
- kind: "terminal",
375
- reason: "scope_check_failed",
376
- detail: `board read of ${boardId} HTTP ${status} ${oneLine(text, 200)}`,
377
- };
378
- }
382
+ if (refusal.status === 403 && refusal.errorCode === "issuer_cannot_read_plan_boards") {
383
+ return {
384
+ reason: "board_unreadable",
385
+ detail: boardUnreadableDetail(boardsOf(refusal.body), "this session's dashboard credential cannot read a board on the connected plan"),
386
+ };
379
387
  }
380
- return { kind: "ok", boards: [...firstCardPerBoard.keys()] };
388
+ return null;
381
389
  }
382
390
  /**
383
- * Mint, verify, stream, re-mint — until a terminal outcome, which it writes as
384
- * the one `stopped` record. Returns 0 when another listener took over, 1 otherwise.
391
+ * Mint, stream, re-mint — until a terminal outcome, which it writes as the one
392
+ * `stopped` record. Returns 0 when another listener took over, 1 otherwise.
385
393
  */
386
394
  export async function runBridge(options, deps) {
387
395
  const delivered = options.resumeIds.slice(-DELIVERED_ID_MEMORY);
388
- const stop = (reason, detail, code) => {
389
- deps.write({ type: "stopped", reason, detail, fix: fixForStop(reason) });
396
+ function stop(reason, detail, code, extra) {
397
+ if (reason === "scope_narrowed") {
398
+ if (extra?.boards === undefined) {
399
+ throw new Error('stop("scope_narrowed", ...) requires boards — unreachable through the exported overloads above');
400
+ }
401
+ deps.write({ type: "stopped", reason, detail, boards: extra.boards, fix: scopeNarrowedFix(extra.boards) });
402
+ return code;
403
+ }
404
+ deps.write({ type: "stopped", reason, detail, refusal: extra?.refusal, fix: extra?.fix ?? fixForStop(reason) });
390
405
  return code;
391
- };
406
+ }
392
407
  let backoff = MINT_INITIAL_BACKOFF_MS;
393
408
  let refusedInRow = 0;
394
- let verified = false;
409
+ let readyEmitted = false;
395
410
  for (;;) {
396
411
  const minted = await mintTicket(options, deps);
397
412
  if (minted.kind === "terminal")
@@ -401,19 +416,13 @@ export async function runBridge(options, deps) {
401
416
  backoff = Math.min(MINT_MAX_BACKOFF_MS, backoff * 2);
402
417
  continue;
403
418
  }
404
- // The reach check runs on the FIRST ticket only: it is a property of the
405
- // credential and the plan, and a re-mint (a lapsed lease) changes neither.
406
- if (!verified) {
407
- const scope = await verifyPlanBoardsReadable(options, deps);
408
- if (scope.kind === "terminal")
409
- return stop(scope.reason, scope.detail, 1);
410
- if (scope.kind === "transient") {
411
- await deps.sleep(backoff);
412
- backoff = Math.min(MINT_MAX_BACKOFF_MS, backoff * 2);
413
- continue;
414
- }
415
- verified = true;
416
- deps.write({ type: "ready", boards: scope.boards });
419
+ // DX-2920 — `ready` fires once, on the FIRST successful mint, now that
420
+ // board reach is proven by the dashboard's own mint refusal rather than a
421
+ // client-side check run here (see the module docblock + the deleted
422
+ // `verifyPlanBoardsReadable`). A re-mint (a lapsed lease) never repeats it.
423
+ if (!readyEmitted) {
424
+ readyEmitted = true;
425
+ deps.write({ type: "ready" });
417
426
  }
418
427
  backoff = MINT_INITIAL_BACKOFF_MS;
419
428
  const run = { stopped: null, emitted: false };
@@ -437,12 +446,28 @@ export async function runBridge(options, deps) {
437
446
  const stopped = run.stopped;
438
447
  if (stopped === null)
439
448
  throw new Error("the stream reader ended without a stop record");
440
- if (TAKEOVER_REASONS.has(stopped.reason))
449
+ // DX-2920 (round 4) — direct equality, not `TAKEOVER_REASONS.has(...)`:
450
+ // `Set.has()` is a runtime check only, so it cannot narrow `stopped.reason`
451
+ // for the compiler — leaving it a `Set` lookup here would keep every
452
+ // downstream branch's exhaustive narrowing (which is what makes the final
453
+ // fallthrough's `stop(stopped.reason, ...)` call type-check against the
454
+ // closed `ListenStopReason` overload) from working.
455
+ if (stopped.reason === "superseded" || stopped.reason === "replaced")
441
456
  return stop(stopped.reason, stopped.detail, 0);
442
457
  if (stopped.reason === "lease_expired") {
443
458
  refusedInRow = 0;
444
459
  continue;
445
460
  }
461
+ if (stopped.reason === "refused") {
462
+ const classified = classifyAdmissionRefusal(stopped.refusal);
463
+ if (classified !== null) {
464
+ // DX-2920 — a NAMED admission refusal is terminal at once, exactly
465
+ // like its mint-time counterpart — never counted toward the generic
466
+ // refused-ticket retry budget below, which exists for an admission
467
+ // refusal with no more specific explanation than "not admitted".
468
+ return stop(classified.reason, classified.detail, 1);
469
+ }
470
+ }
446
471
  if (stopped.reason === "refused") {
447
472
  const provedHealthy = run.emitted || deps.now() - startedAt >= HEALTHY_CONNECTION_MS;
448
473
  refusedInRow = provedHealthy ? 1 : refusedInRow + 1;
@@ -451,6 +476,15 @@ export async function runBridge(options, deps) {
451
476
  }
452
477
  continue;
453
478
  }
479
+ if (stopped.reason === "scope_narrowed") {
480
+ // DX-2920 (round 4) — `stopped.boards` is REQUIRED at the type level now
481
+ // (`ListenStopped`'s `scope_narrowed` member), populated by `listen.ts`'s
482
+ // parse of the dashboard's own `end` payload (never a re-derivation from
483
+ // `detail`) — no `?? []`, no optional check, `stopped.boards` IS
484
+ // `string[]` once narrowed to this branch.
485
+ const boards = stopped.boards;
486
+ return stop("scope_narrowed", boardUnreadableDetail(boards, stopped.detail), 1, { boards });
487
+ }
454
488
  return stop(stopped.reason, stopped.detail, 1);
455
489
  }
456
490
  }
package/dist/handlers.js CHANGED
@@ -844,6 +844,7 @@ export const PLAN_FIELD_GROUPS = [
844
844
  "records:caveat",
845
845
  "architecture",
846
846
  "sessions",
847
+ "notes",
847
848
  ];
848
849
  /**
849
850
  * DX-2834 — the plan-status taxonomy, mirroring `PLAN_FIELD_GROUPS` just
@@ -1147,6 +1148,85 @@ export async function planDeleteRecord(client, args) {
1147
1148
  body: { content_hash: args.content_hash },
1148
1149
  });
1149
1150
  }
1151
+ /**
1152
+ * Write a milestone note to a plan, via `POST /api/plans/:plan_id/notes`.
1153
+ * TAKES AN EXPLICIT `plan_id`, unlike `plan_add_record`/`plan_add_architecture_section`
1154
+ * — mirrors `plan_add_card`'s sibling `plan_remove_card`/`plan_rename`: a
1155
+ * dispatched worker has no plan connection when it finishes a card, and a
1156
+ * card can sit on several plans, so the writer names which one the note
1157
+ * belongs to. A note is a MILESTONE, not a log — write one when a card (or a
1158
+ * related group of cards) finishes, an important decision lands, or a
1159
+ * goal/rule/caveat/architecture section changes meaningfully; routine step
1160
+ * progress stays a card comment. Links resolve on read into what they point
1161
+ * at (a card's title, a record's ref + body, a section's title) — an unknown
1162
+ * card id, an unparseable or foreign record ref, or an unknown or foreign
1163
+ * section id is refused 400 naming exactly which one. A card link does NOT
1164
+ * require the card to be a member of this plan; a record/section link MUST
1165
+ * belong to THIS plan. `author` is stamped server-side from your identity,
1166
+ * never sent by you. Unknown plan → 404. Returns the new note plus the
1167
+ * plan's latest notes page.
1168
+ */
1169
+ export async function planAddNote(client, args) {
1170
+ return client.request({
1171
+ method: "POST",
1172
+ path: `/${args.plan_id}/notes`,
1173
+ basePath: PLANS_BASE_PATH,
1174
+ body: {
1175
+ title: args.title,
1176
+ body: args.body,
1177
+ ...(args.card_ids === undefined ? {} : { card_ids: args.card_ids }),
1178
+ ...(args.record_refs === undefined ? {} : { record_refs: args.record_refs }),
1179
+ ...(args.section_ids === undefined ? {} : { section_ids: args.section_ids }),
1180
+ },
1181
+ });
1182
+ }
1183
+ /**
1184
+ * Edit a plan note, via `PATCH /api/plans/:plan_id/notes/:note_id`.
1185
+ * `content_hash` MUST be the note's `contentHash` from your last read; on a
1186
+ * mismatch nothing is written and you get `{error: "stale_plan_note",
1187
+ * currentHash, currentTitle, currentBody, currentLinks}` — merge into those
1188
+ * and retry with `content_hash: currentHash`, never blindly. `title`/`body`
1189
+ * are each optional and keep their stored value when omitted. The LINK
1190
+ * fields are all-or-nothing as a GROUP: omit all three to keep the stored
1191
+ * link set untouched; send ANY one of them to REPLACE THE WHOLE SET (never a
1192
+ * per-link add/remove — the set is the unit of change). The hash covers
1193
+ * title, body AND the link set, so a stale read of any of the three is
1194
+ * refused. TAKES AN EXPLICIT `plan_id`, same reason as `plan_add_note`.
1195
+ * Unknown plan or note id → 404. Returns the edited note plus the plan's
1196
+ * latest notes page.
1197
+ */
1198
+ export async function planUpdateNote(client, args) {
1199
+ return client.request({
1200
+ method: "PATCH",
1201
+ path: `/${args.plan_id}/notes/${args.note_id}`,
1202
+ basePath: PLANS_BASE_PATH,
1203
+ body: {
1204
+ content_hash: args.content_hash,
1205
+ ...(args.title === undefined ? {} : { title: args.title }),
1206
+ ...(args.body === undefined ? {} : { body: args.body }),
1207
+ ...(args.card_ids === undefined ? {} : { card_ids: args.card_ids }),
1208
+ ...(args.record_refs === undefined ? {} : { record_refs: args.record_refs }),
1209
+ ...(args.section_ids === undefined ? {} : { section_ids: args.section_ids }),
1210
+ },
1211
+ });
1212
+ }
1213
+ /**
1214
+ * Soft-delete a plan note, via `DELETE /api/plans/:plan_id/notes/:note_id`.
1215
+ * `content_hash` MUST be the note's `contentHash` from your last read; a
1216
+ * stale hash deletes nothing and returns the same `{error:
1217
+ * "stale_plan_note", currentHash, currentTitle, currentBody, currentLinks}`
1218
+ * shape `plan_update_note` uses. TAKES AN EXPLICIT `plan_id`, same reason as
1219
+ * `plan_add_note`. Unknown plan, unknown note, or an already-deleted note →
1220
+ * 404. Returns the plan's remaining latest notes page.
1221
+ */
1222
+ export async function planDeleteNote(client, args) {
1223
+ return client.request({
1224
+ method: "DELETE",
1225
+ path: `/${args.plan_id}/notes/${args.note_id}`,
1226
+ basePath: PLANS_BASE_PATH,
1227
+ body: { content_hash: args.content_hash },
1228
+ });
1229
+ }
1150
1230
  // ---------------- failure_category_list / _create / _update (DX-2792) ----------------
1151
1231
  /**
1152
1232
  * DX-2792 (Failure evaluation 3/4) — wraps `src/dashboard/failure-categories-routes.ts`,
package/dist/index.js CHANGED
@@ -101,7 +101,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
101
101
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
102
102
  import { z } from "zod";
103
103
  import { DashboardHttpClient } from "./http-client.js";
104
- import { issueAttach, issueChecklist, issueComment, issueCreate, issueDependency, issueEdit, issueGet, issueList, issueProblem, issueQualityGate, issueQualityGateVerdict, issueRetireBranch, issueRetro, issueSolution, issueTransition, issueTriage, briefGetPage, briefList, briefSetPage, failureCategoryCreate, failureCategoryList, failureCategoryUpdate, planAddArchitectureSection, planAddCard, planAddRecord, planConnect, planCreate, planDeleteArchitectureSection, planDeleteRecord, planGet, PLAN_FIELD_GROUPS, PLAN_STATUSES, ISSUE_BATCH_GET_MAX, LIST_PAGE_MAX_LIMIT, PLAN_GET_CARDS_DEFAULT_LIMIT, planGetArchitectureSection, planGetRecord, planList, planRemoveCard, planRename, planReorderArchitectureSection, planUpdateArchitectureSection, planUpdateRecord, repoKnowledgeGet, repoKnowledgeSet, } from "./handlers.js";
104
+ import { issueAttach, issueChecklist, issueComment, issueCreate, issueDependency, issueEdit, issueGet, issueList, issueProblem, issueQualityGate, issueQualityGateVerdict, issueRetireBranch, issueRetro, issueSolution, issueTransition, issueTriage, briefGetPage, briefList, briefSetPage, failureCategoryCreate, failureCategoryList, failureCategoryUpdate, planAddArchitectureSection, planAddCard, planAddNote, planAddRecord, planConnect, planCreate, planDeleteArchitectureSection, planDeleteNote, planDeleteRecord, planGet, PLAN_FIELD_GROUPS, PLAN_STATUSES, ISSUE_BATCH_GET_MAX, LIST_PAGE_MAX_LIMIT, PLAN_GET_CARDS_DEFAULT_LIMIT, planGetArchitectureSection, planGetRecord, planList, planRemoveCard, planRename, planReorderArchitectureSection, planUpdateArchitectureSection, planUpdateNote, planUpdateRecord, repoKnowledgeGet, repoKnowledgeSet, } from "./handlers.js";
105
105
  import { PRIORITY_TIER_WORDS } from "./priority.js";
106
106
  function readEnvOrDie(name) {
107
107
  const v = process.env[name];
@@ -793,6 +793,32 @@ server.tool("plan_delete_record", 'Soft-delete a goal/rule/caveat of your connec
793
793
  record_id: z.number().int().positive().describe("The record id to delete."),
794
794
  content_hash: z.string().describe("The record's `contentHash` from your last read. Required."),
795
795
  }, async (args) => jsonResult(await planDeleteRecord(client, args)));
796
+ server.tool("plan_add_note", "Write a milestone note to a plan's timeline, via POST /api/plans/:plan_id/notes (DX-2915). A note is a MILESTONE, not a log — write one for a card (or related group of cards) finishing, an important decision, or a meaningful goal/rule/caveat/architecture-section change; routine step progress stays a card comment, never a note. Terse tone: `title` at most 60 characters, `body` (the wrap-up) at most 250 (both 400 if too long, naming the limit and actual length). Links resolve on read into what they point at (a card's title, a record's ref+body, a section's title): an unknown card, an unparseable or foreign record ref, or an unknown or foreign section id is refused 400 naming exactly which one. A card link does NOT require the card to be a member of this plan; a record/section link MUST belong to THIS plan. `author` is stamped from your identity server-side — there is no field for it. TAKES AN EXPLICIT `plan_id` (like `plan_remove_card`/`plan_rename`), so a dispatched worker with no plan connection can still write. Unknown plan → 404. Returns the new note plus the plan's latest notes page.", {
797
+ plan_id: z.number().int().positive().describe("The plan id, from `plan_list`."),
798
+ title: z.string().min(1).describe("At most 60 characters."),
799
+ body: z.string().min(1).describe("The wrap-up, at most 250 characters."),
800
+ card_ids: z.array(z.string().min(1)).optional().describe("Card ids this note concerns, e.g. `[\"DX-2894\"]`."),
801
+ record_refs: z
802
+ .array(z.string().min(1))
803
+ .optional()
804
+ .describe("Goal/rule/caveat references this note announces, e.g. `[\"G-1\", \"R-3\", \"CAV-2\"]`."),
805
+ section_ids: z.array(z.number().int().positive()).optional().describe("Architecture section ids this note announces."),
806
+ }, async (args) => jsonResult(await planAddNote(client, args)));
807
+ server.tool("plan_update_note", 'Edit a plan note, via PATCH /api/plans/:plan_id/notes/:note_id (DX-2915). `content_hash` MUST be the note\'s `contentHash` from your last read; a mismatch writes NOTHING and returns `{error: "stale_plan_note", currentHash, currentTitle, currentBody, currentLinks}` — merge into those and retry with `content_hash: currentHash`, never blindly. `title`/`body` are each optional and keep their stored value when omitted. The link fields (`card_ids`/`record_refs`/`section_ids`) are all-or-nothing AS A GROUP: omit all three to leave the stored link set untouched; send ANY one of them to REPLACE THE WHOLE SET — there is no per-link add/remove. The hash covers title, body AND the link set. TAKES AN EXPLICIT `plan_id`, same reason as `plan_add_note`. Unknown plan or note id → 404. Returns the edited note plus the plan\'s latest notes page.', {
808
+ plan_id: z.number().int().positive().describe("The plan id, from `plan_list`."),
809
+ note_id: z.number().int().positive().describe("The note id to edit."),
810
+ content_hash: z.string().describe("The note's `contentHash` from your last read. Required."),
811
+ title: z.string().min(1).optional().describe("New title, at most 60 characters. Omit to keep the stored title."),
812
+ body: z.string().min(1).optional().describe("New wrap-up, at most 250 characters. Omit to keep the stored body."),
813
+ card_ids: z.array(z.string().min(1)).optional().describe("REPLACES the whole link set when sent (with record_refs/section_ids)."),
814
+ record_refs: z.array(z.string().min(1)).optional().describe("REPLACES the whole link set when sent (with card_ids/section_ids)."),
815
+ section_ids: z.array(z.number().int().positive()).optional().describe("REPLACES the whole link set when sent (with card_ids/record_refs)."),
816
+ }, async (args) => jsonResult(await planUpdateNote(client, args)));
817
+ server.tool("plan_delete_note", 'Soft-delete a plan note, via DELETE /api/plans/:plan_id/notes/:note_id (DX-2915). `content_hash` must be the note\'s `contentHash` from your last read; a stale hash deletes nothing and returns `{error: "stale_plan_note", currentHash, currentTitle, currentBody, currentLinks}` — the same shape `plan_update_note` uses. TAKES AN EXPLICIT `plan_id`, same reason as `plan_add_note`. Unknown plan, unknown note, or an already-deleted note → 404. Returns the plan\'s remaining latest notes page.', {
818
+ plan_id: z.number().int().positive().describe("The plan id, from `plan_list`."),
819
+ note_id: z.number().int().positive().describe("The note id to delete."),
820
+ content_hash: z.string().describe("The note's `contentHash` from your last read. Required."),
821
+ }, async (args) => jsonResult(await planDeleteNote(client, args)));
796
822
  server.tool("plan_add_card", "Add an existing card to the plan this session is connected to, via POST /api/plans/mine/cards (DX-2683). The card may live on ANY board — that is what a plan is for. Idempotent: re-adding a card already on the plan is a no-op, not an error, and a card may sit in several plans at once. This adds MEMBERSHIP only; it never edits the card. TAKES NO PLAN ID: the plan is resolved from your connected session. Not connected → `{error: \"session_not_connected\"}`. Unknown card → 404. Returns the plan's full member list.", {
797
823
  card_id: z.string().min(1).describe("An existing card id, e.g. `DX-2683`."),
798
824
  }, async (args) => jsonResult(await planAddCard(client, args)));
@@ -0,0 +1,23 @@
1
+ /**
2
+ * DX-2920 — the ONE place that reads a JSON body's top-level `.error` string.
3
+ *
4
+ * WHY THIS EXISTS. `bridge.ts` (classifying a mint refusal) and `listen.ts`
5
+ * (classifying a stream admission refusal) both need to answer "what error
6
+ * code did this JSON body carry", and before this card each answered it a
7
+ * different way: `bridge.ts` parsed the body directly, `listen.ts` never
8
+ * looked past a flattened, human-readable detail string at all — a caller
9
+ * that wanted to react to a SPECIFIC refusal (the admission-time 409 the
10
+ * mint route also uses) had to regex-scrape that flattened string back apart,
11
+ * a second, fragile source of truth for the same fact. This module is generic
12
+ * — it knows nothing about "session_not_connected" or "issuer_cannot_read_plan_boards",
13
+ * only that a JSON object may carry a top-level string field named `error` —
14
+ * so it can live in BOTH the domain-aware `bridge.ts` and the deliberately
15
+ * generic `listen.ts` (whose own docblock states it carries no domain
16
+ * knowledge of the dashboard's specific error shapes) without either one
17
+ * importing the other.
18
+ */
19
+ export function errorCodeOf(body) {
20
+ return typeof body === "object" && body !== null && typeof body.error === "string"
21
+ ? body.error
22
+ : null;
23
+ }
package/dist/listen.js CHANGED
@@ -12,9 +12,27 @@
12
12
  * `text` is what the session reads; an event it cannot read still produces one
13
13
  * (`could not read event …`), never silence. `id` is `null` only for a frame
14
14
  * that carried no usable id.
15
- * - `{type:"stopped", reason, detail}` — once, last. `reason` is the dashboard's
16
- * own end reason (`superseded`, `replaced`, `revoked`), `refused` (the ticket
17
- * was not admitted), or `lease_expired` (no healthy connection for the lease).
15
+ * - `{type:"stopped", reason, detail, refusal?, boards?}` — once, last. `reason`
16
+ * is the dashboard's own end reason (`superseded`, `replaced`, `revoked`,
17
+ * `scope_narrowed`, `not_connected`), `refused` (the ticket was not
18
+ * admitted), `bad_end_payload` (a `scope_narrowed` end whose `boards` array
19
+ * was missing/invalid — a protocol error, never silently downgraded), or
20
+ * `lease_expired` (no healthy connection for the lease). DX-2920 —
21
+ * `refusal` is populated ONLY for `reason === "refused"`: `{status,
22
+ * errorCode, body}`, the admission response parsed ONCE here, so a
23
+ * consumer can recognize a SPECIFIC refusal (the dashboard's own
24
+ * `409 session_not_connected`, the same shape the mint route uses) by
25
+ * status + error code rather than re-deriving it from the flattened
26
+ * `detail` string. `boards` is REQUIRED (round 4 — a discriminated union,
27
+ * not an optional field: see `ListenStopped` / `ListenStopReason`) for
28
+ * `reason === "scope_narrowed"` and absent for every other reason, parsed
29
+ * from the dashboard's `end` payload the SAME way `reason` itself is
30
+ * parsed — never re-derived from `detail`. A wire `reason` value this
31
+ * build does not recognize (a newer dashboard's reason an older bridge
32
+ * hasn't shipped yet) normalizes to `"unknown"` rather than passing the
33
+ * raw string through, so the closed set stays closed — `fixForStop`'s
34
+ * generic `DEFAULT_STOP_FIX` handles it exactly as it already handled a
35
+ * verbatim unrecognized string.
18
36
  * - nothing for keep-alives, the connect marker, or a successful reconnect.
19
37
  *
20
38
  * RECONNECT AND RESUME. The stream drops whenever the dashboard restarts, and can
@@ -32,6 +50,7 @@
32
50
  * (`src/issues/db/issue-activity.ts`); this published package cannot import
33
51
  * danxbot source.
34
52
  */
53
+ import { errorCodeOf } from "./json-body.js";
35
54
  import { oneLine } from "./one-line.js";
36
55
  export const INITIAL_BACKOFF_MS = 1_000;
37
56
  export const MAX_BACKOFF_MS = 30_000;
@@ -181,6 +200,23 @@ function parseBlock(block) {
181
200
  }
182
201
  return sawField ? { id, event, data: data.join("\n") } : null;
183
202
  }
203
+ /**
204
+ * DX-2920 (round 4) — the dashboard's own `StreamEndReason` values this
205
+ * client recognizes on the wire, EXCLUDING `scope_narrowed` (parsed by its own
206
+ * dedicated branch in `handle()` below, since it alone carries `boards`).
207
+ * Hand-copied from `StreamEndReason` in `src/issues/plan-session-listeners.ts`
208
+ * — this published package cannot import danxbot source. A wire `reason` this
209
+ * set does not contain (a newer dashboard's reason this build predates)
210
+ * normalizes to `"unknown"` rather than passing the raw string through, which
211
+ * is what lets `Outcome`'s `reason` field below be a closed union instead of
212
+ * `string` — see `ListenStopReason`'s docblock for why that closure is what
213
+ * makes the `scope_narrowed`-requires-`boards` invariant an actual compile
214
+ * error rather than a comment's promise.
215
+ */
216
+ const KNOWN_WIRE_END_REASONS = new Set(["superseded", "replaced", "revoked", "not_connected"]);
217
+ function isKnownWireEndReason(reason) {
218
+ return KNOWN_WIRE_END_REASONS.has(reason);
219
+ }
184
220
  /** Statuses that describe the moment, not the ticket — retry them. */
185
221
  const RETRYABLE_CLIENT_STATUSES = new Set([408, 429]);
186
222
  /**
@@ -201,14 +237,55 @@ export async function runListener(options, deps) {
201
237
  };
202
238
  for (const id of options.resumeIds.slice(-DELIVERED_ID_MEMORY))
203
239
  remember(id);
204
- const stop = (reason, detail, code) => {
205
- deps.write({ type: "stopped", reason, detail });
240
+ function stop(reason, detail, code, extra) {
241
+ if (reason === "scope_narrowed") {
242
+ if (extra?.boards === undefined) {
243
+ throw new Error('stop("scope_narrowed", ...) requires boards — unreachable through the exported overloads above');
244
+ }
245
+ deps.write({ type: "stopped", reason, detail, boards: extra.boards });
246
+ return code;
247
+ }
248
+ deps.write({ type: "stopped", reason, detail, refusal: extra?.refusal });
206
249
  return code;
207
- };
250
+ }
208
251
  const handle = (message) => {
209
252
  if (message.event === "end") {
210
- const reason = JSON.parse(message.data).reason;
211
- return { kind: "ended", reason: typeof reason === "string" ? reason : "unknown" };
253
+ // DX-2920 (round 3) — a JSON.parse failure here falls through to the
254
+ // pre-existing "unknown" reason below via `isRecord(null)`, unchanged
255
+ // from before this round. Only a `scope_narrowed` reason additionally
256
+ // REQUIRES a valid `boards` string array (matching the server's `end`
257
+ // overload, which cannot emit `scope_narrowed` without one) — a
258
+ // `scope_narrowed` frame that fails that check is a PROTOCOL error
259
+ // (a server too old to have sent boards, or a corrupted frame), never
260
+ // silently downgraded to the generic "the dashboard ended this
261
+ // listener" message the caller cannot act on.
262
+ let parsed;
263
+ try {
264
+ parsed = JSON.parse(message.data);
265
+ }
266
+ catch {
267
+ parsed = null;
268
+ }
269
+ const reasonRaw = isRecord(parsed) ? parsed.reason : undefined;
270
+ const reason = typeof reasonRaw === "string" ? reasonRaw : "unknown";
271
+ if (reason === "scope_narrowed") {
272
+ const boardsRaw = isRecord(parsed) ? parsed.boards : undefined;
273
+ const boards = Array.isArray(boardsRaw) && boardsRaw.length > 0 && boardsRaw.every((b) => typeof b === "string")
274
+ ? boardsRaw
275
+ : null;
276
+ if (boards === null) {
277
+ return { kind: "ended", reason: "bad_end_payload" };
278
+ }
279
+ return { kind: "ended", reason, boards };
280
+ }
281
+ // DX-2920 (round 4) — a wire reason this build does not recognize (a
282
+ // newer dashboard's reason an older bridge hasn't shipped yet)
283
+ // normalizes to "unknown" rather than passing the raw string through:
284
+ // `Outcome`'s "ended" reason is now a closed set (`EndedReason`), and an
285
+ // open passthrough here would be exactly the hole that lets a
286
+ // missing-`boards` "scope_narrowed" construction slip past the compiler
287
+ // elsewhere — see `ListenStopReason`'s docblock.
288
+ return { kind: "ended", reason: isKnownWireEndReason(reason) ? reason : "unknown" };
212
289
  }
213
290
  if (message.event !== "activity")
214
291
  return null;
@@ -262,7 +339,25 @@ export async function runListener(options, deps) {
262
339
  armIdle();
263
340
  const response = await deps.fetch(options.streamUrl, { headers, signal: controller.signal });
264
341
  if (response.status >= 400 && response.status < 500 && !RETRYABLE_CLIENT_STATUSES.has(response.status)) {
265
- return { kind: "refused", detail: `HTTP ${response.status} ${oneLine(await response.text(), 300)}` };
342
+ const text = await response.text();
343
+ // DX-2920 — parsed ONCE, here, into a structured outcome a consumer can
344
+ // classify by status + error code (`bridge.ts` does, matching a specific
345
+ // admission refusal such as the dashboard's own `409 session_not_connected`)
346
+ // rather than re-deriving it from the flattened `detail` string below.
347
+ let body = null;
348
+ try {
349
+ body = text === "" ? null : JSON.parse(text);
350
+ }
351
+ catch {
352
+ body = null;
353
+ }
354
+ return {
355
+ kind: "refused",
356
+ status: response.status,
357
+ errorCode: errorCodeOf(body),
358
+ body,
359
+ detail: `HTTP ${response.status} ${oneLine(text, 300)}`,
360
+ };
266
361
  }
267
362
  if (!response.ok || response.body === null) {
268
363
  await response.body?.cancel();
@@ -296,10 +391,23 @@ export async function runListener(options, deps) {
296
391
  if (outcome.reason === "superseded" || outcome.reason === "replaced") {
297
392
  return stop(outcome.reason, "another listener for this session took over", 0);
298
393
  }
394
+ if (outcome.reason === "bad_end_payload") {
395
+ return stop("bad_end_payload", "the dashboard's scope_narrowed end event carried no valid boards array — this is a protocol error, not an ordinary end", 1);
396
+ }
397
+ // DX-2920 (round 4) — split explicitly rather than the old conditional
398
+ // `outcome.boards === undefined ? undefined : {boards}` pass-through:
399
+ // `Outcome`'s "ended" kind is now itself discriminated on `reason`, so
400
+ // `outcome.boards` is GUARANTEED present here (no `?? []`, no optional
401
+ // check needed) once narrowed to `"scope_narrowed"`.
402
+ if (outcome.reason === "scope_narrowed") {
403
+ return stop("scope_narrowed", "the dashboard ended this listener", 1, { boards: outcome.boards });
404
+ }
299
405
  return stop(outcome.reason, "the dashboard ended this listener", 1);
300
406
  }
301
407
  if (outcome.kind === "refused") {
302
- return stop("refused", `the dashboard refused the listener ticket: ${outcome.detail}`, 1);
408
+ return stop("refused", `the dashboard refused the listener ticket: ${outcome.detail}`, 1, {
409
+ refusal: { status: outcome.status, errorCode: outcome.errorCode, body: outcome.body },
410
+ });
303
411
  }
304
412
  if (outcome.healthy) {
305
413
  unhealthySince = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thehammer/danx-dashboard-mcp",
3
- "version": "0.1.81",
3
+ "version": "0.1.83",
4
4
  "description": "Stdio MCP server wrapping danxbot's dashboard /api/issues/* normalized DB-backed HTTP routes for dispatched agents (DX-704 Phase 2).",
5
5
  "license": "MIT",
6
6
  "type": "module",