@thehammer/danx-dashboard-mcp 0.1.74 → 0.1.76

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
@@ -9,7 +9,7 @@ Each tool is a thin envelope over one HTTP route — Zod-validated at the MCP bo
9
9
  | Var | Purpose |
10
10
  |---|---|
11
11
  | `DANXBOT_DASHBOARD_URL` | Dashboard base, e.g. `http://danxbot-dashboard:5555` (inside compose) or `http://localhost:5555` (host) |
12
- | `DANXBOT_DISPATCH_TOKEN` | Per-dispatch bearer — server validates via `requireUser`/dispatch-token checks |
12
+ | `DANXBOT_DASHBOARD_TOKEN_FILE` **or** `DANXBOT_DISPATCH_TOKEN` | The credential, declared exactly one of two ways (DX-2862, `src/credential.ts`). A FILE holding the bearer (a leading `~` is the user's home) — how an interactive operator session declares one, since its `.mcp.json` is git-tracked and can never carry the token itself. Or the bearer VERBATIM — how a dispatch declares one, substituted from its overlay. A declared file is AUTHORITATIVE and its failure is FATAL: an inherited `DANXBOT_DISPATCH_TOKEN` is never consulted instead, because on an operator machine that value routinely names a different (local-dev) dashboard, which answers healthily and empty. The same resolver serves the `bridge` subcommand below, so a session's tools and its event stream can never sign in as two different identities |
13
13
  | `DANX_REPO_NAME` | Repo half of the qualified board id |
14
14
  | `DANXBOT_BOARD_NAME` | Board-slug half of the qualified board id (the dispatch injects `board.slug` here) |
15
15
 
@@ -56,11 +56,12 @@ Evaluated in that order (`complete` > `awaiting-session` > `building` > `plannin
56
56
  A program runs this, not an agent: the danxbot Claude Code plugin's plan event bridge spawns `bridge` for a session, relays each event's `text` into the session, and stores the delivered ids. `plan_connect` only connects the session; it mints no ticket.
57
57
 
58
58
  ```bash
59
- DANXBOT_DASHBOARD_URL=<dashboard> DANXBOT_DISPATCH_TOKEN=<token> CLAUDE_CODE_SESSION_ID=<session> \
60
- npx -y @thehammer/danx-dashboard-mcp@<version> bridge [--resume-ids <id>,<id>,...]
59
+ CLAUDE_CODE_SESSION_ID=<session> npx -y @thehammer/danx-dashboard-mcp@<version> bridge [--resume-ids <id>,<id>,...]
61
60
  ```
62
61
 
63
- - **Credential.** The credential and session come from the environment; `--resume-ids` is the only argument and holds no secret. `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.
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":[…]}`.
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.
64
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.
65
66
  - **Stopping.** Last, one `{"type":"stopped","reason":"…","detail":"…"}` and exit, only on a terminal outcome: `not_connected`, `unauthorized` (401/403), `mint_refused` (any other non-transient refusal), `mint_bad_response`, `superseded` / `replaced` (exit 0), `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
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.
package/dist/bridge.js CHANGED
@@ -5,106 +5,193 @@
5
5
  * WHO RUNS IT. The danxbot Claude Code plugin's plan event bridge
6
6
  * (`danxbot/scripts/plan-event-bridge.mjs` in claude-plugins) spawns it once per
7
7
  * session and owns only process lifecycle, the delivered-id cursor, and posting
8
- * each event into the session's inbox. Everything about the dashboard's HTTP
9
- * contract lives HERE, next to the MCP server that already speaks it, and is
10
- * tested here.
8
+ * each event (and each failure) into the session's inbox. Everything about the
9
+ * dashboard's HTTP contract lives HERE, next to the MCP server that already
10
+ * speaks it, and is tested here.
11
11
  *
12
12
  * WHAT IT DOES.
13
- * 1. Reads `DANXBOT_DASHBOARD_URL`, `DANXBOT_DISPATCH_TOKEN` and
14
- * `CLAUDE_CODE_SESSION_ID` from its environment.
15
- * 2. Mints the session's listener ticket (`POST /api/plan-sessions/me/stream-ticket`)
16
- * with a timeout. The ticket stays in this process.
17
- * 3. Streams through `runListener` (`listen.ts`), which reconnects within the
18
- * ticket, and re-mints when that ticket's lease runs out or a fresh ticket is
19
- * refused once.
20
- * 4. Writes JSON Lines to stdout: `{type:"event", id, text}` per event, and ONE
21
- * final `{type:"stopped", reason, detail}` before exiting.
13
+ * 1. Reads `CLAUDE_CODE_SESSION_ID` from its environment and, for that
14
+ * session, the connection record the session's own MCP server wrote when
15
+ * it connected the plan (`session-connection.ts`): the dashboard URL, the
16
+ * credential's SOURCE, and the credential's FINGERPRINT.
17
+ * 2. Resolves that same source through the same resolver the server used
18
+ * (`credential.ts`) and refuses to run unless the result fingerprints
19
+ * identically — DX-2862, where a bridge authenticated with a DIFFERENT
20
+ * credential from its session's tools streamed happily and relayed nothing.
21
+ * 3. Mints the session's listener ticket (`POST /api/plan-sessions/me/stream-ticket`).
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.
29
+ * 5. Streams through `runListener` (`listen.ts`), which reconnects within the
30
+ * ticket, and re-mints when that ticket's lease runs out or a fresh ticket
31
+ * 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.
22
35
  *
23
36
  * TERMINAL OUTCOMES — each ends the process with its stop record, never a retry:
24
- * - `not_connected` the mint answered 409 `session_not_connected`;
25
- * - `unauthorized` the mint answered 401 / 403;
26
- * - `mint_refused` any other non-transient mint refusal (400, 404, …);
27
- * - `mint_bad_response` a 2xx mint whose body is not a ticket;
37
+ * - `no_connection_record` this session has no connection record to read;
38
+ * - `credential_unavailable` its declared credential cannot be resolved here;
39
+ * - `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;
42
+ * - `mint_refused` any other non-transient mint refusal (400, 404, …);
43
+ * - `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;
28
46
  * - `superseded` / `replaced` (exit 0) — another listener now holds the session;
29
- * - `revoked` the dashboard revoked the ticket or its issuer;
30
- * - `refused` freshly minted tickets were refused twice in a row.
31
- * Transient mint failures (network, timeout, 408, 429, 5xx) back off and retry: the
47
+ * - `revoked` the dashboard revoked the ticket or its issuer;
48
+ * - `refused` freshly minted tickets were refused twice in a row.
49
+ * Transient failures (network, timeout, 408, 429, 5xx) back off and retry: the
32
50
  * process lives exactly as long as its session, and the plugin ends it.
33
51
  *
34
- * THE ENVIRONMENT, NOT ARGV, CARRIES THE CREDENTIAL. A command line is readable by
35
- * every process on the machine; `--resume-ids` is the only argument and holds no
36
- * secret.
52
+ * NEITHER THE ENVIRONMENT NOR ARGV CARRIES THE CREDENTIAL ANY MORE. `--resume-ids`
53
+ * is the only argument and holds no secret; the credential is whatever this
54
+ * session's own MCP server declared, resolved here from that declaration.
37
55
  */
56
+ import { homedir } from "node:os";
38
57
  import { oneLine } from "./one-line.js";
58
+ import { credentialFingerprint, describeCredentialSource, readCredential, } from "./credential.js";
59
+ import { readSessionConnection } from "./session-connection.js";
39
60
  import { DELIVERED_ID_MEMORY, HEALTHY_CONNECTION_MS, READ_IDLE_TIMEOUT_MS, runListener, } from "./listen.js";
40
61
  export const BRIDGE_SUBCOMMAND = "bridge";
41
62
  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";
42
65
  export const SESSION_ID_HEADER = "x-danx-session-id";
43
- /** How long one ticket mint may take before it counts as a transient failure. */
44
- export const MINT_TIMEOUT_MS = 10_000;
66
+ /** How long one dashboard request may take before it counts as a transient failure. */
67
+ export const REQUEST_TIMEOUT_MS = 10_000;
45
68
  export const MINT_INITIAL_BACKOFF_MS = 1_000;
46
69
  export const MINT_MAX_BACKOFF_MS = 60_000;
47
70
  /** A freshly minted ticket refused this many times in a row is a terminal outcome, not a loop. */
48
71
  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;
49
74
  const TRANSIENT_CLIENT_STATUSES = new Set([408, 429]);
50
75
  const TAKEOVER_REASONS = new Set(["superseded", "replaced"]);
51
- const USAGE = `usage: DANXBOT_DASHBOARD_URL=<url> DANXBOT_DISPATCH_TOKEN=<token> CLAUDE_CODE_SESSION_ID=<session-id> ` +
52
- `danx-dashboard-mcp ${BRIDGE_SUBCOMMAND} [--resume-ids <event-id>[,<event-id>...]]`;
76
+ const USAGE = `usage: CLAUDE_CODE_SESSION_ID=<session-id> danx-dashboard-mcp ${BRIDGE_SUBCOMMAND} ` +
77
+ `[--resume-ids <event-id>[,<event-id>...]]`;
78
+ /**
79
+ * The remedy per terminal reason. ONE map, so the message a session is shown and
80
+ * the message a log carries can never drift, and a new reason without a remedy
81
+ * is a compile-visible omission rather than a silent blank.
82
+ */
83
+ export const STOP_FIXES = {
84
+ no_connection_record: "call plan_connect again in this session — its danx-dashboard MCP server records the connection the bridge needs " +
85
+ "(a server older than the one that introduced the record writes none)",
86
+ credential_unavailable: "fix the credential this session's danx-dashboard MCP server declares, then call plan_connect again",
87
+ credential_mismatch: "restart this Claude Code session so its MCP server and its bridge pick up the same credential, then call plan_connect again",
88
+ not_connected: "call plan_connect to connect this session to a plan",
89
+ unauthorized: "give this session's danx-dashboard MCP server a dashboard credential that is accepted, then call plan_connect again",
90
+ mint_refused: "check that this session is connected to a plan on the dashboard its MCP server points at, then call plan_connect again",
91
+ mint_bad_response: "check that DANXBOT_DASHBOARD_URL for this session's MCP server points at a danxbot dashboard",
92
+ 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",
93
+ scope_check_failed: "check the dashboard is reachable and this session is connected to a plan, then call plan_connect again",
94
+ revoked: "call plan_connect again to mint a new listener ticket for this session",
95
+ refused: "call plan_connect again to mint a new listener ticket for this session",
96
+ };
97
+ export const DEFAULT_STOP_FIX = "call plan_connect again in this session to restart the bridge";
98
+ export function fixForStop(reason) {
99
+ return STOP_FIXES[reason] ?? DEFAULT_STOP_FIX;
100
+ }
53
101
  function positiveInteger(raw) {
54
102
  const n = Number(raw);
55
103
  return Number.isSafeInteger(n) && n > 0 && String(n) === raw ? n : null;
56
104
  }
57
- /** Parse the one optional flag and the three required environment values; anything else is refused. */
105
+ /** Parse the one optional flag and the one required environment value; anything else is refused. */
58
106
  export function parseBridgeArgs(argv, env) {
59
107
  const withResume = argv.length === 2 && argv[0] === "--resume-ids" && argv[1] !== "";
60
108
  if (argv.length !== 0 && !withResume)
61
109
  throw new Error(USAGE);
62
- const dashboardUrl = env.DANXBOT_DASHBOARD_URL;
63
- const token = env.DANXBOT_DISPATCH_TOKEN;
64
110
  const sessionId = env.CLAUDE_CODE_SESSION_ID;
65
- if (!dashboardUrl || !token || !sessionId)
111
+ if (!sessionId)
66
112
  throw new Error(USAGE);
67
113
  const resumeIds = withResume ? argv[1].split(",").map(positiveInteger) : [];
68
114
  if (resumeIds.some((id) => id === null))
69
115
  throw new Error(USAGE);
70
- return { dashboardUrl: dashboardUrl.replace(/\/+$/, ""), token, sessionId, resumeIds: resumeIds };
116
+ return { sessionId, resumeIds: resumeIds };
117
+ }
118
+ /** A start this process cannot make, named the way the session will be told about it. */
119
+ export class BridgeStartError extends Error {
120
+ reason;
121
+ fix;
122
+ constructor(reason, detail, fix) {
123
+ super(detail);
124
+ this.name = "BridgeStartError";
125
+ this.reason = reason;
126
+ this.fix = fix;
127
+ }
128
+ }
129
+ /**
130
+ * The session's connection, as its own MCP server recorded it — and the proof
131
+ * that this process holds the SAME credential that server does. Never falls back
132
+ * to an ambient `DANXBOT_DISPATCH_TOKEN`: that fallback is precisely DX-2862.
133
+ */
134
+ export function resolveBridgeOptions(args, env, options = {}) {
135
+ const home = options.home ?? homedir();
136
+ let record;
137
+ try {
138
+ record = readSessionConnection(args.sessionId, { home });
139
+ }
140
+ catch (err) {
141
+ throw new BridgeStartError("no_connection_record", err.message, fixForStop("no_connection_record"));
142
+ }
143
+ if (record === null) {
144
+ throw new BridgeStartError("no_connection_record", `session ${args.sessionId} has no dashboard connection record — no danx-dashboard MCP server has connected it to a plan`, fixForStop("no_connection_record"));
145
+ }
146
+ let token;
147
+ try {
148
+ token = readCredential(record.source, env);
149
+ }
150
+ catch (err) {
151
+ const credentialError = err;
152
+ throw new BridgeStartError("credential_unavailable", `the credential this session's danx-dashboard MCP server declared cannot be resolved here: ${credentialError.message}`, credentialError.fix ?? fixForStop("credential_unavailable"));
153
+ }
154
+ const fingerprint = credentialFingerprint(token);
155
+ if (fingerprint !== record.fingerprint) {
156
+ throw new BridgeStartError("credential_mismatch", `${describeCredentialSource(record.source)} now yields credential ${fingerprint}, but this session's ` +
157
+ `danx-dashboard MCP server is using ${record.fingerprint} — the bridge would authenticate as somebody else`, fixForStop("credential_mismatch"));
158
+ }
159
+ return {
160
+ dashboardUrl: record.dashboardUrl.replace(/\/+$/, ""),
161
+ token,
162
+ sessionId: args.sessionId,
163
+ resumeIds: args.resumeIds,
164
+ };
71
165
  }
72
166
  function errorCodeOf(body) {
73
167
  return typeof body === "object" && body !== null && typeof body.error === "string"
74
168
  ? body.error
75
169
  : null;
76
170
  }
77
- /** What is wrong with a 2xx mint body, or `null` when it is a ticket. Never echoes the body. */
78
- function badTicketReason(body) {
79
- if (typeof body !== "object" || body === null)
80
- return "the body is not a JSON object";
81
- const b = body;
82
- if (typeof b.ticket !== "string" || b.ticket === "")
83
- return "ticket is not a non-empty string";
84
- if (typeof b.streamPath !== "string" || !b.streamPath.startsWith("/"))
85
- return "streamPath is not a path";
86
- if (typeof b.leaseMs !== "number" || !Number.isSafeInteger(b.leaseMs) || b.leaseMs <= 0) {
87
- return "leaseMs is not a positive integer";
88
- }
89
- return null;
90
- }
91
- /** Mint this session's listener ticket, classifying every way that can end. */
92
- export async function mintTicket(options, deps) {
171
+ /**
172
+ * One authenticated request, with a timeout, classifying the failures that are
173
+ * about THIS MOMENT (network, timeout, 408/429, 5xx) as transient. Shared by the
174
+ * mint and the board-reach check so the two can never disagree about which
175
+ * failures are worth retrying.
176
+ */
177
+ async function request(options, deps, what, url, init = { method: "GET" }) {
93
178
  const controller = new AbortController();
94
- const timer = setTimeout(() => controller.abort(), deps.mintTimeoutMs);
179
+ const timer = setTimeout(() => controller.abort(), deps.requestTimeoutMs);
95
180
  try {
96
181
  let status;
97
182
  let text;
98
183
  try {
99
- const response = await deps.fetch(`${options.dashboardUrl}${STREAM_TICKET_PATH}`, {
100
- method: "POST",
101
- headers: {
102
- Authorization: `Bearer ${options.token}`,
103
- Accept: "application/json",
104
- "Content-Type": "application/json",
105
- [SESSION_ID_HEADER]: options.sessionId,
106
- },
107
- body: "{}",
184
+ const headers = {
185
+ Authorization: `Bearer ${options.token}`,
186
+ Accept: "application/json",
187
+ [SESSION_ID_HEADER]: options.sessionId,
188
+ };
189
+ if (init.body !== undefined)
190
+ headers["Content-Type"] = "application/json";
191
+ const response = await deps.fetch(url, {
192
+ method: init.method,
193
+ headers,
194
+ body: init.body,
108
195
  signal: controller.signal,
109
196
  });
110
197
  status = response.status;
@@ -114,12 +201,12 @@ export async function mintTicket(options, deps) {
114
201
  return {
115
202
  kind: "transient",
116
203
  detail: controller.signal.aborted
117
- ? `ticket mint timed out after ${deps.mintTimeoutMs} ms`
118
- : `ticket mint failed: ${err.message}`,
204
+ ? `${what} timed out after ${deps.requestTimeoutMs} ms`
205
+ : `${what} failed: ${err.message}`,
119
206
  };
120
207
  }
121
208
  if (status >= 500 || TRANSIENT_CLIENT_STATUSES.has(status)) {
122
- return { kind: "transient", detail: `ticket mint HTTP ${status}` };
209
+ return { kind: "transient", detail: `${what} HTTP ${status}` };
123
210
  }
124
211
  let body = null;
125
212
  try {
@@ -128,43 +215,168 @@ export async function mintTicket(options, deps) {
128
215
  catch {
129
216
  body = null;
130
217
  }
218
+ return { kind: "answer", status, text, body };
219
+ }
220
+ finally {
221
+ clearTimeout(timer);
222
+ }
223
+ }
224
+ /** What is wrong with a 2xx mint body, or `null` when it is a ticket. Never echoes the body. */
225
+ function badTicketReason(body) {
226
+ if (typeof body !== "object" || body === null)
227
+ return "the body is not a JSON object";
228
+ const b = body;
229
+ if (typeof b.ticket !== "string" || b.ticket === "")
230
+ return "ticket is not a non-empty string";
231
+ if (typeof b.streamPath !== "string" || !b.streamPath.startsWith("/"))
232
+ return "streamPath is not a path";
233
+ if (typeof b.leaseMs !== "number" || !Number.isSafeInteger(b.leaseMs) || b.leaseMs <= 0) {
234
+ return "leaseMs is not a positive integer";
235
+ }
236
+ return null;
237
+ }
238
+ /** Mint this session's listener ticket, classifying every way that can end. */
239
+ export async function mintTicket(options, deps) {
240
+ const outcome = await request(options, deps, "ticket mint", `${options.dashboardUrl}${STREAM_TICKET_PATH}`, {
241
+ method: "POST",
242
+ body: "{}",
243
+ });
244
+ if (outcome.kind === "transient")
245
+ return outcome;
246
+ const { status, text, body } = outcome;
247
+ if (status === 409 && errorCodeOf(body) === "session_not_connected") {
248
+ return { kind: "terminal", reason: "not_connected", detail: "the session is not connected to a plan" };
249
+ }
250
+ if (status === 401 || status === 403) {
251
+ return { kind: "terminal", reason: "unauthorized", detail: `ticket mint HTTP ${status} ${oneLine(text, 300)}` };
252
+ }
253
+ if (status < 200 || status >= 300) {
254
+ return { kind: "terminal", reason: "mint_refused", detail: `ticket mint HTTP ${status} ${oneLine(text, 300)}` };
255
+ }
256
+ const bad = badTicketReason(body);
257
+ if (bad !== null) {
258
+ return { kind: "terminal", reason: "mint_bad_response", detail: `ticket mint HTTP ${status}: ${bad}` };
259
+ }
260
+ const ticket = body;
261
+ return {
262
+ kind: "ticket",
263
+ ticket: ticket.ticket,
264
+ streamUrl: `${options.dashboardUrl}${ticket.streamPath}`,
265
+ leaseMs: ticket.leaseMs,
266
+ };
267
+ }
268
+ /** One card per board of the connected plan, read a page at a time. */
269
+ function cardsPageOf(body) {
270
+ if (typeof body !== "object" || body === null)
271
+ return "the body is not a JSON object";
272
+ const b = body;
273
+ if (!Array.isArray(b.cards))
274
+ return "cards is not an array";
275
+ if (typeof b.cards_total !== "number" || !Number.isSafeInteger(b.cards_total))
276
+ return "cards_total is not an integer";
277
+ const cards = [];
278
+ for (const card of b.cards) {
279
+ if (typeof card !== "object" || card === null)
280
+ return "a card is not an object";
281
+ const c = card;
282
+ if (typeof c.id !== "string" || typeof c.boardId !== "string")
283
+ return "a card has no id / boardId";
284
+ cards.push({ id: c.id, boardId: c.boardId });
285
+ }
286
+ return { cards, total: b.cards_total };
287
+ }
288
+ /**
289
+ * Prove this credential can read every board the connected plan's cards live on.
290
+ *
291
+ * WHY THIS EXISTS AT ALL. The stream admits an event only when the ticket
292
+ * issuer's board allowlist covers the event's board and the issuer still holds
293
+ * `read` (`isVisibleToStream` / `resolveIssuerScope`, dashboard). A credential
294
+ * short of that streams exactly as happily as a correct one and simply never
295
+ * relays those boards' events — silence that looks identical to "nothing has
296
+ * happened yet".
297
+ *
298
+ * WHY THESE TWO ROUTES. `GET /api/plans/mine?fields=cards` is the only read that
299
+ * names the connected plan's cards, and `GET /api/issues/:id/problems` is a card
300
+ * read that runs the SAME board-allowlist + `read` check the stream does
301
+ * (`withV2Handler` + `resolveIssueBoardId`). Deliberately NOT `GET /api/issues`
302
+ * or `GET /api/issues/:id`: neither checks the caller's board allowlist at all,
303
+ * so a check built on them would pass precisely where the stream drops events.
304
+ */
305
+ export async function verifyPlanBoardsReadable(options, deps) {
306
+ const firstCardPerBoard = new Map();
307
+ let offset = 0;
308
+ for (;;) {
309
+ const url = `${options.dashboardUrl}${PLAN_MINE_PATH}?fields=cards&cards_limit=${PLAN_CARDS_PAGE_SIZE}&cards_offset=${offset}`;
310
+ const outcome = await request(options, deps, "plan read", url);
311
+ if (outcome.kind === "transient")
312
+ return outcome;
313
+ const { status, text, body } = outcome;
131
314
  if (status === 409 && errorCodeOf(body) === "session_not_connected") {
132
315
  return { kind: "terminal", reason: "not_connected", detail: "the session is not connected to a plan" };
133
316
  }
134
317
  if (status === 401 || status === 403) {
135
- return { kind: "terminal", reason: "unauthorized", detail: `ticket mint HTTP ${status} ${oneLine(text, 300)}` };
318
+ return { kind: "terminal", reason: "unauthorized", detail: `plan read HTTP ${status} ${oneLine(text, 300)}` };
136
319
  }
137
320
  if (status < 200 || status >= 300) {
138
- return { kind: "terminal", reason: "mint_refused", detail: `ticket mint HTTP ${status} ${oneLine(text, 300)}` };
321
+ return { kind: "terminal", reason: "scope_check_failed", detail: `plan read HTTP ${status} ${oneLine(text, 300)}` };
139
322
  }
140
- const bad = badTicketReason(body);
141
- if (bad !== null) {
142
- return { kind: "terminal", reason: "mint_bad_response", detail: `ticket mint HTTP ${status}: ${bad}` };
323
+ const page = cardsPageOf(body);
324
+ if (typeof page === "string") {
325
+ return { kind: "terminal", reason: "scope_check_failed", detail: `plan read HTTP ${status}: ${page}` };
143
326
  }
144
- const ticket = body;
145
- return {
146
- kind: "ticket",
147
- ticket: ticket.ticket,
148
- streamUrl: `${options.dashboardUrl}${ticket.streamPath}`,
149
- leaseMs: ticket.leaseMs,
150
- };
327
+ for (const card of page.cards) {
328
+ if (!firstCardPerBoard.has(card.boardId))
329
+ firstCardPerBoard.set(card.boardId, card.id);
330
+ }
331
+ offset += page.cards.length;
332
+ if (page.cards.length === 0 || offset >= page.total)
333
+ break;
151
334
  }
152
- finally {
153
- clearTimeout(timer);
335
+ for (const [boardId, cardId] of firstCardPerBoard) {
336
+ const url = `${options.dashboardUrl}${ISSUES_PATH}/${encodeURIComponent(cardId)}/problems?board=${encodeURIComponent(boardId)}`;
337
+ const outcome = await request(options, deps, `board read of ${boardId}`, url);
338
+ if (outcome.kind === "transient")
339
+ return outcome;
340
+ const { status, text } = outcome;
341
+ if (status === 403) {
342
+ return {
343
+ kind: "terminal",
344
+ reason: "board_unreadable",
345
+ detail: `this session's dashboard credential cannot read board ${boardId}, which the connected plan's card ` +
346
+ `${cardId} lives on — the event stream drops that board's events silently (${oneLine(text, 200)})`,
347
+ };
348
+ }
349
+ if (status === 401) {
350
+ return { kind: "terminal", reason: "unauthorized", detail: `board read of ${boardId} HTTP 401 ${oneLine(text, 200)}` };
351
+ }
352
+ if (status === 404) {
353
+ // The card moved or was deleted between the two reads. Nothing is wrong
354
+ // with the credential — re-read the plan rather than accuse it.
355
+ return { kind: "transient", detail: `card ${cardId} vanished between the plan read and the board read` };
356
+ }
357
+ if (status < 200 || status >= 300) {
358
+ return {
359
+ kind: "terminal",
360
+ reason: "scope_check_failed",
361
+ detail: `board read of ${boardId} HTTP ${status} ${oneLine(text, 200)}`,
362
+ };
363
+ }
154
364
  }
365
+ return { kind: "ok", boards: [...firstCardPerBoard.keys()] };
155
366
  }
156
367
  /**
157
- * Mint, stream, re-mint — until a terminal outcome, which it writes as the one
158
- * `stopped` record. Returns 0 when another listener took over, 1 otherwise.
368
+ * Mint, verify, stream, re-mint — until a terminal outcome, which it writes as
369
+ * the one `stopped` record. Returns 0 when another listener took over, 1 otherwise.
159
370
  */
160
371
  export async function runBridge(options, deps) {
161
372
  const delivered = options.resumeIds.slice(-DELIVERED_ID_MEMORY);
162
373
  const stop = (reason, detail, code) => {
163
- deps.write({ type: "stopped", reason, detail });
374
+ deps.write({ type: "stopped", reason, detail, fix: fixForStop(reason) });
164
375
  return code;
165
376
  };
166
377
  let backoff = MINT_INITIAL_BACKOFF_MS;
167
378
  let refusedInRow = 0;
379
+ let verified = false;
168
380
  for (;;) {
169
381
  const minted = await mintTicket(options, deps);
170
382
  if (minted.kind === "terminal")
@@ -174,6 +386,20 @@ export async function runBridge(options, deps) {
174
386
  backoff = Math.min(MINT_MAX_BACKOFF_MS, backoff * 2);
175
387
  continue;
176
388
  }
389
+ // The reach check runs on the FIRST ticket only: it is a property of the
390
+ // credential and the plan, and a re-mint (a lapsed lease) changes neither.
391
+ if (!verified) {
392
+ const scope = await verifyPlanBoardsReadable(options, deps);
393
+ if (scope.kind === "terminal")
394
+ return stop(scope.reason, scope.detail, 1);
395
+ if (scope.kind === "transient") {
396
+ await deps.sleep(backoff);
397
+ backoff = Math.min(MINT_MAX_BACKOFF_MS, backoff * 2);
398
+ continue;
399
+ }
400
+ verified = true;
401
+ deps.write({ type: "ready", boards: scope.boards });
402
+ }
177
403
  backoff = MINT_INITIAL_BACKOFF_MS;
178
404
  const run = { stopped: null, emitted: false };
179
405
  const startedAt = deps.now();
@@ -214,23 +440,40 @@ export async function runBridge(options, deps) {
214
440
  }
215
441
  }
216
442
  /** The bin's `bridge` subcommand, wired to the real process. */
217
- export async function runBridgeCommand(argv, env = process.env) {
218
- let options;
443
+ export async function runBridgeCommand(argv, env = process.env,
444
+ /** Test seam: the home the session's connection record is read from. */
445
+ resolveFrom = {}) {
446
+ const write = (output) => {
447
+ process.stdout.write(`${JSON.stringify(output)}\n`);
448
+ };
449
+ let args;
219
450
  try {
220
- options = parseBridgeArgs(argv, env);
451
+ args = parseBridgeArgs(argv, env);
221
452
  }
222
453
  catch (err) {
223
454
  process.stderr.write(`${err.message}\n`);
224
455
  return 2;
225
456
  }
457
+ let options;
458
+ try {
459
+ options = resolveBridgeOptions(args, env, resolveFrom);
460
+ }
461
+ catch (err) {
462
+ const start = err;
463
+ // A start that never happened still reports itself the way every other
464
+ // terminal outcome does — one stop record the plugin relays into the
465
+ // session — AND on stderr, for the case where there is no session to relay
466
+ // into (a hand run).
467
+ write({ type: "stopped", reason: start.reason, detail: start.message, fix: start.fix });
468
+ process.stderr.write(`${start.reason}: ${start.message}. Fix: ${start.fix}.\n`);
469
+ return 1;
470
+ }
226
471
  return runBridge(options, {
227
472
  fetch: (input, init) => fetch(input, init),
228
- write: (output) => {
229
- process.stdout.write(`${JSON.stringify(output)}\n`);
230
- },
473
+ write,
231
474
  sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
232
475
  now: Date.now,
233
476
  readIdleTimeoutMs: READ_IDLE_TIMEOUT_MS,
234
- mintTimeoutMs: MINT_TIMEOUT_MS,
477
+ requestTimeoutMs: REQUEST_TIMEOUT_MS,
235
478
  });
236
479
  }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * DX-2862 — the ONE resolver for this package's dashboard credential.
3
+ *
4
+ * WHY IT IS ONE FUNCTION AND NOT TWO. Two processes speak to the dashboard for
5
+ * the same Claude Code session: the MCP SERVER (the session's card + plan tools)
6
+ * and `danx-dashboard-mcp bridge` (the session's event stream, run by the
7
+ * danxbot plugin's plan event bridge). Before this module they resolved their
8
+ * credential independently — the server through danxbot's operator launcher,
9
+ * which read a token file, and the bridge from whatever `DANXBOT_DISPATCH_TOKEN`
10
+ * happened to be in the session's environment. On a machine where those two
11
+ * differ (DX-2862: `ffe9f8a882f0…` in the environment, `6f9bc6e94b83…` in
12
+ * `~/.config/danxbot/dashboard-token-gpt`) the session's tools worked, the
13
+ * bridge's stream was admitted, and every operator comment was dropped with no
14
+ * error anywhere. So resolution lives HERE, both callers go through it, and the
15
+ * bridge additionally PROVES it holds the server's credential by comparing
16
+ * fingerprints (`session-connection.ts`).
17
+ *
18
+ * THE DECLARATION. A server's env declares where its credential comes from:
19
+ *
20
+ * - `DANXBOT_DASHBOARD_TOKEN_FILE` — a path (a leading `~` is this user's
21
+ * home). The file's trimmed contents are the credential. This is how an
22
+ * interactive operator session declares one, because its `.mcp.json` is
23
+ * git-tracked and can never carry the bearer itself.
24
+ * - `DANXBOT_DISPATCH_TOKEN` — the credential verbatim. This is how a
25
+ * DISPATCH declares one: the worker substitutes it into the materialized
26
+ * `.mcp.json` from the dispatch overlay.
27
+ *
28
+ * A DECLARED FILE IS AUTHORITATIVE, AND ITS FAILURE IS FATAL. When the file
29
+ * variable is set, `DANXBOT_DISPATCH_TOKEN` is never consulted — not even when
30
+ * the file is missing or empty. That is the DX-2483 rule, moved here from the
31
+ * launcher it used to live in: an operator machine routinely carries an
32
+ * inherited `DANXBOT_DISPATCH_TOKEN` naming the LOCAL dev dashboard, which
33
+ * answers healthily and empty for a board it does not carry, so preferring it
34
+ * after a failed file read would turn a loud, fixable error into exactly the
35
+ * silent wrong-dashboard answer this whole module exists to prevent.
36
+ */
37
+ import { createHash } from "node:crypto";
38
+ import { readFileSync } from "node:fs";
39
+ import { homedir } from "node:os";
40
+ import { isAbsolute, join, resolve } from "node:path";
41
+ export const CREDENTIAL_FILE_ENV = "DANXBOT_DASHBOARD_TOKEN_FILE";
42
+ export const CREDENTIAL_TOKEN_ENV = "DANXBOT_DISPATCH_TOKEN";
43
+ /**
44
+ * A credential that cannot be resolved. Carries the remedy beside the reason,
45
+ * because both of this module's callers report failures to a human who is not
46
+ * reading a log: the server exits with it on stderr, and the bridge posts it
47
+ * into the session's inbox.
48
+ */
49
+ export class CredentialError extends Error {
50
+ fix;
51
+ constructor(detail, fix) {
52
+ super(detail);
53
+ this.name = "CredentialError";
54
+ this.fix = fix;
55
+ }
56
+ }
57
+ const DECLARE_ONE = `declare the credential in this MCP server's env: ${CREDENTIAL_FILE_ENV}=<path to a file holding the ` +
58
+ `dashboard bearer> (an operator session), or ${CREDENTIAL_TOKEN_ENV}=<the bearer> (a dispatch)`;
59
+ /** `~` / `~/x` / `~\x` mean this user's home; every other path is returned as given. */
60
+ export function expandHome(path, home = homedir()) {
61
+ if (path === "~")
62
+ return home;
63
+ if (path.startsWith("~/") || path.startsWith("~\\"))
64
+ return join(home, path.slice(2));
65
+ return path;
66
+ }
67
+ function readEnv(env, name) {
68
+ const value = env[name];
69
+ return typeof value === "string" && value.trim() !== "" ? value.trim() : null;
70
+ }
71
+ /**
72
+ * WHICH source this environment declares. Throws `CredentialError` when it
73
+ * declares none — never a guess, and never an anonymous client.
74
+ */
75
+ export function declaredCredentialSource(env, home = homedir()) {
76
+ const file = readEnv(env, CREDENTIAL_FILE_ENV);
77
+ if (file !== null) {
78
+ const expanded = expandHome(file, home);
79
+ return { kind: "file", path: isAbsolute(expanded) ? expanded : resolve(expanded) };
80
+ }
81
+ if (readEnv(env, CREDENTIAL_TOKEN_ENV) !== null)
82
+ return { kind: "env", name: CREDENTIAL_TOKEN_ENV };
83
+ throw new CredentialError(`no dashboard credential is declared (neither ${CREDENTIAL_FILE_ENV} nor ${CREDENTIAL_TOKEN_ENV} is set)`, DECLARE_ONE);
84
+ }
85
+ /** One line naming a source, for a message a human reads. Holds no secret. */
86
+ export function describeCredentialSource(source) {
87
+ return source.kind === "file" ? `the token file ${source.path}` : `the environment variable ${source.name}`;
88
+ }
89
+ /** The credential a source yields. Every failure is fatal and named. */
90
+ export function readCredential(source, env) {
91
+ if (source.kind === "env") {
92
+ const token = readEnv(env, source.name);
93
+ if (token === null) {
94
+ throw new CredentialError(`${describeCredentialSource(source)} is unset or empty in this process`, `set ${source.name} for this process, or declare ${CREDENTIAL_FILE_ENV} instead`);
95
+ }
96
+ return token;
97
+ }
98
+ let contents;
99
+ try {
100
+ contents = readFileSync(source.path, "utf-8");
101
+ }
102
+ catch (err) {
103
+ throw new CredentialError(`cannot read ${describeCredentialSource(source)}: ${err.message}`, `create that file holding this dashboard's bearer token, or point ${CREDENTIAL_FILE_ENV} at one that exists`);
104
+ }
105
+ const token = contents.trim();
106
+ if (token === "") {
107
+ throw new CredentialError(`${describeCredentialSource(source)} is empty`, `write this dashboard's bearer token into that file`);
108
+ }
109
+ return token;
110
+ }
111
+ /**
112
+ * A credential's IDENTITY, for comparing two processes' credentials without
113
+ * either of them handling the other's. The first 12 hex of SHA-256 — 48 bits,
114
+ * enough that two distinct 48-character tokens colliding is not a thing that
115
+ * happens, and nothing an attacker can invert.
116
+ */
117
+ export function credentialFingerprint(token) {
118
+ return createHash("sha256").update(token).digest("hex").slice(0, 12);
119
+ }
120
+ /** Declare, read, fingerprint — what the MCP server does at boot. */
121
+ export function resolveDeclaredCredential(env, home = homedir()) {
122
+ const source = declaredCredentialSource(env, home);
123
+ const token = readCredential(source, env);
124
+ return { source, token, fingerprint: credentialFingerprint(token) };
125
+ }
File without changes
File without changes
package/dist/handlers.js CHANGED
File without changes
File without changes
package/dist/index.js CHANGED
@@ -69,7 +69,15 @@
69
69
  *
70
70
  * Env at boot (validated fail-loud — missing → process.exit(1)):
71
71
  * DANXBOT_DASHBOARD_URL dashboard base (e.g. http://danxbot-dashboard:5555)
72
- * DANXBOT_DISPATCH_TOKEN per-dispatch bearer
72
+ * the CREDENTIAL, declared exactly one of two ways and resolved by the ONE
73
+ * resolver this package's event bridge also uses (`credential.ts`, DX-2862):
74
+ * DANXBOT_DASHBOARD_TOKEN_FILE a file holding the bearer (an operator
75
+ * session, whose `.mcp.json` is git-tracked and so
76
+ * can never carry one); authoritative when set, and
77
+ * its failure is fatal — an inherited
78
+ * DANXBOT_DISPATCH_TOKEN is never consulted instead
79
+ * DANXBOT_DISPATCH_TOKEN the bearer verbatim (a dispatch, substituted into
80
+ * the materialized `.mcp.json` from its overlay)
73
81
  * DANX_REPO_NAME repo half of the qualified board id
74
82
  * DANXBOT_BOARD_NAME board-slug half of the qualified board id
75
83
  * (the dispatch injects `board.slug` here);
@@ -86,6 +94,8 @@
86
94
  */
87
95
  import { isEntrypointModule } from "./entrypoint.js";
88
96
  import { BRIDGE_SUBCOMMAND, runBridgeCommand } from "./bridge.js";
97
+ import { resolveDeclaredCredential } from "./credential.js";
98
+ import { recordSessionConnectionAfterConnect } from "./session-connection.js";
89
99
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
90
100
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
91
101
  import { z } from "zod";
@@ -119,6 +129,13 @@ function readEnvOptional(name) {
119
129
  // while a real run still fails loud on missing env via `boot()`.
120
130
  let config;
121
131
  let client;
132
+ /**
133
+ * DX-2862 — what this server would tell the session's plan event bridge about
134
+ * how to reach the dashboard AS THIS SERVER: the URL, where the credential came
135
+ * from, and the credential's fingerprint. Never the credential itself. Written
136
+ * to the session's connection record on a successful `plan_connect`.
137
+ */
138
+ let connection;
122
139
  /**
123
140
  * Compose the dispatch's qualified board id (`<repo>:<slug>`) from the two env
124
141
  * halves the worker injects and build the HTTP client. DANXBOT_BOARD_NAME
@@ -127,9 +144,10 @@ let client;
127
144
  * Both halves are fail-loud required (Core Principle 1 — no fallback).
128
145
  */
129
146
  function boot() {
147
+ const credential = resolveCredentialOrDie();
130
148
  config = {
131
149
  baseUrl: readEnvOrDie("DANXBOT_DASHBOARD_URL"),
132
- token: readEnvOrDie("DANXBOT_DISPATCH_TOKEN"),
150
+ token: credential.token,
133
151
  board: `${readEnvOrDie("DANX_REPO_NAME")}:${readEnvOrDie("DANXBOT_BOARD_NAME")}`,
134
152
  // DX-1398 — OPTIONAL cross-process trace context. Present → stamped on every
135
153
  // outbound call so the agent's card writes chain under the launch; absent
@@ -138,8 +156,29 @@ function boot() {
138
156
  // DX-2683 — the working session, when there is one. See below.
139
157
  session: readSessionConfig(),
140
158
  };
159
+ connection = {
160
+ dashboardUrl: config.baseUrl.replace(/\/+$/, ""),
161
+ source: credential.source,
162
+ fingerprint: credential.fingerprint,
163
+ };
141
164
  client = new DashboardHttpClient(config);
142
165
  }
166
+ /**
167
+ * DX-2862 — the credential, through the ONE resolver the `bridge` subcommand
168
+ * also uses, so a session's tools and its event stream can never sign in as two
169
+ * different identities. A credential that cannot be resolved exits non-zero
170
+ * naming the remedy, never a degraded or anonymous server.
171
+ */
172
+ function resolveCredentialOrDie() {
173
+ try {
174
+ return resolveDeclaredCredential(process.env);
175
+ }
176
+ catch (err) {
177
+ const credentialError = err;
178
+ console.error(`[danx-dashboard-mcp] ${credentialError.message}\n Fix: ${credentialError.fix}`);
179
+ process.exit(1);
180
+ }
181
+ }
143
182
  /**
144
183
  * DX-2683 — WHO THIS SESSION IS, read from the environment Claude Code
145
184
  * already provides.
@@ -708,7 +747,23 @@ server.tool("plan_connect",
708
747
  .min(1)
709
748
  .optional()
710
749
  .describe("THIS session's own Claude session title — from `get_session({session_id:\"self\"}).title`, passed verbatim, never invented or derived from the repo/cwd. Stored on your session's row: a title different from what is already stored UPDATES it; omit to leave the stored title untouched. Not this plan's name. At most 200 characters — an overlong title is refused with a 400 naming its length."),
711
- }, async (args) => jsonResult(await planConnect(client, args)));
750
+ },
751
+ // DX-2862 — the connect is also the moment this session's event bridge learns
752
+ // WHICH dashboard credential owns its stream: of the dashboard MCP servers a
753
+ // session may have configured, the one that connected the plan is the one
754
+ // whose credential the stream must be minted with.
755
+ async (args) => {
756
+ const result = await planConnect(client, args);
757
+ return jsonResult({
758
+ ...result,
759
+ ...recordSessionConnectionAfterConnect(result, {
760
+ sessionId: config.session?.id,
761
+ dashboardUrl: connection.dashboardUrl,
762
+ source: connection.source,
763
+ fingerprint: connection.fingerprint,
764
+ }),
765
+ });
766
+ });
712
767
  server.tool("plan_add_record", "Add a goal, rule or caveat to your connected plan (POST /api/plans/mine/records). A GOAL is an outcome the work is measured against. A RULE is a constraint that must hold while it is worked. A CAVEAT is a lasting trade-off or limitation of the ARCHITECTURE — never progress, status or a session note (those are comments on the card). `body` is ONE plain statement of at most 250 characters; the evidence, history and detail go in `context` (markdown). An overlong body is refused with a 400 naming its length. The server allocates a permanent reference (`G-1`, `R-4`, `CAV-12`). Takes no plan id; `session_not_connected` → `plan_connect` first. Returns the record plus that kind's list.", {
713
768
  kind: z.enum(["goal", "rule", "caveat"]).describe("goal = outcome, rule = constraint, caveat = architecture trade-off."),
714
769
  body: z.string().min(1).describe("One plain statement, at most 250 characters. Details go in `context`."),
package/dist/listen.js CHANGED
File without changes
package/dist/one-line.js CHANGED
File without changes
package/dist/priority.js CHANGED
File without changes
@@ -0,0 +1,176 @@
1
+ /**
2
+ * DX-2862 — the record that hands ONE Claude Code session's dashboard
3
+ * connection from its MCP server to its event bridge.
4
+ *
5
+ * THE PROBLEM THIS SOLVES. `danx-dashboard-mcp bridge` runs from a plugin hook,
6
+ * in the SESSION's environment — not in the MCP server's, which is the only
7
+ * place the server's `.mcp.json` env block exists. So the bridge cannot see
8
+ * what the server was configured with, and before this it simply used its own
9
+ * ambient `DANXBOT_DISPATCH_TOKEN`. When that was a different credential from
10
+ * the server's, every event was dropped silently (the card's incident).
11
+ *
12
+ * WHAT IS WRITTEN, AND WHAT IS NOT. On a successful `plan_connect` the server
13
+ * writes the dashboard URL, the credential SOURCE (a file path, or the name of
14
+ * an env var — never a credential) and the credential's FINGERPRINT. The bridge
15
+ * reads that record, resolves the SAME source through the same resolver
16
+ * (`credential.ts`), and refuses to run unless what it resolved fingerprints
17
+ * identically. A rotated file or a session environment that disagrees with the
18
+ * server's therefore fails loud instead of streaming somebody else's events —
19
+ * or, as in DX-2862, none at all.
20
+ *
21
+ * WHY `plan_connect` AND NOTHING ELSE WRITES IT. A session can have several
22
+ * dashboard MCP servers configured (gpt-manager has a deployed one and a local
23
+ * one). The server that CONNECTED the session to a plan is, by construction,
24
+ * the one whose credential owns that session's stream — so the connect is the
25
+ * only moment at which the right server is identifiable. Writing at boot would
26
+ * be a race between servers over the same file.
27
+ *
28
+ * The record is per session, tiny, and pruned after `STALE_RECORD_MS` — the
29
+ * dashboard keeps only a week of events, so an older session cannot resume
30
+ * anything anyway.
31
+ */
32
+ import { mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
33
+ import { homedir } from "node:os";
34
+ import { join } from "node:path";
35
+ /** One canonical shape (CLAUDE.md Core Principle 1) — a record of any other version is refused, never adapted. */
36
+ export const SESSION_CONNECTION_SCHEMA_VERSION = 1;
37
+ export const STALE_RECORD_MS = 7 * 24 * 60 * 60 * 1000;
38
+ export function sessionConnectionDir(home = homedir()) {
39
+ return join(home, ".config", "danxbot", "plan-sessions");
40
+ }
41
+ /** A session id is a path segment here, so an odd one is refused rather than turned into a path. */
42
+ export function sessionConnectionPath(sessionId, home = homedir()) {
43
+ if (typeof sessionId !== "string" || !/^[A-Za-z0-9_-]+$/.test(sessionId)) {
44
+ throw new Error(`session id is missing or has unexpected characters: ${JSON.stringify(sessionId)}`);
45
+ }
46
+ return join(sessionConnectionDir(home), `${sessionId}.json`);
47
+ }
48
+ /** Remove records nothing has touched for `STALE_RECORD_MS`. */
49
+ export function pruneStaleConnections(dir, now) {
50
+ let names;
51
+ try {
52
+ names = readdirSync(dir);
53
+ }
54
+ catch {
55
+ return; // nothing written yet
56
+ }
57
+ for (const name of names) {
58
+ if (!name.endsWith(".json"))
59
+ continue;
60
+ const file = join(dir, name);
61
+ try {
62
+ if (now - statSync(file).mtimeMs > STALE_RECORD_MS)
63
+ rmSync(file, { force: true });
64
+ }
65
+ catch {
66
+ /* removed by another process */
67
+ }
68
+ }
69
+ }
70
+ /**
71
+ * Record this session's connection. Write-then-rename, so a bridge reading
72
+ * concurrently sees the old record or the new one, never a torn one.
73
+ */
74
+ export function writeSessionConnection(record, options = {}) {
75
+ const home = options.home ?? homedir();
76
+ const now = options.now ?? Date.now();
77
+ const file = sessionConnectionPath(record.sessionId, home);
78
+ const dir = sessionConnectionDir(home);
79
+ mkdirSync(dir, { recursive: true });
80
+ pruneStaleConnections(dir, now);
81
+ const full = {
82
+ schemaVersion: SESSION_CONNECTION_SCHEMA_VERSION,
83
+ ...record,
84
+ connectedAt: new Date(now).toISOString(),
85
+ };
86
+ const tmp = `${file}.${process.pid}.${now}.tmp`;
87
+ writeFileSync(tmp, JSON.stringify(full), { mode: 0o600 });
88
+ renameSync(tmp, file);
89
+ return full;
90
+ }
91
+ /**
92
+ * This session's record, or `null` when there is none (the session has never
93
+ * connected a plan from a server new enough to record one). A record that IS
94
+ * there but unreadable or of another shape throws — a connection we cannot
95
+ * understand is not the same thing as no connection, and quietly treating it as
96
+ * one is how a session goes deaf without a word.
97
+ */
98
+ export function readSessionConnection(sessionId, options = {}) {
99
+ const file = sessionConnectionPath(sessionId, options.home ?? homedir());
100
+ let raw;
101
+ try {
102
+ raw = readFileSync(file, "utf-8");
103
+ }
104
+ catch (err) {
105
+ if (err.code === "ENOENT")
106
+ return null;
107
+ throw new Error(`cannot read the session connection record ${file}: ${err.message}`);
108
+ }
109
+ let parsed;
110
+ try {
111
+ parsed = JSON.parse(raw);
112
+ }
113
+ catch (err) {
114
+ throw new Error(`the session connection record ${file} is not JSON: ${err.message}`);
115
+ }
116
+ const invalid = invalidRecordReason(parsed);
117
+ if (invalid !== null)
118
+ throw new Error(`the session connection record ${file} is unusable: ${invalid}`);
119
+ return parsed;
120
+ }
121
+ /**
122
+ * Record the connection a successful `plan_connect` just made, and say so in
123
+ * that tool's own answer when it could not be recorded.
124
+ *
125
+ * FAIL LOUD, IN THE ONE PLACE THE AGENT IS LOOKING. A connect whose record is
126
+ * missing produces a session that is connected and a bridge that refuses to
127
+ * start — so the failure belongs in the connect's answer, not only in a file
128
+ * nobody reads. It does NOT fail the connect itself: the session IS connected,
129
+ * and reporting otherwise would be a second lie on top of the first.
130
+ */
131
+ export function recordSessionConnectionAfterConnect(result, inputs, write = writeSessionConnection) {
132
+ if (!result.ok)
133
+ return {};
134
+ if (inputs.sessionId === undefined || inputs.sessionId === "") {
135
+ return {
136
+ event_bridge_error: "connected, but this server has no CLAUDE_CODE_SESSION_ID, so the plan event bridge has no connection to read — " +
137
+ "dashboard events will NOT reach this session",
138
+ };
139
+ }
140
+ try {
141
+ write({
142
+ sessionId: inputs.sessionId,
143
+ dashboardUrl: inputs.dashboardUrl,
144
+ source: inputs.source,
145
+ fingerprint: inputs.fingerprint,
146
+ });
147
+ return {};
148
+ }
149
+ catch (err) {
150
+ return {
151
+ event_bridge_error: `connected, but recording the connection for the plan event bridge failed (${err.message}) — ` +
152
+ "dashboard events will NOT reach this session until it succeeds",
153
+ };
154
+ }
155
+ }
156
+ /** Why a parsed record cannot be used, or `null` when it can. */
157
+ export function invalidRecordReason(value) {
158
+ if (typeof value !== "object" || value === null)
159
+ return "it is not an object";
160
+ const r = value;
161
+ if (r.schemaVersion !== SESSION_CONNECTION_SCHEMA_VERSION) {
162
+ return `schemaVersion is ${JSON.stringify(r.schemaVersion)}, not ${SESSION_CONNECTION_SCHEMA_VERSION}`;
163
+ }
164
+ for (const field of ["sessionId", "dashboardUrl", "fingerprint", "connectedAt"]) {
165
+ if (typeof r[field] !== "string" || r[field] === "")
166
+ return `${field} is not a non-empty string`;
167
+ }
168
+ const source = r.source;
169
+ if (typeof source !== "object" || source === null)
170
+ return "source is not an object";
171
+ if (source.kind === "file")
172
+ return typeof source.path === "string" && source.path !== "" ? null : "source.path is not a path";
173
+ if (source.kind === "env")
174
+ return typeof source.name === "string" && source.name !== "" ? null : "source.name is not a variable name";
175
+ return `source.kind is ${JSON.stringify(source.kind)}, which this version does not know`;
176
+ }
package/dist/tool-defs.js CHANGED
File without changes
package/dist/types.js CHANGED
File without changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thehammer/danx-dashboard-mcp",
3
- "version": "0.1.74",
3
+ "version": "0.1.76",
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",