@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 +28 -7
- package/dist/handlers.js +9 -1
- package/dist/index.js +8 -3
- package/dist/listen.js +36 -6
- package/dist/one-line.js +3 -2
- package/package.json +1 -1
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
|
-
* `
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
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
|
|
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
|
|
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 `` 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`.", {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
4
|
-
* through it, so they can never disagree about how a
|
|
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.
|
|
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",
|