@thehammer/danx-dashboard-mcp 0.1.87 → 0.1.88

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/dist/listen.js CHANGED
@@ -11,31 +11,10 @@
11
11
  * - `{type:"event", id, text}` — exactly one per event, the moment it arrives.
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
- * that carried no usable id — which includes DX-2885's `nudge` frame
15
- * (`formatNudgeLine`): it is advisory, re-derived fresh every tick by the
16
- * dashboard's own keep-alive tick, and never replayed, so it rides this
17
- * same shape with `id: null` rather than growing a THIRD output variant.
18
- * - `{type:"stopped", reason, detail, refusal?, boards?}` — once, last. `reason`
19
- * is the dashboard's own end reason (`superseded`, `replaced`, `revoked`,
20
- * `scope_narrowed`, `not_connected`), `refused` (the ticket was not
21
- * admitted), `bad_end_payload` (a `scope_narrowed` end whose `boards` array
22
- * was missing/invalid — a protocol error, never silently downgraded), or
23
- * `lease_expired` (no healthy connection for the lease). DX-2920 —
24
- * `refusal` is populated ONLY for `reason === "refused"`: `{status,
25
- * errorCode, body}`, the admission response parsed ONCE here, so a
26
- * consumer can recognize a SPECIFIC refusal (the dashboard's own
27
- * `409 session_not_connected`, the same shape the mint route uses) by
28
- * status + error code rather than re-deriving it from the flattened
29
- * `detail` string. `boards` is REQUIRED (round 4 — a discriminated union,
30
- * not an optional field: see `ListenStopped` / `ListenStopReason`) for
31
- * `reason === "scope_narrowed"` and absent for every other reason, parsed
32
- * from the dashboard's `end` payload the SAME way `reason` itself is
33
- * parsed — never re-derived from `detail`. A wire `reason` value this
34
- * build does not recognize (a newer dashboard's reason an older bridge
35
- * hasn't shipped yet) normalizes to `"unknown"` rather than passing the
36
- * raw string through, so the closed set stays closed — `fixForStop`'s
37
- * generic `DEFAULT_STOP_FIX` handles it exactly as it already handled a
38
- * verbatim unrecognized string.
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).
39
18
  * - nothing for keep-alives, the connect marker, or a successful reconnect.
40
19
  *
41
20
  * RECONNECT AND RESUME. The stream drops whenever the dashboard restarts, and can
@@ -53,7 +32,6 @@
53
32
  * (`src/issues/db/issue-activity.ts`); this published package cannot import
54
33
  * danxbot source.
55
34
  */
56
- import { errorCodeOf } from "./json-body.js";
57
35
  import { oneLine } from "./one-line.js";
58
36
  export const INITIAL_BACKOFF_MS = 1_000;
59
37
  export const MAX_BACKOFF_MS = 30_000;
@@ -64,7 +42,6 @@ export const HEALTHY_CONNECTION_MS = 20_000;
64
42
  /** How many delivered event ids the duplicate guard remembers — well past the replay overlap. */
65
43
  export const DELIVERED_ID_MEMORY = 1_000;
66
44
  const LINE_PREFIX = "[danx-dashboard listen]";
67
- const ACTIVITY_ORIGINS = new Set(["operator", "agent", "machine"]);
68
45
  function quoted(value) {
69
46
  return `"${value.text}${value.truncated ? "…" : ""}"`;
70
47
  }
@@ -92,27 +69,12 @@ export function invalidEventReason(value) {
92
69
  if (typeof value[field] !== "string")
93
70
  return `${field} is not a string`;
94
71
  }
95
- // DX-2730 D4 — checked with the rest of the envelope, before any kind-specific
96
- // detail check, exactly like every other envelope field above.
97
- if (typeof value.origin !== "string" || !ACTIVITY_ORIGINS.has(value.origin)) {
98
- return "origin is not one of operator, agent, machine";
99
- }
100
72
  const d = value.detail;
101
73
  if (!isRecord(d))
102
74
  return "detail is not an object";
103
75
  switch (value.kind) {
104
- case "comment_added": {
105
- if (!isCappedText(d.excerpt))
106
- return "detail.excerpt is not capped text";
107
- // DX-2906 — `problem` is OPTIONAL (only present when the comment named
108
- // one); absent is valid, but a present-and-malformed one is not.
109
- if (d.problem === undefined)
110
- return null;
111
- const problem = d.problem;
112
- return isRecord(problem) && typeof problem.id === "number" && isCappedText(problem.statement)
113
- ? null
114
- : "detail.problem is not {id, statement}";
115
- }
76
+ case "comment_added":
77
+ return isCappedText(d.excerpt) ? null : "detail.excerpt is not capped text";
116
78
  case "solution_answered": {
117
79
  const problem = d.problem;
118
80
  if (!isRecord(problem) || typeof problem.id !== "number" || !isCappedText(problem.statement)) {
@@ -131,68 +93,24 @@ export function invalidEventReason(value) {
131
93
  }
132
94
  return null;
133
95
  }
134
- // DX-2935 — the operator retracted a problem's answer with no new one yet.
135
- case "problem_unanswered": {
136
- const problem = d.problem;
137
- if (!isRecord(problem) || typeof problem.id !== "number" || !isCappedText(problem.statement)) {
138
- return "detail.problem is not {id, statement}";
139
- }
140
- return typeof d.decision_id === "number" ? null : "detail.decision_id is not a number";
141
- }
142
- // DX-2935 — the operator retracted a problem's answer AND recorded a new
143
- // one, in one transaction — same either/or solution/freeform shape
144
- // `solution_answered` carries, plus which decision was retracted.
145
- case "problem_answer_changed": {
146
- const problem = d.problem;
147
- if (!isRecord(problem) || typeof problem.id !== "number" || !isCappedText(problem.statement)) {
148
- return "detail.problem is not {id, statement}";
149
- }
150
- if (typeof d.retracted_decision_id !== "number")
151
- return "detail.retracted_decision_id is not a number";
152
- if (typeof d.decision_id !== "number")
153
- return "detail.decision_id is not a number";
154
- if (d.solution === null)
155
- return isCappedText(d.freeform) ? null : "detail.freeform is not capped text";
156
- if (!isRecord(d.solution) || typeof d.solution.title !== "string")
157
- return "detail.solution is not {title, note}";
158
- return d.solution.note === null || isCappedText(d.solution.note) ? null : "detail.solution.note is not capped text";
159
- }
160
96
  case "blocked":
161
97
  return isCappedText(d.reason) ? null : "detail.reason is not capped text";
162
98
  default:
163
99
  return null;
164
100
  }
165
101
  }
166
- /**
167
- * DX-2730 D4 — `actor` alone cannot distinguish an `agent` event from a
168
- * `machine` one (both stamp `actor: "danxbot"` today), so every line names
169
- * the origin next to the actor rather than leaving it to a separate field a
170
- * reader would have to cross-reference.
171
- */
172
- function actorLabel(event) {
173
- return `${event.actor} (${event.origin})`;
174
- }
175
102
  /** Only ever called on an event `invalidEventReason` accepted, so its casts are checked. */
176
103
  function describe(event) {
177
104
  const d = event.detail;
178
- const who = actorLabel(event);
179
105
  switch (event.kind) {
180
- case "comment_added": {
181
- // DX-2906 — a comment naming a problem (a follow-up question, without
182
- // answering it) relays distinctly from a plain comment, so an agent
183
- // listening to the stream can tell which of its open problems just
184
- // got a reply.
185
- const problem = d.problem;
186
- if (problem === undefined)
187
- return `${who} commented: ${quoted(d.excerpt)}`;
188
- return `${who} commented on problem ${quoted(problem.statement)}: ${quoted(d.excerpt)}`;
189
- }
106
+ case "comment_added":
107
+ return `${event.actor} commented: ${quoted(d.excerpt)}`;
190
108
  case "solution_answered": {
191
109
  // DX-2735: every answer answers ONE problem on a card that may carry several,
192
110
  // so the line names the problem — otherwise the agent cannot tell which of its
193
111
  // questions just closed.
194
112
  const problem = d.problem;
195
- const answered = `${who} answered ${quoted(problem.statement)}:`;
113
+ const answered = `${event.actor} answered ${quoted(problem.statement)}:`;
196
114
  const solution = d.solution;
197
115
  if (solution === null)
198
116
  return `${answered} ${quoted(d.freeform)}`;
@@ -203,32 +121,16 @@ function describe(event) {
203
121
  // DX-2830 — this IS the "needs a human" signal now: a card needs one
204
122
  // exactly when it has an open problem, and this is one being opened.
205
123
  const problem = d.problem;
206
- return `${who} opened a problem: ${quoted(problem.statement)}`;
207
- }
208
- case "problem_unanswered": {
209
- // DX-2935 — MUST read as "a previous answer was withdrawn", never as a
210
- // brand-new problem: a connected session that already acted on the
211
- // retracted answer has to stop, not treat this as fresh work to start.
212
- const problem = d.problem;
213
- return `${who} retracted the answer to ${quoted(problem.statement)}`;
214
- }
215
- case "problem_answer_changed": {
216
- // DX-2935 — ONE line for the whole retract-and-reanswer (never two
217
- // separate events), naming the statement so a listener with several
218
- // open questions knows which one just moved.
219
- const problem = d.problem;
220
- const solution = d.solution;
221
- const now = solution === null ? quoted(d.freeform) : `"${solution.title}"`;
222
- return `${who} changed the answer to ${quoted(problem.statement)}: now ${now}`;
124
+ return `${event.actor} opened a problem: ${quoted(problem.statement)}`;
223
125
  }
224
126
  case "blocked":
225
- return `${who} blocked the card: ${quoted(d.reason)}`;
127
+ return `${event.actor} blocked the card: ${quoted(d.reason)}`;
226
128
  case "unblocked":
227
- return `${who} unblocked the card`;
129
+ return `${event.actor} unblocked the card`;
228
130
  default:
229
131
  // A kind newer than this listener still produces a line: an event the
230
132
  // agent is never told about is exactly the failure this reader prevents.
231
- return `${who}: ${event.kind}`;
133
+ return `${event.actor}: ${event.kind}`;
232
134
  }
233
135
  }
234
136
  /** The one readable line for an event. Always a single line; throws, naming the fault, on a malformed event. */
@@ -238,46 +140,6 @@ export function formatActivityLine(event) {
238
140
  throw new Error(`malformed activity event: ${invalid}`);
239
141
  return oneLine(`[${event.cardId} "${event.cardTitle}" ${event.boardId}] ${describe(event)}`);
240
142
  }
241
- function isNudgeCardRef(value) {
242
- return isRecord(value) && typeof value.id === "string" && typeof value.title === "string";
243
- }
244
- function isNudgeCardRefArray(value) {
245
- return Array.isArray(value) && value.every(isNudgeCardRef);
246
- }
247
- /** Why a nudge cannot be read, or `null` when it can — same checked-before-describing shape as `invalidEventReason`. */
248
- export function invalidNudgeReason(value) {
249
- if (!isRecord(value))
250
- return "the nudge is not an object";
251
- if (typeof value.idleMinutes !== "number" || !Number.isFinite(value.idleMinutes)) {
252
- return "idleMinutes is not a number";
253
- }
254
- if (!isNudgeCardRefArray(value.startable))
255
- return "startable is not a {id,title}[] array";
256
- if (!isNudgeCardRefArray(value.held))
257
- return "held is not a {id,title}[] array";
258
- return null;
259
- }
260
- function cardList(cards) {
261
- return cards.map((c) => `${c.id} "${c.title}"`).join(", ");
262
- }
263
- /**
264
- * The one readable line for a nudge (DX-2885). Always names the actual
265
- * waiting cards — never a generic "keep going" — because that is the whole
266
- * point of the feature: a session that only sees "you're idle" learns
267
- * nothing a bare timer couldn't have told it. Always a single line; throws,
268
- * naming the fault, on a malformed nudge.
269
- */
270
- export function formatNudgeLine(event) {
271
- const invalid = invalidNudgeReason(event);
272
- if (invalid !== null)
273
- throw new Error(`malformed nudge event: ${invalid}`);
274
- const parts = [];
275
- if (event.startable.length > 0)
276
- parts.push(`startable: ${cardList(event.startable)}`);
277
- if (event.held.length > 0)
278
- parts.push(`held: ${cardList(event.held)}`);
279
- return oneLine(`${LINE_PREFIX} this session has been idle ${event.idleMinutes}m with work waiting — ${parts.join("; ")}`);
280
- }
281
143
  /**
282
144
  * Incremental SSE parser. Comment lines (keep-alives, the connect marker) and
283
145
  * field-less blocks produce no message, which is what keeps them out of the output.
@@ -319,23 +181,6 @@ function parseBlock(block) {
319
181
  }
320
182
  return sawField ? { id, event, data: data.join("\n") } : null;
321
183
  }
322
- /**
323
- * DX-2920 (round 4) — the dashboard's own `StreamEndReason` values this
324
- * client recognizes on the wire, EXCLUDING `scope_narrowed` (parsed by its own
325
- * dedicated branch in `handle()` below, since it alone carries `boards`).
326
- * Hand-copied from `StreamEndReason` in `src/issues/plan-session-listeners.ts`
327
- * — this published package cannot import danxbot source. A wire `reason` this
328
- * set does not contain (a newer dashboard's reason this build predates)
329
- * normalizes to `"unknown"` rather than passing the raw string through, which
330
- * is what lets `Outcome`'s `reason` field below be a closed union instead of
331
- * `string` — see `ListenStopReason`'s docblock for why that closure is what
332
- * makes the `scope_narrowed`-requires-`boards` invariant an actual compile
333
- * error rather than a comment's promise.
334
- */
335
- const KNOWN_WIRE_END_REASONS = new Set(["superseded", "replaced", "revoked", "not_connected"]);
336
- function isKnownWireEndReason(reason) {
337
- return KNOWN_WIRE_END_REASONS.has(reason);
338
- }
339
184
  /** Statuses that describe the moment, not the ticket — retry them. */
340
185
  const RETRYABLE_CLIENT_STATUSES = new Set([408, 429]);
341
186
  /**
@@ -356,89 +201,14 @@ export async function runListener(options, deps) {
356
201
  };
357
202
  for (const id of options.resumeIds.slice(-DELIVERED_ID_MEMORY))
358
203
  remember(id);
359
- function stop(reason, detail, code, extra) {
360
- if (reason === "scope_narrowed") {
361
- if (extra?.boards === undefined) {
362
- throw new Error('stop("scope_narrowed", ...) requires boards — unreachable through the exported overloads above');
363
- }
364
- deps.write({ type: "stopped", reason, detail, boards: extra.boards });
365
- return code;
366
- }
367
- deps.write({ type: "stopped", reason, detail, refusal: extra?.refusal });
204
+ const stop = (reason, detail, code) => {
205
+ deps.write({ type: "stopped", reason, detail });
368
206
  return code;
369
- }
207
+ };
370
208
  const handle = (message) => {
371
209
  if (message.event === "end") {
372
- // DX-2920 (round 3) — a JSON.parse failure here falls through to the
373
- // pre-existing "unknown" reason below via `isRecord(null)`, unchanged
374
- // from before this round. Only a `scope_narrowed` reason additionally
375
- // REQUIRES a valid `boards` string array (matching the server's `end`
376
- // overload, which cannot emit `scope_narrowed` without one) — a
377
- // `scope_narrowed` frame that fails that check is a PROTOCOL error
378
- // (a server too old to have sent boards, or a corrupted frame), never
379
- // silently downgraded to the generic "the dashboard ended this
380
- // listener" message the caller cannot act on.
381
- let parsed;
382
- try {
383
- parsed = JSON.parse(message.data);
384
- }
385
- catch {
386
- parsed = null;
387
- }
388
- const reasonRaw = isRecord(parsed) ? parsed.reason : undefined;
389
- const reason = typeof reasonRaw === "string" ? reasonRaw : "unknown";
390
- if (reason === "scope_narrowed") {
391
- const boardsRaw = isRecord(parsed) ? parsed.boards : undefined;
392
- const boards = Array.isArray(boardsRaw) && boardsRaw.length > 0 && boardsRaw.every((b) => typeof b === "string")
393
- ? boardsRaw
394
- : null;
395
- if (boards === null) {
396
- return { kind: "ended", reason: "bad_end_payload" };
397
- }
398
- return { kind: "ended", reason, boards };
399
- }
400
- // DX-2920 (round 4) — a wire reason this build does not recognize (a
401
- // newer dashboard's reason an older bridge hasn't shipped yet)
402
- // normalizes to "unknown" rather than passing the raw string through:
403
- // `Outcome`'s "ended" reason is now a closed set (`EndedReason`), and an
404
- // open passthrough here would be exactly the hole that lets a
405
- // missing-`boards` "scope_narrowed" construction slip past the compiler
406
- // elsewhere — see `ListenStopReason`'s docblock.
407
- return { kind: "ended", reason: isKnownWireEndReason(reason) ? reason : "unknown" };
408
- }
409
- if (message.event === "nudge") {
410
- // DX-2885 — a nudge carries no usable id (it is advisory, re-derived
411
- // fresh every tick, never replayed — see `plan-session-stream.ts`'s
412
- // module doc), so it rides the SAME `{type:"event", id:null, text}`
413
- // shape an ordinary activity line uses (`id` is `null` only for a frame
414
- // that carried no usable id — a nudge is exactly that case). That is
415
- // what lets `bridge.ts` and the plugin's relay
416
- // (`danxbot/scripts/plan-event-bridge.mjs`, outside this repo) forward
417
- // it with NO changes at all: both already forward any `{type:"event",
418
- // id, text}` line generically, keyed only on `type`.
419
- //
420
- // DX-2885 — this frame must stay exempt from DX-2730 D4's digest-
421
- // batching when that lands (see DX-2730 AC 29939, and the matching note
422
- // at the dashboard's own emit site): it must keep delivering
423
- // immediately, never held for a later digest flush.
424
- let parsed;
425
- let invalid;
426
- try {
427
- parsed = JSON.parse(message.data);
428
- invalid = invalidNudgeReason(parsed);
429
- }
430
- catch (err) {
431
- invalid = `not JSON: ${err.message}`;
432
- }
433
- const text = invalid === null
434
- ? formatNudgeLine(parsed)
435
- : `${LINE_PREFIX} could not read nudge (${invalid}): ${oneLine(message.data, 300)}`;
436
- // DX-2730 D4 — `origin: null` here, not the parsed event's own origin
437
- // (a nudge has none): this is the exemption the module doc above
438
- // describes, letting `bridge.ts` treat it exactly like an operator
439
- // event (immediate) without needing a fourth vocabulary member.
440
- deps.write({ type: "event", id: null, text, origin: null });
441
- return null;
210
+ const reason = JSON.parse(message.data).reason;
211
+ return { kind: "ended", reason: typeof reason === "string" ? reason : "unknown" };
442
212
  }
443
213
  if (message.event !== "activity")
444
214
  return null;
@@ -467,13 +237,7 @@ export async function runListener(options, deps) {
467
237
  // DX-2735: oneLine caps by code point, so a bad event carrying an emoji
468
238
  // at the cut can never leave a lone surrogate in the line.
469
239
  oneLine(message.data, 300);
470
- // DX-2730 D4 — an event this listener could not parse carries `origin: null`
471
- // (emit immediately) rather than the unreadable payload's own claimed
472
- // origin: a fault worth surfacing at all must never be hidden behind a
473
- // 10-minute digest wait just because it happened to come from an agent or
474
- // a machine writer.
475
- const origin = invalid === null ? parsed.origin : null;
476
- deps.write({ type: "event", id: knownId ? id : null, text, origin });
240
+ deps.write({ type: "event", id: knownId ? id : null, text });
477
241
  if (knownId)
478
242
  remember(id);
479
243
  return null;
@@ -498,25 +262,7 @@ export async function runListener(options, deps) {
498
262
  armIdle();
499
263
  const response = await deps.fetch(options.streamUrl, { headers, signal: controller.signal });
500
264
  if (response.status >= 400 && response.status < 500 && !RETRYABLE_CLIENT_STATUSES.has(response.status)) {
501
- const text = await response.text();
502
- // DX-2920 — parsed ONCE, here, into a structured outcome a consumer can
503
- // classify by status + error code (`bridge.ts` does, matching a specific
504
- // admission refusal such as the dashboard's own `409 session_not_connected`)
505
- // rather than re-deriving it from the flattened `detail` string below.
506
- let body = null;
507
- try {
508
- body = text === "" ? null : JSON.parse(text);
509
- }
510
- catch {
511
- body = null;
512
- }
513
- return {
514
- kind: "refused",
515
- status: response.status,
516
- errorCode: errorCodeOf(body),
517
- body,
518
- detail: `HTTP ${response.status} ${oneLine(text, 300)}`,
519
- };
265
+ return { kind: "refused", detail: `HTTP ${response.status} ${oneLine(await response.text(), 300)}` };
520
266
  }
521
267
  if (!response.ok || response.body === null) {
522
268
  await response.body?.cancel();
@@ -550,23 +296,10 @@ export async function runListener(options, deps) {
550
296
  if (outcome.reason === "superseded" || outcome.reason === "replaced") {
551
297
  return stop(outcome.reason, "another listener for this session took over", 0);
552
298
  }
553
- if (outcome.reason === "bad_end_payload") {
554
- 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);
555
- }
556
- // DX-2920 (round 4) — split explicitly rather than the old conditional
557
- // `outcome.boards === undefined ? undefined : {boards}` pass-through:
558
- // `Outcome`'s "ended" kind is now itself discriminated on `reason`, so
559
- // `outcome.boards` is GUARANTEED present here (no `?? []`, no optional
560
- // check needed) once narrowed to `"scope_narrowed"`.
561
- if (outcome.reason === "scope_narrowed") {
562
- return stop("scope_narrowed", "the dashboard ended this listener", 1, { boards: outcome.boards });
563
- }
564
299
  return stop(outcome.reason, "the dashboard ended this listener", 1);
565
300
  }
566
301
  if (outcome.kind === "refused") {
567
- return stop("refused", `the dashboard refused the listener ticket: ${outcome.detail}`, 1, {
568
- refusal: { status: outcome.status, errorCode: outcome.errorCode, body: outcome.body },
569
- });
302
+ return stop("refused", `the dashboard refused the listener ticket: ${outcome.detail}`, 1);
570
303
  }
571
304
  if (outcome.healthy) {
572
305
  unhealthySince = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thehammer/danx-dashboard-mcp",
3
- "version": "0.1.87",
3
+ "version": "0.1.88",
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",
package/dist/json-body.js DELETED
@@ -1,23 +0,0 @@
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
- }
@@ -1,194 +0,0 @@
1
- /**
2
- * `danx-dashboard-mcp plan-state` — the ONE-SHOT client of
3
- * `GET /api/plan-sessions/me/turn-state` (DX-2921).
4
- *
5
- * WHO RUNS IT. The danxbot Claude Code plugin's DX-2888 turn-start /
6
- * turn-end hooks, via the plugin's existing `bridgeCommand()` npx path — the
7
- * SAME package pin, no separate install, no deep import of this package's
8
- * `dist/` internals (that was DX-2921's whole reason to exist: the DX-2888
9
- * architecture review found the first build of this logic vendored a private
10
- * copy of the MCP package and hardcoded the dashboard HTTP contract inside
11
- * the PLUGIN). The plugin decides what to DO with the answer (inject plan
12
- * numbers at turn start, refuse to let a turn end while the session could
13
- * legally start work and has nothing running); this module only fetches it.
14
- *
15
- * THE CONTRACT (agreed with the DX-2888 plugin side, issue comment 4245 on
16
- * DX-2921 — binding, because the plugin is already built against it):
17
- * - print EXACTLY ONE JSON line on stdout, and ALWAYS exit 0 — success or
18
- * failure alike, because the hook must stay silent rather than spam the
19
- * session's own turn with a stack trace;
20
- * - a session that is not connected to a plan prints
21
- * `{"ok":false,"reason":"session_not_connected"}` — that EXACT literal,
22
- * because the plugin keys its no-log-spam rule on it (it is also the
23
- * dashboard's own 409 error code for this case — see
24
- * `requireConnectedPlanId`, `plan-session-context.ts`);
25
- * - on success, every count is a finite number, `plan` carries `id` +
26
- * `name`, and every `startable` / `held` entry carries a string `id` +
27
- * `title` — the plugin rejects anything else as `bad_response`, so this
28
- * module validates the server's response BEFORE printing it, rather than
29
- * trusting a 200 status to mean a well-shaped body;
30
- * - human-readable diagnostics go to stderr only, never stdout.
31
- *
32
- * THE CREDENTIAL — the SAME resolver `bridge` uses (DX-2862 / DX-2921 AC),
33
- * via `resolveBridgeOptions` (`bridge.ts`): the dashboard URL, credential
34
- * source and credential all come from the connection record this session's
35
- * OWN danx-dashboard MCP server wrote on its last successful `plan_connect`
36
- * (`session-connection.ts`), never from this process's own ambient env. That
37
- * is deliberate here for the identical reason it is deliberate for `bridge`:
38
- * a hook process trusting whatever `DANXBOT_DISPATCH_TOKEN` happens to be in
39
- * its environment is precisely the DX-2862 bug (a hook's ambient credential
40
- * silently pointed at a different dashboard from the one the session's tools
41
- * actually use).
42
- *
43
- * A HARD TIMEOUT (AC 30672) — `PLAN_STATE_REQUEST_TIMEOUT_MS`. This call
44
- * blocks a turn boundary the plugin is holding open, so it must never hang:
45
- * a slow or wedged dashboard is `{ok:false, reason:"timeout"}`, not a stuck
46
- * hook.
47
- */
48
- import { SESSION_ID_HEADER, resolveBridgeOptions } from "./bridge.js";
49
- export const PLAN_STATE_SUBCOMMAND = "plan-state";
50
- export const TURN_STATE_PATH = "/api/plan-sessions/me/turn-state";
51
- /** How long this one-shot call may take before it counts as a timeout. */
52
- export const PLAN_STATE_REQUEST_TIMEOUT_MS = 5_000;
53
- const USAGE = `usage: CLAUDE_CODE_SESSION_ID=<session-id> danx-dashboard-mcp ${PLAN_STATE_SUBCOMMAND}`;
54
- function errorCodeOf(body) {
55
- return typeof body === "object" && body !== null && typeof body.error === "string"
56
- ? body.error
57
- : null;
58
- }
59
- function isFiniteNumber(v) {
60
- return typeof v === "number" && Number.isFinite(v);
61
- }
62
- function isCardRef(v) {
63
- return (typeof v === "object" &&
64
- v !== null &&
65
- typeof v.id === "string" &&
66
- typeof v.title === "string");
67
- }
68
- /**
69
- * The strict shape check the contract pins (comment 4245): finite counts, a
70
- * `plan` with `id` + `name`, and a string `id` + `title` on every
71
- * `startable` / `held` entry. `null` on anything short of that — a 200 whose
72
- * body does not match is `bad_response`, exactly like a non-2xx.
73
- */
74
- export function parseTurnStateBody(body) {
75
- if (typeof body !== "object" || body === null)
76
- return null;
77
- const b = body;
78
- const plan = b.plan;
79
- if (typeof plan !== "object" || plan === null)
80
- return null;
81
- const p = plan;
82
- if (!isFiniteNumber(p.id) || typeof p.name !== "string")
83
- return null;
84
- const counts = b.counts;
85
- if (typeof counts !== "object" || counts === null)
86
- return null;
87
- const c = counts;
88
- if (!isFiniteNumber(c.open) || !isFiniteNumber(c.inProgress) || !isFiniteNumber(c.needsYou))
89
- return null;
90
- if (!Array.isArray(b.startable) || !b.startable.every(isCardRef))
91
- return null;
92
- if (!Array.isArray(b.held) || !b.held.every(isCardRef))
93
- return null;
94
- return {
95
- plan: { id: p.id, name: p.name },
96
- counts: { open: c.open, inProgress: c.inProgress, needsYou: c.needsYou },
97
- startable: b.startable,
98
- held: b.held,
99
- };
100
- }
101
- /**
102
- * One authenticated GET, with a hard timeout, classified into the output
103
- * shape this subcommand always prints — never a throw, never a non-2xx
104
- * bubbling past this function.
105
- */
106
- export async function fetchTurnState(options, deps) {
107
- const controller = new AbortController();
108
- const timer = setTimeout(() => controller.abort(), deps.requestTimeoutMs);
109
- let status;
110
- let text;
111
- try {
112
- try {
113
- const response = await deps.fetch(`${options.dashboardUrl}${TURN_STATE_PATH}`, {
114
- method: "GET",
115
- headers: {
116
- Authorization: `Bearer ${options.token}`,
117
- Accept: "application/json",
118
- [SESSION_ID_HEADER]: options.sessionId,
119
- },
120
- signal: controller.signal,
121
- });
122
- status = response.status;
123
- text = await response.text();
124
- }
125
- catch (err) {
126
- return { ok: false, reason: controller.signal.aborted ? "timeout" : "request_failed" };
127
- }
128
- }
129
- finally {
130
- clearTimeout(timer);
131
- }
132
- let body = null;
133
- try {
134
- body = text === "" ? null : JSON.parse(text);
135
- }
136
- catch {
137
- return { ok: false, reason: "bad_response" };
138
- }
139
- // The exact literal the plugin's no-log-spam rule keys on (AC 30672 /
140
- // comment 4245) — the dashboard's own 409 error code for an unconnected
141
- // session, passed through verbatim rather than renamed.
142
- if (status === 409 && errorCodeOf(body) === "session_not_connected") {
143
- return { ok: false, reason: "session_not_connected" };
144
- }
145
- if (status === 401 || status === 403) {
146
- return { ok: false, reason: "unauthorized" };
147
- }
148
- if (status < 200 || status >= 300) {
149
- return { ok: false, reason: "http_error" };
150
- }
151
- const parsed = parseTurnStateBody(body);
152
- if (parsed === null) {
153
- return { ok: false, reason: "bad_response" };
154
- }
155
- return { ok: true, ...parsed };
156
- }
157
- /** The bin's `plan-state` subcommand, wired to the real process. Always resolves to exit code 0. */
158
- export async function runPlanStateCommand(argv, env = process.env,
159
- /** Test seam: the home the session's connection record is read from. */
160
- resolveFrom = {}) {
161
- const write = (output) => {
162
- process.stdout.write(`${JSON.stringify(output)}\n`);
163
- };
164
- if (argv.length !== 0) {
165
- // A malformed invocation is still a "the hook must stay silent" failure,
166
- // not a crash — same ok:false/exit-0 contract as every other failure
167
- // path here, with the usage line reserved for a human reading stderr.
168
- write({ ok: false, reason: "usage" });
169
- process.stderr.write(`${USAGE}\n`);
170
- return 0;
171
- }
172
- const sessionId = env.CLAUDE_CODE_SESSION_ID;
173
- if (!sessionId) {
174
- write({ ok: false, reason: "no_session_id" });
175
- process.stderr.write(`${USAGE}\n`);
176
- return 0;
177
- }
178
- let options;
179
- try {
180
- options = resolveBridgeOptions({ sessionId, resumeIds: [] }, env, resolveFrom);
181
- }
182
- catch (err) {
183
- const start = err;
184
- write({ ok: false, reason: start.reason });
185
- process.stderr.write(`${start.reason}: ${start.message}. Fix: ${start.fix}.\n`);
186
- return 0;
187
- }
188
- const output = await fetchTurnState(options, {
189
- fetch: (input, init) => fetch(input, init),
190
- requestTimeoutMs: PLAN_STATE_REQUEST_TIMEOUT_MS,
191
- });
192
- write(output);
193
- return 0;
194
- }