@thehammer/danx-dashboard-mcp 0.1.87 → 0.1.89

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/bridge.js CHANGED
@@ -88,7 +88,7 @@ import { errorCodeOf } from "./json-body.js";
88
88
  import { oneLine } from "./one-line.js";
89
89
  import { credentialFingerprint, describeCredentialSource, readCredential, } from "./credential.js";
90
90
  import { readSessionConnection } from "./session-connection.js";
91
- import { DELIVERED_ID_MEMORY, HEALTHY_CONNECTION_MS, READ_IDLE_TIMEOUT_MS, runListener, } from "./listen.js";
91
+ import { capResumeIds, DELIVERED_ID_MEMORY, HEALTHY_CONNECTION_MS, READ_IDLE_TIMEOUT_MS, runListener, } from "./listen.js";
92
92
  /**
93
93
  * DX-2730 D4 — WHICH EVENTS WAKE THE SESSION, NOW THAT EVERY ONE OF THEM CAN.
94
94
  *
@@ -170,11 +170,20 @@ const USAGE = `usage: CLAUDE_CODE_SESSION_ID=<session-id> danx-dashboard-mcp ${B
170
170
  * The remedy per terminal reason THIS module knows about. ONE map, so the
171
171
  * message a session is shown and the message a log carries can never drift.
172
172
  * `satisfies` makes a reason missing its remedy a compile error here — but
173
- * `reason` can also arrive at runtime from the dashboard's own SSE end event
174
- * (`ListenStopped.reason` is an open string, not this union), so a build that
175
- * predates a newer dashboard reason still needs an honest, generic fallback
176
- * rather than an index miss: that is `DEFAULT_STOP_FIX`, reached only for a
177
- * reason genuinely outside this list, never for one of the reasons above.
173
+ * `KnownStopReason` above is only a SUBSET of `ListenStopReason` (DX-2920
174
+ * round 4 already made `ListenStopped.reason` a closed union, not a plain
175
+ * `string` — see `listen.ts`), and `ListenStopReason` itself has an `"unknown"`
176
+ * member for exactly a newer dashboard's wire reason this build predates
177
+ * (normalized there, never passed through raw). So a build older than the
178
+ * dashboard it talks to can still reach a real `ListenStopReason` value this
179
+ * map has no entry for. `fixForStop` below is deliberately typed to accept a
180
+ * plain `string`, wider than `ListenStopReason`, purely as defense in depth
181
+ * for a caller reached some other way (and what lets the forward-compat test
182
+ * below exercise the fallback with an arbitrary string) — every value that can
183
+ * actually reach it through `ListenStopped.reason` is already a real,
184
+ * compile-time-checked member of the closed union. `DEFAULT_STOP_FIX` is the
185
+ * honest, generic fallback for a reason genuinely outside this list, never a
186
+ * silent index miss.
178
187
  */
179
188
  export const STOP_FIXES = {
180
189
  no_connection_record: "call plan_connect again in this session — its danx-dashboard MCP server records the connection the bridge needs " +
@@ -457,7 +466,7 @@ function classifyAdmissionRefusal(refusal) {
457
466
  * `stopped` record. Returns 0 when another listener took over, 1 otherwise.
458
467
  */
459
468
  export async function runBridge(options, deps) {
460
- const delivered = options.resumeIds.slice(-DELIVERED_ID_MEMORY);
469
+ const delivered = capResumeIds(options.resumeIds);
461
470
  function stop(reason, detail, code, extra) {
462
471
  // DX-2730 D4 — every terminal return goes through here, so this is the ONE
463
472
  // place that cancels a pending digest timer. CANCELLED, never flushed: for
@@ -577,6 +586,18 @@ export async function runBridge(options, deps) {
577
586
  backoff = MINT_INITIAL_BACKOFF_MS;
578
587
  const run = { stopped: null, emitted: false };
579
588
  const startedAt = deps.now();
589
+ // DX-2829 — `runListener`'s own return value (0 for a benign takeover, 1
590
+ // otherwise) is intentionally discarded here, never reused as this
591
+ // function's exit code: `runBridge` computes ITS OWN exit code below from
592
+ // `stopped.reason` via the `stop()` helper above, because a bridge-level
593
+ // stop can happen for reasons `runListener` never sees at all (mint
594
+ // failures, the refused-ticket retry budget, `classifyAdmissionRefusal`'s
595
+ // reclassification) — `stop()` is the one place that has to decide the
596
+ // exit code for ALL of them, not just the stream-reader's own outcomes.
597
+ // Reusing `runListener`'s return value here would be redundant at best
598
+ // (the two happen to agree for `superseded`/`replaced`) and wrong at worst
599
+ // wherever a bridge-level reason diverges from the stream-level one it
600
+ // was built from.
580
601
  await runListener(
581
602
  // DX-2730 D4 — `heldIds()` rides along with `delivered`: a held-but-
582
603
  // not-yet-flushed event was never added to `delivered` (see `emitNow`),
package/dist/handlers.js CHANGED
@@ -566,6 +566,12 @@ export async function issueChecklist(client, args) {
566
566
  * card is put in front of one; the reminder that used to ride the retired
567
567
  * `issue_requires_human({set: true})` now rides here instead.
568
568
  *
569
+ * DX-2942 — `statement` is capped at 100 characters server-side (a 400 names
570
+ * the actual length otherwise); `context` is the markdown detail behind it,
571
+ * sent only when given on `add`, and on `edit` `undefined` keeps the stored
572
+ * context while `null` clears it — the same omit/null semantics
573
+ * `plan_update_record` already gives a plan record's context.
574
+ *
569
575
  * No answer action, for the reason `issueSolution` gives.
570
576
  */
571
577
  export async function issueProblem(client, args) {
@@ -577,6 +583,8 @@ export async function issueProblem(client, args) {
577
583
  return client.request({ method: "GET", path: `/${idEnc}/problems`, board });
578
584
  case "add": {
579
585
  const body = { statement: need.string(args.statement, "statement") };
586
+ if (args.context !== undefined)
587
+ body.context = args.context;
580
588
  if (args.solutions !== undefined)
581
589
  body.solutions = args.solutions;
582
590
  const result = await client.request({ method: "POST", path: `/${idEnc}/problems`, body, board });
@@ -589,7 +597,7 @@ export async function issueProblem(client, args) {
589
597
  return client.request({
590
598
  method: "PATCH",
591
599
  path: `/${idEnc}/problems/${problemId}`,
592
- body: { base_hash: baseHash, statement },
600
+ body: { base_hash: baseHash, statement, ...(args.context === undefined ? {} : { context: args.context }) },
593
601
  board,
594
602
  });
595
603
  }
package/dist/index.js CHANGED
@@ -548,12 +548,17 @@ const SOLUTION_FIELDS = {
548
548
  con: z.string().optional(),
549
549
  recommended: z.boolean().optional(),
550
550
  };
551
- server.tool("issue_problem", "A card's PROBLEMS via /api/issues/:id/problems[/:pid]: one statement the operator must resolve (a question, or a flaw in the plan), each with its own solutions/answers — one problem per question. OPEN = not yet answered; the card needs a human exactly while open_problem_count > 0 — there is no separate flag to set, adding a problem IS putting the card in front of a human. list → live problems in order, each {id, statement, content_hash, open, solutions[], decisions[]}; add {statement, solutions?} → problem_id + solution_ids in one transaction (zero solutions is valid: the operator answers free-form) plus `problems_reminder: {open_problem_count, instruction}` naming each open problem's solution count; edit :pid {base_hash, statement}; remove :pid {base_hash} — always allowed, even as the card's last open problem (DX-2830: removing it just means the card no longer needs a human). A stale base_hash → 409 `stale_problem` with currentHash + currentProblem: merge, then retry. No answer action — the operator answers in the dashboard.", {
551
+ server.tool("issue_problem", "A card's PROBLEMS via /api/issues/:id/problems[/:pid]: one statement the operator must resolve (a question, or a flaw in the plan), each with its own solutions/answers — one problem per question. OPEN = not yet answered; the card needs a human exactly while open_problem_count > 0 — there is no separate flag to set, adding a problem IS putting the card in front of a human. list → live problems in order, each {id, statement, context, content_hash, open, solutions[], decisions[]}; add {statement, context?, solutions?} → problem_id + solution_ids in one transaction (zero solutions is valid: the operator answers free-form) plus `problems_reminder: {open_problem_count, instruction}` naming each open problem's solution count; edit :pid {base_hash, statement, context?}; remove :pid {base_hash} — always allowed, even as the card's last open problem (DX-2830: removing it just means the card no longer needs a human). A stale base_hash → 409 `stale_problem` with currentHash + currentProblem: merge, then retry. No answer action — the operator answers in the dashboard. DX-2942 — `statement` is capped at 100 characters (a 400 names the actual length otherwise): write it as ONE plain question, and put any investigation detail in `context` (markdown, no cap) instead of running it on. Good: statement \"Which cache should we use?\", context \"Redis fits the read-heavy path; see benchmark in #123. Memcached is simpler ops but no persistence.\" Bad: statement \"We looked at Redis vs Memcached, ran benchmarks showing Redis 3x faster on reads, but Memcached has simpler ops and we're not sure persistence matters here since the cache is fully rebuildable from Postgres...\" (too long, refused — move it to context).", {
552
552
  id: z.string().min(1),
553
553
  action: z.enum(["list", "add", "edit", "remove"]),
554
554
  problem_id: z.number().int().positive().optional().describe("edit/remove"),
555
555
  base_hash: z.string().min(1).optional().describe("content_hash last read; edit/remove"),
556
- statement: z.string().min(1).optional().describe("add/edit"),
556
+ statement: z.string().min(1).optional().describe("add/edit; ONE plain question, at most 100 characters"),
557
+ context: z
558
+ .string()
559
+ .nullable()
560
+ .optional()
561
+ .describe("add/edit; markdown detail behind statement — file:line refs, code excerpts, root-cause writeups. add: sent only when given (null/omit means none). edit: omit to keep the stored context, null to clear it."),
557
562
  solutions: z
558
563
  .array(z.object({ title: z.string().min(1), ...SOLUTION_FIELDS }))
559
564
  .optional()
@@ -654,7 +659,7 @@ server.tool("issue_retro", "Replace the retro block via PUT /api/issues/:id/retr
654
659
  // route's MAX_DECODED_BYTES (src/issues/write/attachments.ts). This package is
655
660
  // a separate published artifact and cannot import that constant, so the number
656
661
  // is restated here as prose — keep the two in sync if the backend ceiling moves.
657
- server.tool("issue_attach", "Attach a LOCAL file to an issue card via POST /api/issues/:id/attachments. Pass `id` (the card) and `file_path` (an ABSOLUTE path to a file on the dispatch's shared filesystem — e.g. a screenshot, exported CSV, or diagram you wrote). This MCP server reads the bytes, infers the MIME type from the extension, and uploads through the dashboard, which: stores the bytes in S3, inserts ONE danxbot-origin issue_attachments row, and auto-mirrors the file to the card's linked Trello card AND its Slack card-view thread (DX-1122 outbound projection) — no extra step needed. Board-scoped; see `board`. Fail-loud: a relative/empty path is rejected at the MCP boundary, and a missing/unreadable file throws BEFORE any upload (no partial S3 object, no row). 25 MB decoded ceiling (413). Returns the hydrated issue plus the new attachment id.", {
662
+ server.tool("issue_attach", "Attach a LOCAL file to an issue card via POST /api/issues/:id/attachments. Pass `id` (the card) and `file_path` (an ABSOLUTE path to a file on the dispatch's shared filesystem — e.g. a screenshot, exported CSV, or diagram you wrote). This MCP server reads the bytes, infers the MIME type from the extension, and uploads through the dashboard, which: stores the bytes in S3, inserts ONE danxbot-origin issue_attachments row, and auto-mirrors the file to the card's linked Trello card AND its Slack card-view thread (DX-1122 outbound projection) — no extra step needed. Board-scoped; see `board`. Fail-loud: a relative/empty path is rejected at the MCP boundary, and a missing/unreadable file throws BEFORE any upload (no partial S3 object, no row). 25 MB decoded ceiling (413). Returns `{issue, attachment_id, s3_key}` — find the new attachment in `issue.attachments` by `attachment_id` to read its public, long-lived `url` (no expiry — never a presigned link). EMBEDDING: that `url` is not only a card-level attachment — paste it into ANY markdown-bearing field (a problem's `statement`/`context` via `issue_problem`, a comment via `issue_comment`, a plan record's `context`, a solution's `body`) as `![description](url)` and the dashboard renders it inline, capped to a compact thumbnail with click-to-enlarge. Use this whenever a screenshot would let a human judge something faster than prose — a UI bug, a before/after, a broken layout. Example: after uploading and reading the attachment's url (say `https://dx-issues.s3.us-east-1.amazonaws.com/abc123.png`), call `issue_comment` with text `Before the fix, the sidebar overlapped the header:\\n\\n![sidebar overlapping header](https://dx-issues.s3.us-east-1.amazonaws.com/abc123.png)`.", {
658
663
  id: z.string().min(1),
659
664
  file_path: z
660
665
  .string()
package/dist/listen.js CHANGED
@@ -63,7 +63,17 @@ export const READ_IDLE_TIMEOUT_MS = 45_000;
63
63
  export const HEALTHY_CONNECTION_MS = 20_000;
64
64
  /** How many delivered event ids the duplicate guard remembers — well past the replay overlap. */
65
65
  export const DELIVERED_ID_MEMORY = 1_000;
66
- const LINE_PREFIX = "[danx-dashboard listen]";
66
+ /**
67
+ * DX-2829 — the ONE place that caps a resume-id list to the last
68
+ * `DELIVERED_ID_MEMORY` entries. Both `runListener` (seeding its own
69
+ * duplicate-detection `Set` below) and `bridge.ts`'s `runBridge` (seeding its
70
+ * own delivered-id array) used to each carry their own `slice(-DELIVERED_ID_MEMORY)`
71
+ * — the same cap, on the same kind of input, computed twice.
72
+ */
73
+ export function capResumeIds(ids) {
74
+ return ids.slice(-DELIVERED_ID_MEMORY);
75
+ }
76
+ const LINE_PREFIX = "[danx-dashboard stream]";
67
77
  const ACTIVITY_ORIGINS = new Set(["operator", "agent", "machine"]);
68
78
  function quoted(value) {
69
79
  return `"${value.text}${value.truncated ? "…" : ""}"`;
@@ -336,6 +346,10 @@ const KNOWN_WIRE_END_REASONS = new Set(["superseded", "replaced", "revoked", "no
336
346
  function isKnownWireEndReason(reason) {
337
347
  return KNOWN_WIRE_END_REASONS.has(reason);
338
348
  }
349
+ /** A thrown value's message, for the one drop path that carries a real JS error — never re-derived elsewhere. */
350
+ function describeDropError(err) {
351
+ return err instanceof Error ? err.message : String(err);
352
+ }
339
353
  /** Statuses that describe the moment, not the ticket — retry them. */
340
354
  const RETRYABLE_CLIENT_STATUSES = new Set([408, 429]);
341
355
  /**
@@ -348,13 +362,18 @@ export async function runListener(options, deps) {
348
362
  let lastEventId = null;
349
363
  let unhealthySince = null;
350
364
  let attempt = 0;
365
+ // DX-2829 — the most recent `dropped` outcome's cause, carried across
366
+ // reconnect attempts so the eventual `lease_expired` stop (the only outcome
367
+ // reached exclusively via repeated drops) can say what actually failed
368
+ // instead of only "no healthy connection ... past the ticket's lease".
369
+ let lastDropDetail = null;
351
370
  const remember = (id) => {
352
371
  delivered.add(id);
353
372
  if (delivered.size > DELIVERED_ID_MEMORY)
354
373
  delivered.delete(delivered.values().next().value);
355
374
  lastEventId = lastEventId === null ? id : Math.max(lastEventId, id);
356
375
  };
357
- for (const id of options.resumeIds.slice(-DELIVERED_ID_MEMORY))
376
+ for (const id of capResumeIds(options.resumeIds))
358
377
  remember(id);
359
378
  function stop(reason, detail, code, extra) {
360
379
  if (reason === "scope_narrowed") {
@@ -520,7 +539,7 @@ export async function runListener(options, deps) {
520
539
  }
521
540
  if (!response.ok || response.body === null) {
522
541
  await response.body?.cancel();
523
- return { kind: "dropped", healthy: false };
542
+ return { kind: "dropped", healthy: false, detail: `HTTP ${response.status} with no readable body` };
524
543
  }
525
544
  const parser = new SseParser();
526
545
  const decoder = new TextDecoder();
@@ -536,8 +555,14 @@ export async function runListener(options, deps) {
536
555
  }
537
556
  return { kind: "dropped", healthy: healthy() };
538
557
  }
539
- catch {
540
- return { kind: "dropped", healthy: healthy() };
558
+ catch (err) {
559
+ // DX-2829 — was a bare `catch { ... }` that discarded the causing error
560
+ // entirely: a connection that failed the same way on every retry (DNS
561
+ // failure, TLS reset, the idle-timeout abort) surfaced nothing beyond
562
+ // "no healthy connection" once the lease finally expired. Preserving the
563
+ // message here is what lets `runListener` name the actual last failure
564
+ // in its final `lease_expired` stop record below.
565
+ return { kind: "dropped", healthy: healthy(), detail: describeDropError(err) };
541
566
  }
542
567
  finally {
543
568
  clearTimeout(idle);
@@ -568,6 +593,8 @@ export async function runListener(options, deps) {
568
593
  refusal: { status: outcome.status, errorCode: outcome.errorCode, body: outcome.body },
569
594
  });
570
595
  }
596
+ if (outcome.detail !== undefined)
597
+ lastDropDetail = outcome.detail;
571
598
  if (outcome.healthy) {
572
599
  unhealthySince = null;
573
600
  attempt = 0;
@@ -575,7 +602,10 @@ export async function runListener(options, deps) {
575
602
  const now = deps.now();
576
603
  unhealthySince ??= now;
577
604
  if (now - unhealthySince >= options.leaseMs) {
578
- return stop("lease_expired", `no healthy connection to ${options.streamUrl} for ${Math.round((now - unhealthySince) / 60_000)} minutes, past the ticket's lease`, 1);
605
+ // DX-2829 — names the last connection failure when one was ever recorded,
606
+ // instead of only reporting the timeout itself.
607
+ const cause = lastDropDetail === null ? "" : ` — last connection attempt failed: ${lastDropDetail}`;
608
+ return stop("lease_expired", `no healthy connection to ${options.streamUrl} for ${Math.round((now - unhealthySince) / 60_000)} minutes, past the ticket's lease${cause}`, 1);
579
609
  }
580
610
  await deps.sleep(Math.min(MAX_BACKOFF_MS, INITIAL_BACKOFF_MS * 2 ** attempt));
581
611
  attempt += 1;
package/dist/one-line.js CHANGED
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * DX-2735 — the ONE way this package renders human text into an agent-facing
3
- * line: the listen notification line and the problems reminder both go
4
- * through it, so they can never disagree about how a statement looks.
3
+ * line: the dashboard-event stream's notification line and the problems
4
+ * reminder both go through it, so they can never disagree about how a
5
+ * statement looks.
5
6
  *
6
7
  * Every whitespace run (newlines included) collapses to a single space, because
7
8
  * a notification is one line and a reminder quotes statements inline. The
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.89",
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",