@thehammer/danx-dashboard-mcp 0.1.64 → 0.1.66

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
@@ -38,19 +38,19 @@ All exposed as `mcp__danx-dashboard__<name>` once wired through the workspace `m
38
38
 
39
39
  A bare `plan_get` (no `fields`) returns only the plan's cheap scalars — `plan`, `boards`, `cardCount`, `bucketCounts`, `session`, `sessionListenerAttached` — plus `available_field_groups` naming what else exists. Pass `fields` to opt into `cards` (paged: `cards_offset`, default 0, and `cards_limit`, 1..1000, default 200, pick the page; the response carries `cards_total` and `cards_offset`, so page with `cards_offset` while `cards_offset + cards.length < cards_total` — either paging arg without `cards` in `fields` is a 400), `records` (every goal/rule/caveat) or `records:goal` / `records:rule` / `records:caveat` (just one kind), `architecture`, and `sessions`. A `plan_get` made only to grab a hash before a one-line edit no longer pays for the architecture document or every member card.
40
40
 
41
- ## `listen` — a working session's event listener
41
+ ## `bridge` — a working session's event stream client
42
42
 
43
- `plan_connect` connects the session to a plan AND returns `listener: {command, persistent: true, instruction}`. The agent arms `command` with Claude Code's Monitor tool (`persistent: true`); every line the command prints becomes a notification that wakes the session.
43
+ 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.
44
44
 
45
45
  ```bash
46
- npx -y @thehammer/danx-dashboard-mcp@<version> listen --stream <dashboard>/api/plan-sessions/stream --ticket <ticket> --lease-ms <ms>
46
+ DANXBOT_DASHBOARD_URL=<dashboard> DANXBOT_DISPATCH_TOKEN=<token> CLAUDE_CODE_SESSION_ID=<session> \
47
+ npx -y @thehammer/danx-dashboard-mcp@<version> bridge [--resume-ids <id>,<id>,...]
47
48
  ```
48
49
 
49
- All three flags come from the dashboard's own ticket response; `plan_connect` assembles the command, so never build it by hand.
50
-
51
- - **Credential.** `--ticket` is a listener ticket minted by `POST /api/plan-sessions/me/stream-ticket` for THIS session only. It authorizes reading that one session's event stream and nothing else, and stops the moment the credential that minted it is revoked — the MCP server's own token never appears in the command. Minting a new one (calling `plan_connect` again) ends the previous listener; a second connection with the same ticket replaces the first.
52
- - **Output.** Exactly one line per event on the connected plan's cards — `[DX-8 "Title" repo:board] newms87 answered "Which rollout order?": chose "Pause E2E" — note: "only this week"`, `… answered "…": "<free-form answer>"`, `… commented: "…"`, `… set requires_human: "…"`, `… cleared requires_human`, `… blocked the card: "…"`, `… unblocked the card`. Nothing for keep-alives or reconnects. The session's own writes are never echoed back to it. An event it cannot read still produces one `could not read event` line.
53
- - **Reconnect.** 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 printed twice. One final `[danx-dashboard listen] …` line and exit when the ticket is refused or revoked (exit 1), when a newer listener takes over (exit 0), or after `--lease-ms` without a healthy connection (exit 1) — the remedy is always `plan_connect` again.
50
+ - **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.
51
+ - **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: "…"`, `… set requires_human: "…"`, `… cleared requires_human`, `… 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.
52
+ - **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.
53
+ - **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.
54
54
 
55
55
  ## Build + test
56
56
 
package/dist/bridge.js ADDED
@@ -0,0 +1,236 @@
1
+ /**
2
+ * `danx-dashboard-mcp bridge [--resume-ids <event-id>[,<event-id>...]]` — the ONE
3
+ * client of a working session's dashboard event stream.
4
+ *
5
+ * WHO RUNS IT. The danxbot Claude Code plugin's plan event bridge
6
+ * (`danxbot/scripts/plan-event-bridge.mjs` in claude-plugins) spawns it once per
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.
11
+ *
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.
22
+ *
23
+ * 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;
28
+ * - `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
32
+ * process lives exactly as long as its session, and the plugin ends it.
33
+ *
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.
37
+ */
38
+ import { oneLine } from "./one-line.js";
39
+ import { DELIVERED_ID_MEMORY, HEALTHY_CONNECTION_MS, READ_IDLE_TIMEOUT_MS, runListener, } from "./listen.js";
40
+ export const BRIDGE_SUBCOMMAND = "bridge";
41
+ export const STREAM_TICKET_PATH = "/api/plan-sessions/me/stream-ticket";
42
+ 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;
45
+ export const MINT_INITIAL_BACKOFF_MS = 1_000;
46
+ export const MINT_MAX_BACKOFF_MS = 60_000;
47
+ /** A freshly minted ticket refused this many times in a row is a terminal outcome, not a loop. */
48
+ export const MAX_REFUSED_TICKETS_IN_A_ROW = 2;
49
+ const TRANSIENT_CLIENT_STATUSES = new Set([408, 429]);
50
+ 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>...]]`;
53
+ function positiveInteger(raw) {
54
+ const n = Number(raw);
55
+ return Number.isSafeInteger(n) && n > 0 && String(n) === raw ? n : null;
56
+ }
57
+ /** Parse the one optional flag and the three required environment values; anything else is refused. */
58
+ export function parseBridgeArgs(argv, env) {
59
+ const withResume = argv.length === 2 && argv[0] === "--resume-ids" && argv[1] !== "";
60
+ if (argv.length !== 0 && !withResume)
61
+ throw new Error(USAGE);
62
+ const dashboardUrl = env.DANXBOT_DASHBOARD_URL;
63
+ const token = env.DANXBOT_DISPATCH_TOKEN;
64
+ const sessionId = env.CLAUDE_CODE_SESSION_ID;
65
+ if (!dashboardUrl || !token || !sessionId)
66
+ throw new Error(USAGE);
67
+ const resumeIds = withResume ? argv[1].split(",").map(positiveInteger) : [];
68
+ if (resumeIds.some((id) => id === null))
69
+ throw new Error(USAGE);
70
+ return { dashboardUrl: dashboardUrl.replace(/\/+$/, ""), token, sessionId, resumeIds: resumeIds };
71
+ }
72
+ function errorCodeOf(body) {
73
+ return typeof body === "object" && body !== null && typeof body.error === "string"
74
+ ? body.error
75
+ : null;
76
+ }
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) {
93
+ const controller = new AbortController();
94
+ const timer = setTimeout(() => controller.abort(), deps.mintTimeoutMs);
95
+ try {
96
+ let status;
97
+ let text;
98
+ 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: "{}",
108
+ signal: controller.signal,
109
+ });
110
+ status = response.status;
111
+ text = await response.text();
112
+ }
113
+ catch (err) {
114
+ return {
115
+ kind: "transient",
116
+ detail: controller.signal.aborted
117
+ ? `ticket mint timed out after ${deps.mintTimeoutMs} ms`
118
+ : `ticket mint failed: ${err.message}`,
119
+ };
120
+ }
121
+ if (status >= 500 || TRANSIENT_CLIENT_STATUSES.has(status)) {
122
+ return { kind: "transient", detail: `ticket mint HTTP ${status}` };
123
+ }
124
+ let body = null;
125
+ try {
126
+ body = text === "" ? null : JSON.parse(text);
127
+ }
128
+ catch {
129
+ body = null;
130
+ }
131
+ if (status === 409 && errorCodeOf(body) === "session_not_connected") {
132
+ return { kind: "terminal", reason: "not_connected", detail: "the session is not connected to a plan" };
133
+ }
134
+ if (status === 401 || status === 403) {
135
+ return { kind: "terminal", reason: "unauthorized", detail: `ticket mint HTTP ${status} ${oneLine(text, 300)}` };
136
+ }
137
+ if (status < 200 || status >= 300) {
138
+ return { kind: "terminal", reason: "mint_refused", detail: `ticket mint HTTP ${status} ${oneLine(text, 300)}` };
139
+ }
140
+ const bad = badTicketReason(body);
141
+ if (bad !== null) {
142
+ return { kind: "terminal", reason: "mint_bad_response", detail: `ticket mint HTTP ${status}: ${bad}` };
143
+ }
144
+ const ticket = body;
145
+ return {
146
+ kind: "ticket",
147
+ ticket: ticket.ticket,
148
+ streamUrl: `${options.dashboardUrl}${ticket.streamPath}`,
149
+ leaseMs: ticket.leaseMs,
150
+ };
151
+ }
152
+ finally {
153
+ clearTimeout(timer);
154
+ }
155
+ }
156
+ /**
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.
159
+ */
160
+ export async function runBridge(options, deps) {
161
+ const delivered = options.resumeIds.slice(-DELIVERED_ID_MEMORY);
162
+ const stop = (reason, detail, code) => {
163
+ deps.write({ type: "stopped", reason, detail });
164
+ return code;
165
+ };
166
+ let backoff = MINT_INITIAL_BACKOFF_MS;
167
+ let refusedInRow = 0;
168
+ for (;;) {
169
+ const minted = await mintTicket(options, deps);
170
+ if (minted.kind === "terminal")
171
+ return stop(minted.reason, minted.detail, 1);
172
+ if (minted.kind === "transient") {
173
+ await deps.sleep(backoff);
174
+ backoff = Math.min(MINT_MAX_BACKOFF_MS, backoff * 2);
175
+ continue;
176
+ }
177
+ backoff = MINT_INITIAL_BACKOFF_MS;
178
+ const run = { stopped: null, emitted: false };
179
+ const startedAt = deps.now();
180
+ await runListener({ streamUrl: minted.streamUrl, ticket: minted.ticket, leaseMs: minted.leaseMs, resumeIds: [...delivered] }, {
181
+ ...deps,
182
+ write: (output) => {
183
+ if (output.type === "stopped") {
184
+ run.stopped = output;
185
+ return;
186
+ }
187
+ run.emitted = true;
188
+ if (output.id !== null) {
189
+ delivered.push(output.id);
190
+ if (delivered.length > DELIVERED_ID_MEMORY)
191
+ delivered.shift();
192
+ }
193
+ deps.write(output);
194
+ },
195
+ });
196
+ const stopped = run.stopped;
197
+ if (stopped === null)
198
+ throw new Error("the stream reader ended without a stop record");
199
+ if (TAKEOVER_REASONS.has(stopped.reason))
200
+ return stop(stopped.reason, stopped.detail, 0);
201
+ if (stopped.reason === "lease_expired") {
202
+ refusedInRow = 0;
203
+ continue;
204
+ }
205
+ if (stopped.reason === "refused") {
206
+ const provedHealthy = run.emitted || deps.now() - startedAt >= HEALTHY_CONNECTION_MS;
207
+ refusedInRow = provedHealthy ? 1 : refusedInRow + 1;
208
+ if (refusedInRow >= MAX_REFUSED_TICKETS_IN_A_ROW) {
209
+ return stop("refused", `${stopped.detail} (a freshly minted ticket was refused ${refusedInRow} times in a row)`, 1);
210
+ }
211
+ continue;
212
+ }
213
+ return stop(stopped.reason, stopped.detail, 1);
214
+ }
215
+ }
216
+ /** The bin's `bridge` subcommand, wired to the real process. */
217
+ export async function runBridgeCommand(argv, env = process.env) {
218
+ let options;
219
+ try {
220
+ options = parseBridgeArgs(argv, env);
221
+ }
222
+ catch (err) {
223
+ process.stderr.write(`${err.message}\n`);
224
+ return 2;
225
+ }
226
+ return runBridge(options, {
227
+ fetch: (input, init) => fetch(input, init),
228
+ write: (output) => {
229
+ process.stdout.write(`${JSON.stringify(output)}\n`);
230
+ },
231
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
232
+ now: Date.now,
233
+ readIdleTimeoutMs: READ_IDLE_TIMEOUT_MS,
234
+ mintTimeoutMs: MINT_TIMEOUT_MS,
235
+ });
236
+ }
package/dist/handlers.js CHANGED
@@ -887,39 +887,6 @@ export async function planGet(client, args = {}) {
887
887
  query,
888
888
  });
889
889
  }
890
- function readIssuedTicket(body) {
891
- const b = body;
892
- return b !== null &&
893
- typeof b.ticket === "string" &&
894
- b.ticket !== "" &&
895
- typeof b.streamPath === "string" &&
896
- typeof b.leaseMs === "number"
897
- ? { ticket: b.ticket, streamPath: b.streamPath, leaseMs: b.leaseMs }
898
- : null;
899
- }
900
- /**
901
- * The exact shell command a Monitor runs. The ticket is the only credential in
902
- * it; the stream URL and lease come from the dashboard's own ticket response, so
903
- * the listener carries no copy of either.
904
- */
905
- export function listenCommand(target, issued) {
906
- const quote = (value) => `'${value.replace(/'/g, `'\\''`)}'`;
907
- const streamUrl = `${target.baseUrl.replace(/\/+$/, "")}${issued.streamPath}`;
908
- return (`npx -y ${target.packageSpec} listen --stream ${quote(streamUrl)} ` +
909
- `--ticket ${quote(issued.ticket)} --lease-ms ${issued.leaseMs}`);
910
- }
911
- function listenerNotArmed(status, connected, ticketResponse) {
912
- return {
913
- ok: false,
914
- status,
915
- body: {
916
- error: "listener_not_armed",
917
- message: "This session IS now connected to the plan, but no listener ticket was issued, so no listener can be armed and this plan's events will NOT reach this session. Resolve the problem below and call plan_connect again.",
918
- connected,
919
- ticket_response: ticketResponse,
920
- },
921
- };
922
- }
923
890
  /**
924
891
  * Connect THIS session to a plan — the same write the operator's Connect
925
892
  * action performs, reaching the same server-side code path. `me` in the URL
@@ -929,50 +896,19 @@ function listenerNotArmed(status, connected, ticketResponse) {
929
896
  * A session already on another plan is MOVED, and the response says which
930
897
  * plan it left (`movedFrom`).
931
898
  *
932
- * THEN IT ARMS THE LISTENER. A connected session must hear about its plan's
933
- * cards the moment something happens, so the reply carries the exact Monitor
934
- * command to run. The command holds a stream TICKET this server mints for the
935
- * session — never this server's own long-lived token, which must not appear in
936
- * a command line. Minting a ticket replaces the session's previous one, so
937
- * re-connecting (after a restart, or to re-arm) leaves exactly one listener.
938
- *
939
- * A refused ticket is NOT reported as a successful connect: the binding did
940
- * happen, but a session that believes it is listening when it is not would miss
941
- * the operator's answer silently, so the whole call fails loud and says both.
899
+ * IT DOES NOTHING ABOUT HEARING THE PLAN'S EVENTS, deliberately. Delivery belongs
900
+ * to the danxbot Claude Code plugin's plan event bridge, a background process
901
+ * that holds the session's ONE listener ticket and starts when this tool
902
+ * succeeds. The dashboard keeps one ticket per session, so a ticket minted here
903
+ * would end the bridge's stream.
942
904
  */
943
- export async function planConnect(client, args, listener) {
944
- const connected = await client.request({
905
+ export async function planConnect(client, args) {
906
+ return client.request({
945
907
  method: "POST",
946
908
  path: "/me/plan",
947
909
  basePath: PLAN_SESSIONS_BASE_PATH,
948
910
  body: { plan_id: args.plan_id },
949
911
  });
950
- if (!connected.ok)
951
- return connected;
952
- let issued;
953
- try {
954
- issued = await client.request({
955
- method: "POST",
956
- path: "/me/stream-ticket",
957
- basePath: PLAN_SESSIONS_BASE_PATH,
958
- });
959
- }
960
- catch (err) {
961
- // The binding already happened; a thrown ticket request must not erase that fact.
962
- return listenerNotArmed(502, connected.body, { error: err instanceof Error ? err.message : String(err) });
963
- }
964
- const ticket = issued.ok ? readIssuedTicket(issued.body) : null;
965
- if (ticket === null) {
966
- return listenerNotArmed(issued.ok ? 502 : issued.status, connected.body, issued.body);
967
- }
968
- return {
969
- ...connected,
970
- listener: {
971
- command: listenCommand(listener, ticket),
972
- persistent: true,
973
- instruction: "Arm this now with the Monitor tool (command above, persistent: true). Every comment, answer, requires_human change and block/unblock on this plan's cards will then arrive as a notification within seconds — do not poll for them. If the Monitor prints that it gave up or was ended, call plan_connect again and arm the new command.",
974
- },
975
- };
976
912
  }
977
913
  /** Add a goal, rule or caveat to the connected plan. `context` is sent only when given. */
978
914
  export async function planAddRecord(client, args) {
package/dist/index.js CHANGED
@@ -81,10 +81,9 @@
81
81
  * agent reads `body.error` + structured fields to decide next action.
82
82
  * 5xx and network failures throw — never silently swallowed.
83
83
  */
84
- import { createRequire } from "node:module";
85
84
  import { basename } from "node:path";
86
85
  import { isEntrypointModule } from "./entrypoint.js";
87
- import { LISTEN_SUBCOMMAND, runListenCommand } from "./listen.js";
86
+ import { BRIDGE_SUBCOMMAND, runBridgeCommand } from "./bridge.js";
88
87
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
89
88
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
90
89
  import { z } from "zod";
@@ -170,16 +169,6 @@ function readSessionConfig() {
170
169
  const fallback = cwdName === "" ? `session ${id.slice(0, 8)}` : cwdName;
171
170
  return { id, title: readEnvOptional("DANX_SESSION_TITLE") ?? fallback };
172
171
  }
173
- /**
174
- * This build's own npm spec. `plan_connect` hands out a `listen` command pinned to
175
- * it, so the listener that runs is always the one written for the stream this
176
- * server's dashboard speaks — never whatever `npx` happens to have cached.
177
- * `package.json` sits one level above both `src/` (tsx) and `dist/` (the bin).
178
- */
179
- const PACKAGE_SPEC = (() => {
180
- const pkg = createRequire(import.meta.url)("../package.json");
181
- return `${pkg.name}@${pkg.version}`;
182
- })();
183
172
  export const server = new McpServer({
184
173
  name: "danx-dashboard-mcp",
185
174
  version: "0.1.0",
@@ -275,19 +264,19 @@ const boardField = {
275
264
  .string()
276
265
  .min(1)
277
266
  .optional()
278
- .describe("Target another board by its qualified id `<repo>:<slug>` (e.g. `platform:the-supply-operations-hub`); omit to use this dispatch's board. Unknown board → 404."),
267
+ .describe("Target another board by its qualified id `<repo>:<slug>`; omit for this dispatch's board. Unknown → 404."),
279
268
  };
280
269
  // The three prose fields of a card, each with ONE job. Shared by issue_create
281
270
  // (root + phase children) and issue_edit so the guidance an agent reads is
282
271
  // identical wherever it writes the field.
283
- const TITLE_DESCRIBE = 'Short, specific label that names the domain, so a reader recognises the card without opening it (e.g. "Guest checkout rejects carts holding a gift card"). Never a generic phrase like "2 real decisions needed", "Fix bug" or "Follow-up".';
284
- const SUMMARY_DESCRIBE = "1–3 plain-language sentences — no markdown, no jargon — for someone who has never seen this codebase: what the card is about and why it matters. Always shown, never collapsed. It must stand on its own: not a second title, not a teaser for the description.";
272
+ const TITLE_DESCRIBE = 'Short, specific label naming the domain, so a reader recognises the card unopened (e.g. "Guest checkout rejects carts holding a gift card"). Never generic ("2 real decisions needed", "Fix bug", "Follow-up").';
273
+ const SUMMARY_DESCRIBE = "1–3 plain-language sentences, no markdown/jargon, for someone new to this codebase: what the card is and why it matters. Always shown, never collapsed — not a second title, not a teaser for the description.";
285
274
  const DESCRIPTION_DESCRIBE = 'The full body ("Context"): evidence, examples, technical detail; markdown, collapsed by default. A question for the operator and its options go in issue_problem, not here.';
286
275
  // ---------------- issue_list ----------------
287
276
  server.tool("issue_list",
288
277
  // DX-2735: trimmed to pay for the problem tools inside the work-profile
289
278
  // injected-surface budget — same facts, no repeated prose.
290
- "List cards via GET /api/issues on this dispatch's board, or `board` (`<repo>:<slug>`; unknown → 404). `filter`: type, parent_id, dispatchable_derived, status_derived[], self_dispatchable_derived, assigned_agent, include_closed, include_deleted, q (free text over id+title+description). THE DEFAULT ROW IS MINIMAL — scalars only (id, type, title, status, parent_id, priority, created_at, updated_at, assigned_agent, content_hash), no joins; opt into heavy data with `fields` groups: description (+ summary), problems (open_problem_count), ac, comments, retro, dependencies, triage, requires_human, assignment, quality_gates, children, effort. `sort`: [{column, order}] over id|priority|repo_name|title|type|status_derived|triage_ice_total|created_at|updated_at; default priority desc, repo_name asc, numeric-id tiebreaker. `limit`/`offset` page (uncapped by default). issue_get reads one card in full.", {
279
+ "List cards via GET /api/issues. Board-scoped; see `board`. `filter`: type, parent_id, dispatchable_derived, status_derived[], self_dispatchable_derived, assigned_agent, include_closed, include_deleted, q (free text over id+title+description). THE DEFAULT ROW IS MINIMAL — scalars only (id, type, title, status, parent_id, priority, created_at, updated_at, assigned_agent, content_hash), no joins; opt into heavy data with `fields` groups: description (+ summary), problems (open_problem_count), ac, comments, retro, dependencies, triage, requires_human, assignment, quality_gates, children, effort. `sort`: [{column, order}] over id|priority|repo_name|title|type|status_derived|triage_ice_total|created_at|updated_at; default priority desc, repo_name asc, numeric-id tiebreaker. `limit`/`offset` page (uncapped by default). issue_get reads one card in full.", {
291
280
  filter: z
292
281
  .object({
293
282
  q: z.string().optional(),
@@ -324,7 +313,7 @@ server.tool("issue_get",
324
313
  ...boardField,
325
314
  }, async (args) => jsonResult(await issueGet(client, args)));
326
315
  // ---------------- issue_create ----------------
327
- server.tool("issue_create", 'Create a card via POST /api/issues on this dispatch\'s board, or another via `board` (`<repo>:<slug>`; unknown → 404). type=Epic REQUIRES non-empty phase_children[] (epic and phases inserted in one transaction; children get the epic as parent); other types refuse phase_children[] (400). Status starts at Review. `list_id` places the card straight into a column — a board_lists id or the list\'s display NAME (case-insensitive, emoji-tolerant): a `ready`-type queue lands it in ToDo, a `completed` list in Done, with no follow-up transition. Not valid on Epic; unknown name/id → 400. `gate_decisions` is REQUIRED when the board has an OPTIONAL quality gate for the card\'s type: a missing one fails closed with 400 `{error, required_gate_decisions:[...]}` naming each gate — retry with one `{gate, enabled, note}` per listed gate. ALWAYS pass `triage_enabled` explicitly on the root card and every phase child: true only when it should enter automatic triage/dispatch without human review; absent → false.', {
316
+ server.tool("issue_create", 'Create a card via POST /api/issues. Board-scoped; see `board`. type=Epic REQUIRES non-empty phase_children[] (epic and phases inserted in one transaction; children get the epic as parent); other types refuse phase_children[] (400). Status starts at Review. `list_id` places the card straight into a column — a board_lists id or the list\'s display NAME (case-insensitive, emoji-tolerant): a `ready`-type queue lands it in ToDo, a `completed` list in Done, with no follow-up transition. Not valid on Epic; unknown name/id → 400. `gate_decisions` is REQUIRED when the board has an OPTIONAL quality gate for the card\'s type: a missing one fails closed with 400 `{error, required_gate_decisions:[...]}` naming each gate — retry with one `{gate, enabled, note}` per listed gate. ALWAYS pass `triage_enabled` explicitly on the root card and every phase child: true only when it should enter automatic triage/dispatch without human review; absent → false.', {
328
317
  type: z.enum(ISSUE_TYPES),
329
318
  title: z.string().min(1).describe(TITLE_DESCRIBE),
330
319
  summary: z.string().min(1).optional().describe(SUMMARY_DESCRIBE),
@@ -376,7 +365,7 @@ server.tool("issue_create", 'Create a card via POST /api/issues on this dispatch
376
365
  ...boardField,
377
366
  }, async (args) => jsonResult(await issueCreate(client, args, config.board)));
378
367
  // ---------------- issue_edit ----------------
379
- server.tool("issue_edit", 'Patch a card via PATCH /api/issues/:id/edit. ALLOWED keys: title, summary, description, ac, checklists, effort_level, parent_id, priority, list_id, triage_enabled, type, content_hash. Any other key (lifecycle, triage, dependencies, retro, requires_human, blocked) is refused 400 with offending_keys[] naming the right tool: issue_transition / issue_triage / issue_comment / issue_dependency / issue_requires_human / issue_retro. `type`: Story/Bug/Chore makes a card eligible for autonomous pickup; Task or a container (Epic/Feature) removes that eligibility — how a planning item becomes work. `priority` (a tier word or a number) is the ONLY way to set priority; a "Priority:" line in the description changes nothing. CHECKLISTS: each item has one status `incomplete|failing|passing|cancelled|deferred`; `deferred` (work done, a real-world/post-deploy check outstanding) REQUIRES `detail`, and a `📡`-prefixed item can never be `passing`. `ac` edits the default "Acceptance Criteria" checklist (items matched by check_item_id, else exact title); `checklists` REPLACES every named checklist with full status control (`{name, items:[{label, detail?, status}]}`). Send `ac` OR `checklists`, not both (400). `list_id` pins the card to a list by id or display NAME; its type must match the card\'s current derived status (400 otherwise — ready a card before pinning it to a `ready` queue); null clears the pin. `content_hash` (DX-2741) is the card\'s optimistic-concurrency token — REQUIRED (never optional/defaulted) whenever the edit touches `title` / `description` / `checklists` (NOT `ac`, which keeps its own check_item_id/title diffing): a missing hash on one of those fields 400s, a stale one 409s `stale_issue_content` carrying `currentHash` + `currentTitle` + `currentDescription`. Read it off `issue_get`/`issue_list`\'s `content_hash` scalar field (present even in the minimal response) immediately before editing; on a 409, re-`issue_get` and retry with the fresh hash — never blindly.', {
368
+ server.tool("issue_edit", 'Patch a card via PATCH /api/issues/:id/edit. ALLOWED keys: title, summary, description, ac, checklists, effort_level, parent_id, priority, list_id, triage_enabled, type, content_hash. Any other key (lifecycle, triage, dependencies, retro, requires_human, blocked) is refused 400 with offending_keys[] naming the right tool: issue_transition / issue_triage / issue_comment / issue_dependency / issue_requires_human / issue_retro. `type`: Story/Bug/Chore = eligible for autonomous pickup; Task or a container (Epic/Feature) removes that eligibility — how a planning item becomes work. `priority` (a tier word or a number) is the ONLY way to set priority; a "Priority:" line in the description does nothing. CHECKLISTS: each item has one status `incomplete|failing|passing|cancelled|deferred`; `deferred` (work done, a real-world/post-deploy check outstanding) REQUIRES `detail`, and a `📡`-prefixed item can never be `passing`. `ac` edits the default "Acceptance Criteria" checklist (items matched by check_item_id, else exact title); `checklists` REPLACES every named checklist with full status control (`{name, items:[{label, detail?, status}]}`). Send `ac` OR `checklists`, not both (400). `list_id` pins the card to a list by id or display NAME; its type must match the card\'s current derived status (400 otherwise — e.g. ready the card first before pinning it to a `ready`-type list); null clears the pin. `content_hash` (DX-2741) is the card\'s optimistic-concurrency token — REQUIRED whenever the edit touches `title` / `description` / `checklists` (NOT `ac`, which keeps its own check_item_id/title diffing): missing → 400, stale → 409 `stale_issue_content` carrying `currentHash` + `currentTitle` + `currentDescription`. Read it off `issue_get`/`issue_list`\'s `content_hash` scalar (present even minimal); on 409, re-`issue_get` and retry with the fresh hash — never blindly.', {
380
369
  id: z.string().min(1),
381
370
  title: z.string().min(1).optional().describe(TITLE_DESCRIBE),
382
371
  summary: z
@@ -476,7 +465,7 @@ const CHECKLIST_ITEM_INPUT = z.object({
476
465
  detail: z.string().optional(),
477
466
  status: z.enum(CHECKLIST_ITEM_STATUSES).optional(),
478
467
  });
479
- server.tool("issue_checklist", "Targeted checklist CUD via /api/issues/:id/checklists[/:cid[/items[/:iid]]] (DX-1362). Mutates ONE checklist or item WITHOUT the wholesale `issue_edit({checklists})` replace — use this for the common case (flip an item's status, add/rename a checklist, add/edit/remove an item); the wholesale path silently DROPS any checklist you omit and churns every item id (orphaning its Trello mirror), so prefer this for single-item changes. Action-dispatched: add_list (POST :id/checklists {name, items?}) — create a named checklist, optionally with initial items; update_list (PATCH :id/checklists/:cid {name}) — rename; remove_list (DELETE :id/checklists/:cid) — soft-delete the checklist (audit trail preserved); add_item (POST :id/checklists/:cid/items {label, detail?, status?}) — append an item (status defaults `incomplete`); update_item (PATCH :id/checklists/:cid/items/:iid {label?, detail?, status?}) — change ONLY the fields you pass, in place (the item keeps its id + Trello linkage; at least one field required); remove_item (DELETE :id/checklists/:cid/items/:iid) — soft-delete one item. Status: incomplete|failing|passing|cancelled|deferred (terminal = passing|cancelled|deferred; DX-2653 added `deferred` — the honest disposition for a criterion whose work is done but names a real-world/post-deploy check still outstanding, REQUIRES a non-empty `detail`). checklist_id is required for every action except add_list; item_id for update_item/remove_item. Each returns the {ok,status,body} envelope; unknown card/checklist/item → 404, invalid status → 400. ADDITIVE — the wholesale `issue_edit({checklists})` path stays for bulk authoring.", {
468
+ server.tool("issue_checklist", "Targeted checklist CUD via /api/issues/:id/checklists[/:cid[/items[/:iid]]] (DX-1362). Mutates ONE checklist/item without the wholesale `issue_edit({checklists})` replace, which DROPS any checklist you omit and churns every item id (orphaning its Trello mirror) — prefer this for single-item changes. Action-dispatched: add_list (POST :id/checklists {name, items?}) — create a named checklist, optionally with initial items; update_list (PATCH :id/checklists/:cid {name}) — rename; remove_list (DELETE :id/checklists/:cid) — soft-delete the checklist (audit trail preserved); add_item (POST :id/checklists/:cid/items {label, detail?, status?}) — append an item (status defaults `incomplete`); update_item (PATCH :id/checklists/:cid/items/:iid {label?, detail?, status?}) — change ONLY the fields you pass, in place (keeps id + Trello link; ≥1 field required); remove_item (DELETE :id/checklists/:cid/items/:iid) — soft-delete one item. Status: incomplete|failing|passing|cancelled|deferred (terminal = passing|cancelled|deferred; DX-2653 added `deferred` — the honest disposition for a criterion whose work is done but names a real-world/post-deploy check still outstanding, REQUIRES a non-empty `detail`). checklist_id is required for every action except add_list; item_id for update_item/remove_item. Each returns the {ok,status,body} envelope; unknown card/checklist/item → 404, invalid status → 400. ADDITIVE — `issue_edit({checklists})` still works for bulk authoring.", {
480
469
  id: z.string().min(1),
481
470
  action: z.enum([
482
471
  "add_list",
@@ -505,7 +494,7 @@ const SOLUTION_FIELDS = {
505
494
  con: z.string().optional(),
506
495
  recommended: z.boolean().optional(),
507
496
  };
508
- server.tool("issue_problem", "A card's PROBLEMS via /api/issues/:id/problems[/:pid]: each is one statement the operator must resolve (a question, or a flaw in the plan) with its own solutions and answers — one problem per question. OPEN = not yet answered; the card needs a human until none is open, and issue_requires_human set is refused 409 `no_open_problem` until one is. 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); edit :pid {base_hash, statement}; remove :pid {base_hash} (409 `last_open_problem` while requires_human is set). A stale base_hash → 409 `stale_problem` with currentHash + currentProblem: merge, then retry. No answer action — the operator answers in the dashboard.", {
497
+ 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 until none is open, and issue_requires_human set is refused 409 `no_open_problem` until one is. 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); edit :pid {base_hash, statement}; remove :pid {base_hash} (409 `last_open_problem` while requires_human is set). A stale base_hash → 409 `stale_problem` with currentHash + currentProblem: merge, then retry. No answer action — the operator answers in the dashboard.", {
509
498
  id: z.string().min(1),
510
499
  action: z.enum(["list", "add", "edit", "remove"]),
511
500
  problem_id: z.number().int().positive().optional().describe("edit/remove"),
@@ -547,7 +536,7 @@ server.tool("issue_requires_human", "Set/clear the requires_human gate via /api/
547
536
  ...boardField,
548
537
  }, async (args) => jsonResult(await issueRequiresHuman(client, args)));
549
538
  // ---------------- issue_quality_gate ----------------
550
- server.tool("issue_quality_gate", "Set one card's per-gate `required` flag via POST /api/issues/:id/quality-gates/:gate {required} — the only post-create way (issue_create takes gate_decisions; issue_edit refuses gate keys). PRE `plan-*` gates run before the work dispatch; POST `code-*` gates block complete. Unknown gate → 400. The board state per gate is tri-state: `required` always runs, `optional` runs WHEN this flag is true (optional is NOT off), `disabled` never runs. Optional `effort_level` overrides a `plan-*` gate's reviewer rung (null clears it). The write always succeeds and returns `{issue, applied: true, effective, reason}` — read `effective` (does the gate now run) and `reason` (why the board overrode your value), not just the 200. Pass `board` to target another board.", {
539
+ server.tool("issue_quality_gate", "Set one card's per-gate `required` flag via POST /api/issues/:id/quality-gates/:gate {required} — the only post-create way (issue_create takes gate_decisions; issue_edit refuses gate keys). PRE `plan-*` gates run before the work dispatch; POST `code-*` gates block complete. Unknown gate → 400. The board state per gate is tri-state: `required` always runs, `optional` runs WHEN this flag is true (optional is NOT off), `disabled` never runs. Optional `effort_level` overrides a `plan-*` gate's reviewer rung (null clears it). The write always succeeds and returns `{issue, applied: true, effective, reason}` — read `effective` (does the gate now run) and `reason` (why the board overrode your value), not just the 200. Board-scoped; see `board`.", {
551
540
  id: z.string().min(1),
552
541
  gate: z.enum([
553
542
  "plan-dependency",
@@ -562,7 +551,7 @@ server.tool("issue_quality_gate", "Set one card's per-gate `required` flag via P
562
551
  ...boardField,
563
552
  }, async (args) => jsonResult(await issueQualityGate(client, args)));
564
553
  // ---------------- issue_quality_gate_verdict ----------------
565
- server.tool("issue_quality_gate_verdict", "Stamp an operator MANUAL quality-gate VERDICT via PATCH /api/issues/:id/quality-gates/:gate {status, message} — the same write the dashboard Gates-tab Pass / Fail / Revert controls perform (DX-1373). SIBLING of `issue_quality_gate`, not a replacement: that one flips the per-card `required` FLAG (does this gate run at all), THIS one records the VERDICT (did it pass) — the server exposes them as POST vs PATCH on the same resource and neither substitutes for the other. **Use this to close out a card you picked up with `issue_transition pickup {manual:true}`** (DX-946 operator-session self-pickup): `issue_transition complete` REFUSES 409 (`failed_gate: \"quality_gate_post\"`, `failed_post_gates[]`) while any required POST gate (`code-quality` / `code-test-quality` / `code-architecture`) is not `pass`, so without a verdict a manually-claimed card can never reach Done — it strands In Progress and its `conflict_on` / `waiting_on` edges then stall OTHER cards' dispatch. `status`: `pass` | `fail` | `pending` (revert a prior verdict, clears the message). `message` is the accountability record for the override and is REQUIRED at >= 20 characters for `pass`/`fail` (shorter → 400); it is ignored for `pending`. Record the REAL reviewer finding here, not a rubber stamp — this is a human-attributed override, stamped with the operator actor, and it is what a later reader sees instead of a reviewer dispatch. A manual verdict is a PURE row write: unlike the worker's in-dispatch gate route it fires NO side effects — a manual `fail` never blocks the card and a manual `pass` never releases a dispatch. Unknown gate → 400; status outside the three values → 400; unknown card → 404. Board-scoped; pass `board` (`<repo>:<slug>`) to target another board.", {
554
+ server.tool("issue_quality_gate_verdict", "Stamp an operator MANUAL quality-gate VERDICT via PATCH /api/issues/:id/quality-gates/:gate {status, message} — the same write the dashboard Gates-tab Pass / Fail / Revert controls perform (DX-1373). SIBLING of `issue_quality_gate`, not a replacement: that one flips the per-card `required` FLAG (does this gate run at all), THIS one records the VERDICT (did it pass) — POST vs PATCH on the same resource, neither substitutes for the other. **Use this to close out a card you picked up with `issue_transition pickup {manual:true}`** (DX-946 operator-session self-pickup): `issue_transition complete` REFUSES 409 (`failed_gate: \"quality_gate_post\"`, `failed_post_gates[]`) while any required POST gate (`code-quality` / `code-test-quality` / `code-architecture`) is not `pass`, so without a verdict a manually-claimed card can never reach Done — it strands In Progress and its `conflict_on` / `waiting_on` edges then stall OTHER cards' dispatch. `status`: `pass` | `fail` | `pending` (revert a prior verdict, clears the message). `message` is the accountability record for the override, REQUIRED at >= 20 characters for `pass`/`fail` (shorter → 400), ignored for `pending`. Record the REAL reviewer finding, not a rubber stamp — a human-attributed override, stamped with the operator actor, standing in for a reviewer dispatch. A manual verdict is a PURE row write: no side effects — a manual `fail` never blocks the card and a manual `pass` never releases a dispatch. Unknown gate → 400; bad status → 400; unknown card → 404. Board-scoped; see `board`.", {
566
555
  id: z.string().min(1),
567
556
  gate: z.enum([
568
557
  "plan-dependency",
@@ -577,7 +566,7 @@ server.tool("issue_quality_gate_verdict", "Stamp an operator MANUAL quality-gate
577
566
  ...boardField,
578
567
  }, async (args) => jsonResult(await issueQualityGateVerdict(client, args)));
579
568
  // ---------------- issue_retro ----------------
580
- server.tool("issue_retro", "Replace the retro block via PUT /api/issues/:id/retro. Body: {good, bad, action_item_ids[], commits[], tests[]}. REFUSES 409 unless the card is terminal (completed_at OR cancelled_at) — retro ships when work concludes. Replace semantics: good/bad upsert; action_item_ids[] + commits[] + tests[] soft-delete prior live rows and insert with fresh ordinals. action_item_ids[] entries MUST match <PREFIX>-N. commits[] entries take {sha, subject?}. tests[] (DX-1646) is REQUIRED (empty array allowed — the \"ran no tests\" case): one row per test GROUP that ran (a whole suite/class — name the group, do NOT list individual unit tests) or per individual e2e test (kind:'e2e', listed explicitly since they are few + expensive). Each row: {name, kind:'group'|'e2e', num_tests, num_passing_tests, duration_ms} required; num_assertions + num_passing_assertions NULLABLE (vitest surfaces no assertion totals — pass null or omit).", {
569
+ server.tool("issue_retro", "Replace the retro block via PUT /api/issues/:id/retro. Body: {good, bad, action_item_ids[], commits[], tests[]}. REFUSES 409 unless the card is terminal (completed_at OR cancelled_at) — retro ships when work concludes. Replace semantics: good/bad upsert; action_item_ids[] + commits[] + tests[] soft-delete prior live rows and insert with fresh ordinals. action_item_ids[] entries MUST match <PREFIX>-N. commits[] entries take {sha, subject?}. tests[] (DX-1646) is REQUIRED (empty array allowed — the \"ran no tests\" case): one row per test GROUP that ran (a whole suite/class — name the group, do NOT list individual unit tests) or per individual e2e test (kind:'e2e', few + expensive so listed explicitly). Each row: {name, kind:'group'|'e2e', num_tests, num_passing_tests, duration_ms} required; num_assertions + num_passing_assertions NULLABLE (vitest has no assertion totals — pass null or omit).", {
581
570
  id: z.string().min(1),
582
571
  good: z.string(),
583
572
  bad: z.string(),
@@ -607,7 +596,7 @@ server.tool("issue_retro", "Replace the retro block via PUT /api/issues/:id/retr
607
596
  // route's MAX_DECODED_BYTES (src/issues/write/attachments.ts). This package is
608
597
  // a separate published artifact and cannot import that constant, so the number
609
598
  // is restated here as prose — keep the two in sync if the backend ceiling moves.
610
- 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; defaults to the dispatch's board. Pass `board` (a qualified id `<repo>:<slug>`) to attach on another board (unknown board → 404). 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.", {
599
+ 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.", {
611
600
  id: z.string().min(1),
612
601
  file_path: z
613
602
  .string()
@@ -616,11 +605,11 @@ server.tool("issue_attach", "Attach a LOCAL file to an issue card via POST /api/
616
605
  ...boardField,
617
606
  }, async (args) => jsonResult(await issueAttach(client, args)));
618
607
  // ---------------- repo_knowledge_get ----------------
619
- server.tool("repo_knowledge_get", "Fetch the board's working-knowledge markdown doc via GET /api/repo-knowledge (DX-1128, Story 2). Board-scoped; defaults to the dispatch's board. Pass `board` (a qualified id `<repo>:<slug>`) to read another board's doc. Returns `{ok, status, body: {content, contentHash, updatedAt, updatedBy, boardId}}` — an unset doc reads as the empty view (`content: \"\"`, `contentHash: \"\"`), NOT a 404. Ground exploratory answers in `content`; before `repo_knowledge_set`, ALWAYS `repo_knowledge_get` immediately first and pass its `contentHash` back as `base_hash` — the server's optimistic-concurrency guard rejects a stale write.", {
608
+ server.tool("repo_knowledge_get", "Fetch the board's working-knowledge markdown doc via GET /api/repo-knowledge (DX-1128, Story 2). Board-scoped; see `board`. Returns `{ok, status, body: {content, contentHash, updatedAt, updatedBy, boardId}}` — an unset doc reads as the empty view (`content: \"\"`, `contentHash: \"\"`), NOT a 404. Ground exploratory answers in `content`; before `repo_knowledge_set`, ALWAYS `repo_knowledge_get` immediately first and pass its `contentHash` back as `base_hash` — the server's optimistic-concurrency guard rejects a stale write.", {
620
609
  ...boardField,
621
610
  }, async (args) => jsonResult(await repoKnowledgeGet(client, args)));
622
611
  // ---------------- repo_knowledge_set ----------------
623
- server.tool("repo_knowledge_set", 'Write the board\'s working-knowledge markdown doc via PUT /api/repo-knowledge (DX-1128, Story 2). Board-scoped; defaults to the dispatch\'s board. `base_hash` MUST be the `contentHash` from the immediately-prior `repo_knowledge_get` call ("" for the true first write, when the board has no doc yet) — the server compares it against the CURRENT hash and, on mismatch, fails loud with `{ok: false, body: {error: "stale_repo_knowledge", currentHash}}` rather than silently overwriting a concurrent write. On that refusal: re-`repo_knowledge_get`, re-merge your insight into the fresh content, and retry `repo_knowledge_set` with the new hash. On success, persists to the DB, publishes `repo-knowledge:updated` over SSE (live in the dashboard editor), and returns the new view.', {
612
+ server.tool("repo_knowledge_set", 'Write the board\'s working-knowledge markdown doc via PUT /api/repo-knowledge (DX-1128, Story 2). Board-scoped; see `board`. `base_hash` MUST be the `contentHash` from the immediately-prior `repo_knowledge_get` call ("" for the true first write, when the board has no doc yet) — the server compares it against the CURRENT hash and, on mismatch, fails loud with `{ok: false, body: {error: "stale_repo_knowledge", currentHash}}` rather than silently overwriting a concurrent write. On that refusal: re-`repo_knowledge_get`, re-merge your insight into the fresh content, and retry `repo_knowledge_set` with the new hash. On success, persists to the DB, publishes `repo-knowledge:updated` over SSE (live in the dashboard editor), and returns the new view.', {
624
613
  content: z.string(),
625
614
  base_hash: z
626
615
  .string()
@@ -629,11 +618,11 @@ server.tool("repo_knowledge_set", 'Write the board\'s working-knowledge markdown
629
618
  ...boardField,
630
619
  }, async (args) => jsonResult(await repoKnowledgeSet(client, args)));
631
620
  // ---------------- brief_list ----------------
632
- server.tool("brief_list", "List the board's named Brief pages via GET /api/brief (DX-2083 / DX-2484). Board-scoped; defaults to the dispatch's board. Pass `board` (a qualified id `<repo>:<slug>`) to list another board's pages. Returns `{boardId, pages: [{slug, title, contentHash, sortOrder, updatedAt, updatedBy}]}` — metadata only, no page content (use `brief_get_page` for that). This is the list+page-shaped sibling of `repo_knowledge_get`/`repo_knowledge_set` (one board-level doc) — Brief pages are MANY named pages per board (the Goals / Architecture / Rules / Caveats tabs), keyed by `(board, slug)`. The reserved `index` slug always exists — every board carries exactly one.", {
621
+ server.tool("brief_list", "List the board's named Brief pages via GET /api/brief (DX-2083 / DX-2484). Board-scoped; see `board`. Returns `{boardId, pages: [{slug, title, contentHash, sortOrder, updatedAt, updatedBy}]}` — metadata only, no page content (use `brief_get_page` for that). This is the list+page-shaped sibling of `repo_knowledge_get`/`repo_knowledge_set` (one board-level doc) — Brief pages are MANY named pages per board (the Goals / Architecture / Rules / Caveats tabs), keyed by `(board, slug)`. The reserved `index` slug always exists — every board carries exactly one.", {
633
622
  ...boardField,
634
623
  }, async (args) => jsonResult(await briefList(client, args)));
635
624
  // ---------------- brief_get_page ----------------
636
- server.tool("brief_get_page", 'Fetch one Brief page by slug via GET /api/brief/page?slug=<slug> (DX-2083 / DX-2484). Board-scoped; defaults to the dispatch\'s board. Returns `{boardId, slug, title, content, contentHash, sortOrder, updatedAt, updatedBy}` — a missing/not-yet-created page reads as the empty view (`content: ""`, `contentHash: ""`), NOT a 404, matching `repo_knowledge_get`\'s convention. Before `brief_set_page`, ALWAYS `brief_get_page` immediately first and pass its `contentHash` back as `base_hash` — the server\'s optimistic-concurrency guard rejects a stale write.', {
625
+ server.tool("brief_get_page", 'Fetch one Brief page by slug via GET /api/brief/page?slug=<slug> (DX-2083 / DX-2484). Board-scoped; see `board`. Returns `{boardId, slug, title, content, contentHash, sortOrder, updatedAt, updatedBy}` — a missing/not-yet-created page reads as the empty view (`content: ""`, `contentHash: ""`), NOT a 404, matching `repo_knowledge_get`\'s convention. Before `brief_set_page`, ALWAYS `brief_get_page` immediately first and pass its `contentHash` back as `base_hash` — the server\'s optimistic-concurrency guard rejects a stale write.', {
637
626
  slug: z
638
627
  .string()
639
628
  .min(1)
@@ -641,7 +630,7 @@ server.tool("brief_get_page", 'Fetch one Brief page by slug via GET /api/brief/p
641
630
  ...boardField,
642
631
  }, async (args) => jsonResult(await briefGetPage(client, args)));
643
632
  // ---------------- brief_set_page ----------------
644
- server.tool("brief_set_page", 'Write one Brief page via PUT /api/brief/page?slug=<slug> (DX-2083 / DX-2484). Board-scoped; defaults to the dispatch\'s board. Body: `{content, title?, sortOrder?, base_hash?}` — mirrors `repo_knowledge_set`\'s optimistic-concurrency shape but targets one named page instead of the board\'s single working-knowledge doc. `base_hash` MUST be the `contentHash` from the immediately-prior `brief_get_page` call ("" for a true first write, when the page doesn\'t exist yet) — the server compares it against the CURRENT hash and, on mismatch, fails loud with `{ok: false, body: {error: "stale_brief_page", currentHash}}` rather than silently overwriting a concurrent write — re-get, re-merge, and retry on that refusal, never retry blindly or overwrite. On success, persists to the DB, publishes `brief:updated` over SSE, and returns the new view. NO delete tool is exposed on this surface — the reserved `index` slug can never be deleted through the tool surface, matching the route\'s own refusal; deleting a non-index page is dashboard-UI-only for now.', {
633
+ server.tool("brief_set_page", 'Write one Brief page via PUT /api/brief/page?slug=<slug> (DX-2083 / DX-2484). Board-scoped; see `board`. Body: `{content, title?, sortOrder?, base_hash?}` — mirrors `repo_knowledge_set`\'s optimistic-concurrency shape but targets one named page instead of the board\'s single working-knowledge doc. `base_hash` MUST be the `contentHash` from the immediately-prior `brief_get_page` call ("" for a true first write, when the page doesn\'t exist yet) — the server compares it against the CURRENT hash and, on mismatch, fails loud with `{ok: false, body: {error: "stale_brief_page", currentHash}}` rather than silently overwriting a concurrent write — re-get, re-merge, and retry on that refusal, never retry blindly or overwrite. On success, persists to the DB, publishes `brief:updated` over SSE, and returns the new view. NO delete tool is exposed on this surface — the reserved `index` slug can never be deleted through the tool surface, matching the route\'s own refusal; deleting a non-index page is dashboard-UI-only for now.', {
645
634
  slug: z
646
635
  .string()
647
636
  .min(1)
@@ -665,8 +654,8 @@ server.tool("brief_set_page", 'Write one Brief page via PUT /api/brief/page?slug
665
654
  // plan id — and it can only ever bind the caller's own session. `plan_create`
666
655
  // also takes no plan id, but for a different reason: it MAKES a plan rather
667
656
  // than acting on one, so there is no existing plan for an id to name yet.
668
- server.tool("plan_list", "List every plan via GET /api/plans (DX-2683), and learn which plan THIS session is connected to. Plans are GLOBAL, not board-scoped: a plan is a named, dated set of cards an operator assembled by hand, and its cards may come from any repository. Returns `{ok, status, body: {plans: [{id, name, createdAt, cardCount, boards}], session, sessionListenerAttached}}`. `session` is your own registration — `{sessionId, title, planId, planName, firstSeenAt, lastActiveAt}` — or `null` if this process is not running inside a Claude Code session. A `planId` of null means you are connected to no plan: read any plan with `plan_get`, then `plan_connect` to the one you are working on (or ask the operator to connect you from the Plans list). `sessionListenerAttached` says whether your event listener is running: `false` while connected to a plan means you will NOT hear about its cards — call `plan_connect` again and arm the Monitor it returns. NOTE this is NOT the board Brief (`brief_list`), which is a different feature entirely.", {}, async () => jsonResult(await planList(client)));
669
- server.tool("plan_get", "Read a plan via GET /api/plans (DX-2683). Pass `plan_id` to read ANY plan (browsing another plan is useful and changes nothing); OMIT it to read the plan this session is connected to. Omitting it while connected to no plan fails loud with `{error: \"session_not_connected\"}` — connect first. A BARE call (no `fields`) returns ONLY the plan's cheap scalars: `{plan, boards, cardCount, bucketCounts, session, sessionListenerAttached, available_field_groups}` — no member cards, no goals/rules/caveats, no architecture body. Pass `fields` to opt into the rest, one call at a time: `cards` (member cards, PAGED: `cards_offset` (default 0) and `cards_limit` (1.." + LIST_PAGE_MAX_LIMIT + ", default " + PLAN_GET_CARDS_DEFAULT_LIMIT + ") pick the page, in card-reference order (board prefix, then card number — stable while cards are edited, so pages never repeat or skip a card unless the plan's membership changes between reads), and the response carries `cards_total` and `cards_offset` — page with cards_offset while cards_offset + cards.length < cards_total; either paging arg without `fields: [\"cards\"]` is a 400), `records` (every goal+rule+caveat, keyed by kind) or `records:goal` / `records:rule` / `records:caveat` (just that one kind — cheaper than the full union), `architecture` (`{sections: [{id, planId, contentHash, title, content, sortOrder, createdAt, updatedAt}]}`), `sessions` (every session connected to the plan). `session`/`sessionListenerAttached` (your own connection state) and `available_field_groups` ride EVERY response, gated or not. `sessionListenerAttached: false` while connected means your event listener is not running; call `plan_connect` again and arm the Monitor it returns. ALWAYS `plan_get`/`plan_get_architecture_section` immediately before `plan_update_architecture_section`/`plan_delete_architecture_section` and pass the returned `contentHash` back as `base_hash`.", {
657
+ server.tool("plan_list", "List every plan via GET /api/plans (DX-2683), and learn which plan THIS session is connected to. Plans are GLOBAL, not board-scoped: a plan is a named, dated set of cards an operator assembled by hand, and its cards may come from any repository. Returns `{ok, status, body: {plans: [{id, name, createdAt, cardCount, boards}], session, sessionListenerAttached}}`. `session` is your own registration — `{sessionId, title, planId, planName, firstSeenAt, lastActiveAt}` — or `null` if this process is not running inside a Claude Code session. A `planId` of null means you are connected to no plan: read any plan with `plan_get`, then `plan_connect` to the one you are working on (or ask the operator to connect you from the Plans list). `sessionListenerAttached` says whether your session's event stream is attached (the danxbot plugin's plan event bridge holds it). It is `false` for a few seconds right after `plan_connect` while the bridge starts; still `false` after that while connected means its card events are NOT reaching you — tell the operator. There is nothing to arm. NOTE this is NOT the board Brief (`brief_list`), which is a different feature entirely.", {}, async () => jsonResult(await planList(client)));
658
+ server.tool("plan_get", "Read a plan via GET /api/plans (DX-2683). Pass `plan_id` to read ANY plan; OMIT to read the plan this session is connected to — omitting while connected to none fails loud `{error: \"session_not_connected\"}`, connect first. A BARE call (no `fields`) returns ONLY the plan's cheap scalars: `{plan, boards, cardCount, bucketCounts, session, sessionListenerAttached, available_field_groups}` — no cards, records, or architecture body. Pass `fields` to opt into: `cards` (member cards, PAGED: `cards_offset` (default 0) and `cards_limit` (1.." + LIST_PAGE_MAX_LIMIT + ", default " + PLAN_GET_CARDS_DEFAULT_LIMIT + ") pick the page, in stable card-reference order (board prefix, then card number — pages never repeat/skip unless membership changes between reads); response carries `cards_total`/`cards_offset` — page while cards_offset + cards.length < cards_total; either paging arg without `fields: [\"cards\"]` is a 400), `records` (every goal+rule+caveat, keyed by kind) or `records:goal` / `records:rule` / `records:caveat` (one kind, cheaper), `architecture` (`{sections: [{id, planId, contentHash, title, content, sortOrder, createdAt, updatedAt}]}`), `sessions` (every session connected to the plan). `session`/`sessionListenerAttached` and `available_field_groups` ride every response regardless. `sessionListenerAttached` is `false` for a few seconds right after `plan_connect` while the plugin's event bridge starts; still `false` after that while connected means the plan's card events are not reaching you — tell the operator. ALWAYS `plan_get`/`plan_get_architecture_section` immediately before `plan_update_architecture_section`/`plan_delete_architecture_section` and pass the returned `contentHash` back as `base_hash`.", {
670
659
  plan_id: z
671
660
  .number()
672
661
  .int()
@@ -696,9 +685,9 @@ server.tool("plan_create", "Create a new, empty plan via POST /api/plans (DX-253
696
685
  }, async (args) => jsonResult(await planCreate(client, args)));
697
686
  server.tool("plan_connect",
698
687
  // DX-2735: trimmed with the problem tools to stay inside the work-profile budget.
699
- "Connect THIS session to a plan via POST /api/plan-sessions/me/plan — the same binding the operator's Connect action writes. A session is on AT MOST ONE plan: connecting elsewhere MOVES it, and the reply says which plan it left: `{session, movedFrom: {id, name} | null}` (null = no plan, or already this one). It binds only your OWN session, resolved from the session id this server forwards. Afterwards every plan WRITE tool acts on this plan and takes no plan id. THE REPLY ALSO CARRIES `listener: {command, persistent: true, instruction}` — arm it IMMEDIATELY with the Monitor tool (`persistent: true`): every comment, answer, requires_human change and block/unblock on this plan's cards then arrives as a line like `[DX-8 \"Title\" repo:board] newms87 answered \"<problem statement>\": chose \"Pause E2E\" — note: \"…\"`. Never poll for these. The command holds a narrow stream ticket, not a credential; calling plan_connect again (same plan is fine) issues a new one and ends the old listener — how you re-arm after a restart or a give-up. If no ticket can be issued the call fails `listener_not_armed`, though the connect happened.", {
688
+ "Connect THIS session to a plan via POST /api/plan-sessions/me/plan — the same binding the operator's Connect action writes. A session is on AT MOST ONE plan: connecting elsewhere MOVES it, and the reply says which plan it left: `{session, movedFrom: {id, name} | null}` (null = no plan, or already this one). It binds only your OWN session, resolved from the session id this server forwards. Afterwards every plan WRITE tool acts on this plan and takes no plan id. Every comment, answer, requires_human change and block/unblock on this plan's cards then reaches the session on its own, relayed by the danxbot plugin's plan event bridge as a line like `[DX-8 \"Title\" repo:board] newms87 answered \"<problem statement>\": chose \"Pause E2E\"`. Nothing to arm; never poll for these.", {
700
689
  plan_id: z.number().int().positive().describe("The plan id, from `plan_list`."),
701
- }, async (args) => jsonResult(await planConnect(client, args, { baseUrl: config.baseUrl, packageSpec: PACKAGE_SPEC })));
690
+ }, async (args) => jsonResult(await planConnect(client, args)));
702
691
  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.", {
703
692
  kind: z.enum(["goal", "rule", "caveat"]).describe("goal = outcome, rule = constraint, caveat = architecture trade-off."),
704
693
  body: z.string().min(1).describe("One plain statement, at most 250 characters. Details go in `context`."),
@@ -769,21 +758,21 @@ async function main() {
769
758
  // symlink-aware (DX-1647) so it holds under the symlinked `npx` bin the worker
770
759
  // spawns, not just a direct `node dist/index.js`.
771
760
  //
772
- // `listen` is the one subcommand: the session event listener `plan_connect`
773
- // tells the agent to arm as a Monitor. It never boots the MCP server and reads
774
- // none of the MCP env — its whole configuration is its two flags. Any OTHER
775
- // argument is refused rather than ignored, so a typo cannot silently start an
776
- // MCP server on stdio where a listener was meant to run.
761
+ // `bridge` is the one subcommand: the session event stream client the danxbot
762
+ // plugin's plan event bridge runs. It never boots the MCP server; its whole
763
+ // configuration is its environment (dashboard URL, credential, session id) plus
764
+ // `--resume-ids`. Any OTHER argument is refused rather than ignored, so a typo
765
+ // cannot silently start an MCP server on stdio where the bridge was meant to run.
777
766
  if (isEntrypointModule(import.meta.url, process.argv[1])) {
778
767
  const [subcommand, ...rest] = process.argv.slice(2);
779
- if (subcommand === LISTEN_SUBCOMMAND) {
780
- runListenCommand(rest).then((code) => process.exit(code), (err) => {
781
- console.error(`[danx-dashboard-mcp] listen fatal: ${err.message}`);
768
+ if (subcommand === BRIDGE_SUBCOMMAND) {
769
+ runBridgeCommand(rest).then((code) => process.exit(code), (err) => {
770
+ console.error(`[danx-dashboard-mcp] bridge fatal: ${err.message}`);
782
771
  process.exit(1);
783
772
  });
784
773
  }
785
774
  else if (subcommand !== undefined) {
786
- console.error(`[danx-dashboard-mcp] unknown subcommand "${subcommand}" (the only one is "${LISTEN_SUBCOMMAND}")`);
775
+ console.error(`[danx-dashboard-mcp] unknown subcommand "${subcommand}" (the only one is "${BRIDGE_SUBCOMMAND}")`);
787
776
  process.exit(2);
788
777
  }
789
778
  else {
package/dist/listen.js CHANGED
@@ -1,69 +1,47 @@
1
1
  /**
2
- * `danx-dashboard-mcp listen --stream <url> --ticket <ticket> --lease-ms <ms>` —
3
- * a working session's event listener.
2
+ * The session event STREAM READER — holds ONE listener ticket's connection to the
3
+ * dashboard's session event stream and emits one record per event.
4
4
  *
5
- * WHY IT EXISTS. A Claude Code session cannot be interrupted from outside, but
6
- * its Monitor tool runs a background command and turns every line that command
7
- * prints into a notification that wakes the session. This command is that
8
- * background process: it holds the dashboard's session event stream open and
9
- * prints ONE line per event on the plan the session is connected to — a comment,
10
- * an answer, a `requires_human` change, a block — so the operator's answer
11
- * reaches the agent within seconds, with no polling anywhere.
5
+ * WHO USES IT. Only `bridge.ts` (`danx-dashboard-mcp bridge`), which mints the
6
+ * ticket, runs this reader in-process, and re-mints when a ticket's lease runs out.
7
+ * The ticket therefore never leaves the bridge process: no command line, no
8
+ * environment variable.
12
9
  *
13
- * THE OUTPUT CONTRACT, BECAUSE EVERY LINE WAKES THE AGENT:
14
- * - exactly one line per event, written the moment it arrives;
15
- * - nothing for keep-alives, the connect marker, or a successful reconnect;
16
- * - one line for an event it cannot read (never silence);
17
- * - one final line, and exit, only when it gives up or the dashboard ends it.
10
+ * THE OUTPUT CONTRACT (`ListenOutput`), because every event record reaches an agent:
11
+ * - `{type:"event", id, text}` — exactly one per event, the moment it arrives.
12
+ * `text` is what the session reads; an event it cannot read still produces one
13
+ * (`could not read event …`), never silence. `id` is `null` only for a frame
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).
18
+ * - nothing for keep-alives, the connect marker, or a successful reconnect.
18
19
  *
19
- * RECONNECT. The stream drops whenever the dashboard restarts, and can also die
20
- * silently (sleep, NAT, proxy). A read-idle timeout of three missed keep-alives
21
- * turns silent death into a drop. Retries use capped exponential backoff and send
22
- * `Last-Event-ID`; the dashboard replays what was missed (with a small overlap)
23
- * and an id already printed is never printed twice. The backoff only resets once
24
- * a connection proves healthy — it delivered an event, or stayed up past one
25
- * keep-alive — so a dashboard that admits and immediately drops is not hammered.
26
- * The listener gives up once it has gone `--lease-ms` without a healthy
27
- * connection (past that the ticket cannot be admitted anyway), or at once when
28
- * the ticket is refused.
20
+ * RECONNECT AND RESUME. The stream drops whenever the dashboard restarts, and can
21
+ * also die silently (sleep, NAT, proxy). A read-idle timeout of three missed
22
+ * keep-alives turns silent death into a drop. Retries use capped exponential
23
+ * backoff and send `Last-Event-ID`; the dashboard replays what was missed, with a
24
+ * commit-order overlap that re-sends recently delivered ids, and an id already
25
+ * emitted is never emitted twice. `resumeIds` carries that memory across a ticket
26
+ * or process restart: the ids already delivered seed the duplicate guard, and the
27
+ * highest is the first `Last-Event-ID`. The backoff only resets once a connection
28
+ * proves healthy — it delivered an event, or stayed up past one keep-alive — so a
29
+ * dashboard that admits and immediately drops is not hammered.
29
30
  *
30
- * Everything server-specific — the stream URL and the lease — arrives as flags
31
- * from `plan_connect`, which read them off the dashboard's own ticket response.
32
31
  * Only the event shape is hand-copied from the dashboard's `IssueActivityEvent`
33
32
  * (`src/issues/db/issue-activity.ts`); this published package cannot import
34
33
  * danxbot source.
35
34
  */
36
35
  import { oneLine } from "./one-line.js";
37
- export const LISTEN_SUBCOMMAND = "listen";
38
36
  export const INITIAL_BACKOFF_MS = 1_000;
39
37
  export const MAX_BACKOFF_MS = 30_000;
40
38
  /** Three of the dashboard's 15 s keep-alives without a byte means the connection is dead. */
41
39
  export const READ_IDLE_TIMEOUT_MS = 45_000;
42
40
  /** A connection that stays up this long has proven the dashboard healthy. */
43
41
  export const HEALTHY_CONNECTION_MS = 20_000;
44
- /** How many printed event ids the duplicate guard remembers — well past the replay overlap. */
45
- const PRINTED_ID_MEMORY = 1_000;
42
+ /** How many delivered event ids the duplicate guard remembers — well past the replay overlap. */
43
+ export const DELIVERED_ID_MEMORY = 1_000;
46
44
  const LINE_PREFIX = "[danx-dashboard listen]";
47
- const REARM = "Call plan_connect again and arm the Monitor it returns to keep receiving this plan's events.";
48
- const USAGE = `usage: danx-dashboard-mcp ${LISTEN_SUBCOMMAND} --stream <stream-url> --ticket <ticket> --lease-ms <ms>`;
49
- /** Parse the three required flags; anything else is refused. */
50
- export function parseListenArgs(argv) {
51
- const values = new Map();
52
- for (let i = 0; i < argv.length; i += 2) {
53
- const flag = argv[i];
54
- const value = argv[i + 1];
55
- if (!["--stream", "--ticket", "--lease-ms"].includes(flag) || value === undefined || value === "") {
56
- throw new Error(USAGE);
57
- }
58
- values.set(flag, value);
59
- }
60
- const streamUrl = values.get("--stream");
61
- const ticket = values.get("--ticket");
62
- const leaseMs = Number(values.get("--lease-ms"));
63
- if (!streamUrl || !ticket || !Number.isSafeInteger(leaseMs) || leaseMs <= 0)
64
- throw new Error(USAGE);
65
- return { streamUrl, ticket, leaseMs };
66
- }
67
45
  function quoted(value) {
68
46
  return `"${value.text}${value.truncated ? "…" : ""}"`;
69
47
  }
@@ -143,11 +121,11 @@ function describe(event) {
143
121
  return `${event.actor} unblocked the card`;
144
122
  default:
145
123
  // A kind newer than this listener still produces a line: an event the
146
- // agent is never told about is exactly the failure this command prevents.
124
+ // agent is never told about is exactly the failure this reader prevents.
147
125
  return `${event.actor}: ${event.kind}`;
148
126
  }
149
127
  }
150
- /** The one notification line for an event. Always a single line; throws, naming the fault, on a malformed event. */
128
+ /** The one readable line for an event. Always a single line; throws, naming the fault, on a malformed event. */
151
129
  export function formatActivityLine(event) {
152
130
  const invalid = invalidEventReason(event);
153
131
  if (invalid !== null)
@@ -156,7 +134,7 @@ export function formatActivityLine(event) {
156
134
  }
157
135
  /**
158
136
  * Incremental SSE parser. Comment lines (keep-alives, the connect marker) and
159
- * field-less blocks produce no message, which is what keeps them off stdout.
137
+ * field-less blocks produce no message, which is what keeps them out of the output.
160
138
  */
161
139
  export class SseParser {
162
140
  buffer = "";
@@ -198,21 +176,27 @@ function parseBlock(block) {
198
176
  /** Statuses that describe the moment, not the ticket — retry them. */
199
177
  const RETRYABLE_CLIENT_STATUSES = new Set([408, 429]);
200
178
  /**
201
- * Run until the dashboard ends the stream or the listener gives up. Returns the
202
- * exit code: 0 when a newer listener took over, 1 when revoked, refused, or
203
- * given up.
179
+ * Run until the dashboard ends the stream or the reader gives up, always ending
180
+ * with exactly one `stopped` record. Returns the exit code: 0 when a newer
181
+ * listener took over, 1 otherwise.
204
182
  */
205
183
  export async function runListener(options, deps) {
206
- const printed = new Set();
184
+ const delivered = new Set();
207
185
  let lastEventId = null;
208
186
  let unhealthySince = null;
209
187
  let attempt = 0;
210
188
  const remember = (id) => {
211
- printed.add(id);
212
- if (printed.size > PRINTED_ID_MEMORY)
213
- printed.delete(printed.values().next().value);
189
+ delivered.add(id);
190
+ if (delivered.size > DELIVERED_ID_MEMORY)
191
+ delivered.delete(delivered.values().next().value);
214
192
  lastEventId = lastEventId === null ? id : Math.max(lastEventId, id);
215
193
  };
194
+ for (const id of options.resumeIds.slice(-DELIVERED_ID_MEMORY))
195
+ remember(id);
196
+ const stop = (reason, detail, code) => {
197
+ deps.write({ type: "stopped", reason, detail });
198
+ return code;
199
+ };
216
200
  const handle = (message) => {
217
201
  if (message.event === "end") {
218
202
  const reason = JSON.parse(message.data).reason;
@@ -220,8 +204,11 @@ export async function runListener(options, deps) {
220
204
  }
221
205
  if (message.event !== "activity")
222
206
  return null;
223
- const id = Number(message.id);
224
- if (Number.isSafeInteger(id) && printed.has(id))
207
+ // `Number(null)` is 0, so a frame with no id must never be read as event 0: that id
208
+ // would become a `Last-Event-ID` the dashboard refuses, ending the listener.
209
+ const id = message.id === null ? Number.NaN : Number(message.id);
210
+ const knownId = Number.isSafeInteger(id) && id > 0;
211
+ if (knownId && delivered.has(id))
225
212
  return null;
226
213
  // DX-2735: an event that is not JSON, or JSON of the wrong shape, is reported
227
214
  // from an explicit check naming the fault — never from a TypeError thrown
@@ -236,16 +223,14 @@ export async function runListener(options, deps) {
236
223
  catch (err) {
237
224
  invalid = `not JSON: ${err.message}`;
238
225
  }
239
- if (invalid === null) {
240
- deps.write(formatActivityLine(parsed));
241
- }
242
- else {
243
- deps.write(`${LINE_PREFIX} could not read event ${message.id ?? "(no id)"} (${invalid}): ` +
226
+ const text = invalid === null
227
+ ? formatActivityLine(parsed)
228
+ : `${LINE_PREFIX} could not read event ${message.id ?? "(no id)"} (${invalid}): ` +
244
229
  // DX-2735: oneLine caps by code point, so a bad event carrying an emoji
245
230
  // at the cut can never leave a lone surrogate in the line.
246
- oneLine(message.data, 300));
247
- }
248
- if (Number.isSafeInteger(id))
231
+ oneLine(message.data, 300);
232
+ deps.write({ type: "event", id: knownId ? id : null, text });
233
+ if (knownId)
249
234
  remember(id);
250
235
  return null;
251
236
  };
@@ -263,8 +248,8 @@ export async function runListener(options, deps) {
263
248
  if (lastEventId !== null)
264
249
  headers["Last-Event-ID"] = String(lastEventId);
265
250
  const openedAt = deps.now();
266
- let delivered = false;
267
- const healthy = () => delivered || deps.now() - openedAt >= HEALTHY_CONNECTION_MS;
251
+ let gotEvent = false;
252
+ const healthy = () => gotEvent || deps.now() - openedAt >= HEALTHY_CONNECTION_MS;
268
253
  try {
269
254
  armIdle();
270
255
  const response = await deps.fetch(options.streamUrl, { headers, signal: controller.signal });
@@ -284,7 +269,7 @@ export async function runListener(options, deps) {
284
269
  if (outcome !== null)
285
270
  return outcome;
286
271
  if (message.event === "activity")
287
- delivered = true;
272
+ gotEvent = true;
288
273
  }
289
274
  }
290
275
  return { kind: "dropped", healthy: healthy() };
@@ -301,16 +286,12 @@ export async function runListener(options, deps) {
301
286
  const outcome = await connectOnce();
302
287
  if (outcome.kind === "ended") {
303
288
  if (outcome.reason === "superseded" || outcome.reason === "replaced") {
304
- deps.write(`${LINE_PREFIX} stopped: another listener for this session took over (${outcome.reason}). ` +
305
- `If you did not just call plan_connect, something else did — ${REARM}`);
306
- return 0;
289
+ return stop(outcome.reason, "another listener for this session took over", 0);
307
290
  }
308
- deps.write(`${LINE_PREFIX} stopped: the dashboard ended this listener (${outcome.reason}). ${REARM}`);
309
- return 1;
291
+ return stop(outcome.reason, "the dashboard ended this listener", 1);
310
292
  }
311
293
  if (outcome.kind === "refused") {
312
- deps.write(`${LINE_PREFIX} gave up: the dashboard refused the listener ticket (${outcome.detail}). ${REARM}`);
313
- return 1;
294
+ return stop("refused", `the dashboard refused the listener ticket: ${outcome.detail}`, 1);
314
295
  }
315
296
  if (outcome.healthy) {
316
297
  unhealthySince = null;
@@ -319,31 +300,9 @@ export async function runListener(options, deps) {
319
300
  const now = deps.now();
320
301
  unhealthySince ??= now;
321
302
  if (now - unhealthySince >= options.leaseMs) {
322
- deps.write(`${LINE_PREFIX} gave up: no healthy connection to ${options.streamUrl} for ` +
323
- `${Math.round((now - unhealthySince) / 60_000)} minutes, past the ticket's lease. ${REARM}`);
324
- return 1;
303
+ return stop("lease_expired", `no healthy connection to ${options.streamUrl} for ${Math.round((now - unhealthySince) / 60_000)} minutes, past the ticket's lease`, 1);
325
304
  }
326
305
  await deps.sleep(Math.min(MAX_BACKOFF_MS, INITIAL_BACKOFF_MS * 2 ** attempt));
327
306
  attempt += 1;
328
307
  }
329
308
  }
330
- /** The bin's `listen` subcommand, wired to the real process. */
331
- export async function runListenCommand(argv) {
332
- let options;
333
- try {
334
- options = parseListenArgs(argv);
335
- }
336
- catch (err) {
337
- process.stderr.write(`${err.message}\n`);
338
- return 2;
339
- }
340
- return runListener(options, {
341
- fetch,
342
- write: (line) => {
343
- process.stdout.write(`${line}\n`);
344
- },
345
- sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
346
- now: Date.now,
347
- readIdleTimeoutMs: READ_IDLE_TIMEOUT_MS,
348
- });
349
- }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thehammer/danx-dashboard-mcp",
3
- "version": "0.1.64",
3
+ "version": "0.1.66",
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",