@thehammer/danx-dashboard-mcp 0.1.52 → 0.1.53

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
@@ -33,6 +33,20 @@ All exposed as `mcp__danx-dashboard__<name>` once wired through the workspace `m
33
33
  | `issue_requires_human` | `POST/DELETE /api/issues/:id/requires-human` | Set replaces step rows atomically; clear soft-deletes them. A successful set also returns `solutions_reminder: {solution_count, instruction}` |
34
34
  | `issue_retro` | `PUT /api/issues/:id/retro` | Requires terminal card; replace semantics |
35
35
 
36
+ ## `listen` — a working session's event listener
37
+
38
+ `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.
39
+
40
+ ```bash
41
+ npx -y @thehammer/danx-dashboard-mcp@<version> listen --stream <dashboard>/api/plan-sessions/stream --ticket <ticket> --lease-ms <ms>
42
+ ```
43
+
44
+ All three flags come from the dashboard's own ticket response; `plan_connect` assembles the command, so never build it by hand.
45
+
46
+ - **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.
47
+ - **Output.** Exactly one line per event on the connected plan's cards — `[DX-8 "Title" repo:board] newms87 answered: chose "Pause E2E" — note: "only this week"`, `… 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.
48
+ - **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.
49
+
36
50
  ## Build + test
37
51
 
38
52
  ```bash
package/dist/handlers.js CHANGED
@@ -721,6 +721,39 @@ export async function planGet(client, args = {}) {
721
721
  basePath: PLANS_BASE_PATH,
722
722
  });
723
723
  }
724
+ function readIssuedTicket(body) {
725
+ const b = body;
726
+ return b !== null &&
727
+ typeof b.ticket === "string" &&
728
+ b.ticket !== "" &&
729
+ typeof b.streamPath === "string" &&
730
+ typeof b.leaseMs === "number"
731
+ ? { ticket: b.ticket, streamPath: b.streamPath, leaseMs: b.leaseMs }
732
+ : null;
733
+ }
734
+ /**
735
+ * The exact shell command a Monitor runs. The ticket is the only credential in
736
+ * it; the stream URL and lease come from the dashboard's own ticket response, so
737
+ * the listener carries no copy of either.
738
+ */
739
+ export function listenCommand(target, issued) {
740
+ const quote = (value) => `'${value.replace(/'/g, `'\\''`)}'`;
741
+ const streamUrl = `${target.baseUrl.replace(/\/+$/, "")}${issued.streamPath}`;
742
+ return (`npx -y ${target.packageSpec} listen --stream ${quote(streamUrl)} ` +
743
+ `--ticket ${quote(issued.ticket)} --lease-ms ${issued.leaseMs}`);
744
+ }
745
+ function listenerNotArmed(status, connected, ticketResponse) {
746
+ return {
747
+ ok: false,
748
+ status,
749
+ body: {
750
+ error: "listener_not_armed",
751
+ 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.",
752
+ connected,
753
+ ticket_response: ticketResponse,
754
+ },
755
+ };
756
+ }
724
757
  /**
725
758
  * Connect THIS session to a plan — the same write the operator's Connect
726
759
  * action performs, reaching the same server-side code path. `me` in the URL
@@ -729,14 +762,51 @@ export async function planGet(client, args = {}) {
729
762
  *
730
763
  * A session already on another plan is MOVED, and the response says which
731
764
  * plan it left (`movedFrom`).
765
+ *
766
+ * THEN IT ARMS THE LISTENER. A connected session must hear about its plan's
767
+ * cards the moment something happens, so the reply carries the exact Monitor
768
+ * command to run. The command holds a stream TICKET this server mints for the
769
+ * session — never this server's own long-lived token, which must not appear in
770
+ * a command line. Minting a ticket replaces the session's previous one, so
771
+ * re-connecting (after a restart, or to re-arm) leaves exactly one listener.
772
+ *
773
+ * A refused ticket is NOT reported as a successful connect: the binding did
774
+ * happen, but a session that believes it is listening when it is not would miss
775
+ * the operator's answer silently, so the whole call fails loud and says both.
732
776
  */
733
- export async function planConnect(client, args) {
734
- return client.request({
777
+ export async function planConnect(client, args, listener) {
778
+ const connected = await client.request({
735
779
  method: "POST",
736
780
  path: "/me/plan",
737
781
  basePath: PLAN_SESSIONS_BASE_PATH,
738
782
  body: { plan_id: args.plan_id },
739
783
  });
784
+ if (!connected.ok)
785
+ return connected;
786
+ let issued;
787
+ try {
788
+ issued = await client.request({
789
+ method: "POST",
790
+ path: "/me/stream-ticket",
791
+ basePath: PLAN_SESSIONS_BASE_PATH,
792
+ });
793
+ }
794
+ catch (err) {
795
+ // The binding already happened; a thrown ticket request must not erase that fact.
796
+ return listenerNotArmed(502, connected.body, { error: err instanceof Error ? err.message : String(err) });
797
+ }
798
+ const ticket = issued.ok ? readIssuedTicket(issued.body) : null;
799
+ if (ticket === null) {
800
+ return listenerNotArmed(issued.ok ? 502 : issued.status, connected.body, issued.body);
801
+ }
802
+ return {
803
+ ...connected,
804
+ listener: {
805
+ command: listenCommand(listener, ticket),
806
+ persistent: true,
807
+ 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.",
808
+ },
809
+ };
740
810
  }
741
811
  /** Add a goal, rule or caveat to the connected plan. */
742
812
  export async function planAddRecord(client, args) {
package/dist/index.js CHANGED
@@ -74,8 +74,10 @@
74
74
  * agent reads `body.error` + structured fields to decide next action.
75
75
  * 5xx and network failures throw — never silently swallowed.
76
76
  */
77
+ import { createRequire } from "node:module";
77
78
  import { basename } from "node:path";
78
79
  import { isEntrypointModule } from "./entrypoint.js";
80
+ import { LISTEN_SUBCOMMAND, runListenCommand } from "./listen.js";
79
81
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
80
82
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
81
83
  import { z } from "zod";
@@ -161,6 +163,16 @@ function readSessionConfig() {
161
163
  const fallback = cwdName === "" ? `session ${id.slice(0, 8)}` : cwdName;
162
164
  return { id, title: readEnvOptional("DANX_SESSION_TITLE") ?? fallback };
163
165
  }
166
+ /**
167
+ * This build's own npm spec. `plan_connect` hands out a `listen` command pinned to
168
+ * it, so the listener that runs is always the one written for the stream this
169
+ * server's dashboard speaks — never whatever `npx` happens to have cached.
170
+ * `package.json` sits one level above both `src/` (tsx) and `dist/` (the bin).
171
+ */
172
+ const PACKAGE_SPEC = (() => {
173
+ const pkg = createRequire(import.meta.url)("../package.json");
174
+ return `${pkg.name}@${pkg.version}`;
175
+ })();
164
176
  export const server = new McpServer({
165
177
  name: "danx-dashboard-mcp",
166
178
  version: "0.1.0",
@@ -619,8 +631,8 @@ server.tool("brief_set_page", 'Write one Brief page via PUT /api/brief/page?slug
619
631
  // plan id — and it can only ever bind the caller's own session. `plan_create`
620
632
  // also takes no plan id, but for a different reason: it MAKES a plan rather
621
633
  // than acting on one, so there is no existing plan for an id to name yet.
622
- 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}}`. `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). NOTE this is NOT the board Brief (`brief_list`), which is a different feature entirely.", {}, async () => jsonResult(await planList(client)));
623
- server.tool("plan_get", "Read one plan WHOLE via GET /api/plans (DX-2683) — its member cards (with the boards they cover), its goals, rules and caveats, its architecture document, the sessions working on it, and your own session state. One call, not five. 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. Returns `{plan, cards, boards, records: {goal: [], rule: [], caveat: []}, architecture: {content, contentHash, updatedAt, updatedBy}, sessions, session}`. ALWAYS `plan_get` immediately before `plan_set_architecture` and pass the returned `architecture.contentHash` back as `base_hash`.", {
634
+ 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)));
635
+ server.tool("plan_get", "Read one plan WHOLE via GET /api/plans (DX-2683) — its member cards (with the boards they cover), its goals, rules and caveats, its architecture document, the sessions working on it, and your own session state. One call, not five. 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. Returns `{plan, cards, boards, records: {goal: [], rule: [], caveat: []}, architecture: {content, contentHash, updatedAt, updatedBy}, sessions, session, sessionListenerAttached}` — `sessionListenerAttached: false` while connected means your event listener is not running; call `plan_connect` again and arm the Monitor it returns. ALWAYS `plan_get` immediately before `plan_set_architecture` and pass the returned `architecture.contentHash` back as `base_hash`.", {
624
636
  plan_id: z
625
637
  .number()
626
638
  .int()
@@ -631,9 +643,9 @@ server.tool("plan_get", "Read one plan WHOLE via GET /api/plans (DX-2683) — it
631
643
  server.tool("plan_create", "Create a new, empty plan via POST /api/plans (DX-2531). GLOBAL — a plan is not board-scoped, and this call adds no cards, no records, and no architecture document; it does NOT connect any session to the new plan (call `plan_connect` separately, exactly as adding a card to a plan is its own separate step). Returns `{ok, status, body: {plan: {id, name, createdAt}}}`. Use the returned `plan.id` with `plan_connect` to start working on it, or with `plan_get({plan_id})` to browse it.", {
632
644
  name: z.string().min(1).describe("The plan's name — shown in the Plans list."),
633
645
  }, async (args) => jsonResult(await planCreate(client, args)));
634
- server.tool("plan_connect", "Connect THIS session to a plan via POST /api/plan-sessions/me/plan (DX-2683) — the same binding the operator's Connect action writes, through the same server-side path. A session is connected to AT MOST ONE plan (enforced by the schema, not by convention); connecting while already on another plan MOVES you, and the response says which plan you left: `{ok, status, body: {session, movedFrom: {id, name} | null}}`. `movedFrom: null` means you were on no plan, or already on this one. It can only ever bind your OWN session — `me` is resolved from the session id this server forwards, never from anything you pass. After this, every plan WRITE tool acts on this plan, and no plan id is accepted anywhere.", {
646
+ server.tool("plan_connect", "Connect THIS session to a plan via POST /api/plan-sessions/me/plan (DX-2683) — the same binding the operator's Connect action writes, through the same server-side path. A session is connected to AT MOST ONE plan (enforced by the schema, not by convention); connecting while already on another plan MOVES you, and the response says which plan you left: `{ok, status, body: {session, movedFrom: {id, name} | null}}`. `movedFrom: null` means you were on no plan, or already on this one. It can only ever bind your OWN session — `me` is resolved from the session id this server forwards, never from anything you pass. After this, every plan WRITE tool acts on this plan, and no plan id is accepted anywhere. THE REPLY ALSO CARRIES `listener: {command, persistent: true, instruction}` — arm it IMMEDIATELY with the Monitor tool (`command` as given, `persistent: true`): from then on every comment, answer, requires_human change and block/unblock on this plan's cards arrives as a notification line like `[DX-8 \"Title\" repo:board] newms87 answered: chose \"Pause E2E\" — note: \"…\"`. Never poll for these. The command carries a narrow stream ticket, not a credential; calling plan_connect again (same plan is fine) issues a new one and ends the old listener, which is how you re-arm after a session restart or after the Monitor reports it gave up. If the ticket cannot be issued the call fails with `listener_not_armed` even though the connect itself happened.", {
635
647
  plan_id: z.number().int().positive().describe("The plan id, from `plan_list`."),
636
- }, async (args) => jsonResult(await planConnect(client, args)));
648
+ }, async (args) => jsonResult(await planConnect(client, args, { baseUrl: config.baseUrl, packageSpec: PACKAGE_SPEC })));
637
649
  server.tool("plan_add_record", "Add a GOAL, RULE or CAVEAT to the plan this session is connected to, via POST /api/plans/mine/records (DX-2683). A goal is what the plan is FOR (the outcome the work is measured against — not a task). A rule is what must HOLD while it is worked. A caveat is what is known to be AWKWARD — the fact that will surprise the next person. Each gets a permanent short reference within the plan (`G-1`, `R-4`, `CAV-12`) allocated by the server, which is how a person cites it in a card or a commit. TAKES NO PLAN ID: the plan is resolved from your connected session, so you cannot write a plan you are not connected to. Not connected → `{error: \"session_not_connected\"}`; call `plan_connect` first. Returns the new record plus that kind's full list.", {
638
650
  kind: z
639
651
  .enum(["goal", "rule", "caveat"])
@@ -678,9 +690,28 @@ async function main() {
678
690
  // schemas can be introspected without spawning a dispatch. The check is
679
691
  // symlink-aware (DX-1647) so it holds under the symlinked `npx` bin the worker
680
692
  // spawns, not just a direct `node dist/index.js`.
693
+ //
694
+ // `listen` is the one subcommand: the session event listener `plan_connect`
695
+ // tells the agent to arm as a Monitor. It never boots the MCP server and reads
696
+ // none of the MCP env — its whole configuration is its two flags. Any OTHER
697
+ // argument is refused rather than ignored, so a typo cannot silently start an
698
+ // MCP server on stdio where a listener was meant to run.
681
699
  if (isEntrypointModule(import.meta.url, process.argv[1])) {
682
- main().catch((err) => {
683
- console.error(`[danx-dashboard-mcp] fatal: ${err.message}`);
684
- process.exit(1);
685
- });
700
+ const [subcommand, ...rest] = process.argv.slice(2);
701
+ if (subcommand === LISTEN_SUBCOMMAND) {
702
+ runListenCommand(rest).then((code) => process.exit(code), (err) => {
703
+ console.error(`[danx-dashboard-mcp] listen fatal: ${err.message}`);
704
+ process.exit(1);
705
+ });
706
+ }
707
+ else if (subcommand !== undefined) {
708
+ console.error(`[danx-dashboard-mcp] unknown subcommand "${subcommand}" (the only one is "${LISTEN_SUBCOMMAND}")`);
709
+ process.exit(2);
710
+ }
711
+ else {
712
+ main().catch((err) => {
713
+ console.error(`[danx-dashboard-mcp] fatal: ${err.message}`);
714
+ process.exit(1);
715
+ });
716
+ }
686
717
  }
package/dist/listen.js ADDED
@@ -0,0 +1,278 @@
1
+ /**
2
+ * `danx-dashboard-mcp listen --stream <url> --ticket <ticket> --lease-ms <ms>` —
3
+ * a working session's event listener.
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.
12
+ *
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.
18
+ *
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.
29
+ *
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
+ * Only the event shape is hand-copied from the dashboard's `IssueActivityEvent`
33
+ * (`src/issues/db/issue-activity.ts`); this published package cannot import
34
+ * danxbot source.
35
+ */
36
+ export const LISTEN_SUBCOMMAND = "listen";
37
+ export const INITIAL_BACKOFF_MS = 1_000;
38
+ export const MAX_BACKOFF_MS = 30_000;
39
+ /** Three of the dashboard's 15 s keep-alives without a byte means the connection is dead. */
40
+ export const READ_IDLE_TIMEOUT_MS = 45_000;
41
+ /** A connection that stays up this long has proven the dashboard healthy. */
42
+ export const HEALTHY_CONNECTION_MS = 20_000;
43
+ /** How many printed event ids the duplicate guard remembers — well past the replay overlap. */
44
+ const PRINTED_ID_MEMORY = 1_000;
45
+ const LINE_PREFIX = "[danx-dashboard listen]";
46
+ const REARM = "Call plan_connect again and arm the Monitor it returns to keep receiving this plan's events.";
47
+ const USAGE = `usage: danx-dashboard-mcp ${LISTEN_SUBCOMMAND} --stream <stream-url> --ticket <ticket> --lease-ms <ms>`;
48
+ /** Parse the three required flags; anything else is refused. */
49
+ export function parseListenArgs(argv) {
50
+ const values = new Map();
51
+ for (let i = 0; i < argv.length; i += 2) {
52
+ const flag = argv[i];
53
+ const value = argv[i + 1];
54
+ if (!["--stream", "--ticket", "--lease-ms"].includes(flag) || value === undefined || value === "") {
55
+ throw new Error(USAGE);
56
+ }
57
+ values.set(flag, value);
58
+ }
59
+ const streamUrl = values.get("--stream");
60
+ const ticket = values.get("--ticket");
61
+ const leaseMs = Number(values.get("--lease-ms"));
62
+ if (!streamUrl || !ticket || !Number.isSafeInteger(leaseMs) || leaseMs <= 0)
63
+ throw new Error(USAGE);
64
+ return { streamUrl, ticket, leaseMs };
65
+ }
66
+ function quoted(value) {
67
+ return `"${value.text}${value.truncated ? "…" : ""}"`;
68
+ }
69
+ function describe(event) {
70
+ const d = event.detail;
71
+ switch (event.kind) {
72
+ case "comment_added":
73
+ return `${event.actor} commented: ${quoted(d.excerpt)}`;
74
+ case "solution_answered": {
75
+ const solution = d.solution;
76
+ if (solution === null)
77
+ return `${event.actor} answered: ${quoted(d.freeform)}`;
78
+ const note = solution.note === null ? "" : ` — note: ${quoted(solution.note)}`;
79
+ return `${event.actor} answered: chose "${solution.title}"${note}`;
80
+ }
81
+ case "requires_human_set":
82
+ return `${event.actor} set requires_human: ${quoted(d.reason)}`;
83
+ case "requires_human_cleared":
84
+ return `${event.actor} cleared requires_human`;
85
+ case "blocked":
86
+ return `${event.actor} blocked the card: ${quoted(d.reason)}`;
87
+ case "unblocked":
88
+ return `${event.actor} unblocked the card`;
89
+ default:
90
+ // A kind newer than this listener still produces a line: an event the
91
+ // agent is never told about is exactly the failure this command prevents.
92
+ return `${event.actor}: ${event.kind}`;
93
+ }
94
+ }
95
+ /** The one notification line for an event. Always a single line; throws on a malformed event. */
96
+ export function formatActivityLine(event) {
97
+ return `[${event.cardId} "${event.cardTitle}" ${event.boardId}] ${describe(event)}`.replace(/\s+/g, " ");
98
+ }
99
+ /**
100
+ * Incremental SSE parser. Comment lines (keep-alives, the connect marker) and
101
+ * field-less blocks produce no message, which is what keeps them off stdout.
102
+ */
103
+ export class SseParser {
104
+ buffer = "";
105
+ push(chunk) {
106
+ this.buffer += chunk.replace(/\r\n?/g, "\n");
107
+ const messages = [];
108
+ let boundary = this.buffer.indexOf("\n\n");
109
+ while (boundary !== -1) {
110
+ const message = parseBlock(this.buffer.slice(0, boundary));
111
+ this.buffer = this.buffer.slice(boundary + 2);
112
+ if (message !== null)
113
+ messages.push(message);
114
+ boundary = this.buffer.indexOf("\n\n");
115
+ }
116
+ return messages;
117
+ }
118
+ }
119
+ function parseBlock(block) {
120
+ let id = null;
121
+ let event = "message";
122
+ const data = [];
123
+ let sawField = false;
124
+ for (const line of block.split("\n")) {
125
+ if (line === "" || line.startsWith(":"))
126
+ continue;
127
+ const colon = line.indexOf(":");
128
+ const field = colon === -1 ? line : line.slice(0, colon);
129
+ const value = colon === -1 ? "" : line.slice(colon + 1).replace(/^ /, "");
130
+ sawField = true;
131
+ if (field === "id")
132
+ id = value;
133
+ else if (field === "event")
134
+ event = value;
135
+ else if (field === "data")
136
+ data.push(value);
137
+ }
138
+ return sawField ? { id, event, data: data.join("\n") } : null;
139
+ }
140
+ /** Statuses that describe the moment, not the ticket — retry them. */
141
+ const RETRYABLE_CLIENT_STATUSES = new Set([408, 429]);
142
+ /**
143
+ * Run until the dashboard ends the stream or the listener gives up. Returns the
144
+ * exit code: 0 when a newer listener took over, 1 when revoked, refused, or
145
+ * given up.
146
+ */
147
+ export async function runListener(options, deps) {
148
+ const printed = new Set();
149
+ let lastEventId = null;
150
+ let unhealthySince = null;
151
+ let attempt = 0;
152
+ const remember = (id) => {
153
+ printed.add(id);
154
+ if (printed.size > PRINTED_ID_MEMORY)
155
+ printed.delete(printed.values().next().value);
156
+ lastEventId = lastEventId === null ? id : Math.max(lastEventId, id);
157
+ };
158
+ const handle = (message) => {
159
+ if (message.event === "end") {
160
+ const reason = JSON.parse(message.data).reason;
161
+ return { kind: "ended", reason: typeof reason === "string" ? reason : "unknown" };
162
+ }
163
+ if (message.event !== "activity")
164
+ return null;
165
+ const id = Number(message.id);
166
+ if (Number.isSafeInteger(id) && printed.has(id))
167
+ return null;
168
+ try {
169
+ deps.write(formatActivityLine(JSON.parse(message.data)));
170
+ }
171
+ catch (err) {
172
+ // Never silence, and never a reconnect loop on the same bad event: say so
173
+ // in one line and move past it.
174
+ deps.write(`${LINE_PREFIX} could not read event ${message.id ?? "(no id)"} (${err.message}): ` +
175
+ `${message.data.slice(0, 300)}`.replace(/\s+/g, " "));
176
+ }
177
+ if (Number.isSafeInteger(id))
178
+ remember(id);
179
+ return null;
180
+ };
181
+ const connectOnce = async () => {
182
+ const controller = new AbortController();
183
+ let idle;
184
+ const armIdle = () => {
185
+ clearTimeout(idle);
186
+ idle = setTimeout(() => controller.abort(), deps.readIdleTimeoutMs);
187
+ };
188
+ const headers = {
189
+ Authorization: `Bearer ${options.ticket}`,
190
+ Accept: "text/event-stream",
191
+ };
192
+ if (lastEventId !== null)
193
+ headers["Last-Event-ID"] = String(lastEventId);
194
+ const openedAt = deps.now();
195
+ let delivered = false;
196
+ const healthy = () => delivered || deps.now() - openedAt >= HEALTHY_CONNECTION_MS;
197
+ try {
198
+ armIdle();
199
+ const response = await deps.fetch(options.streamUrl, { headers, signal: controller.signal });
200
+ if (response.status >= 400 && response.status < 500 && !RETRYABLE_CLIENT_STATUSES.has(response.status)) {
201
+ return { kind: "refused", detail: `HTTP ${response.status} ${(await response.text()).slice(0, 300)}` };
202
+ }
203
+ if (!response.ok || response.body === null) {
204
+ await response.body?.cancel();
205
+ return { kind: "dropped", healthy: false };
206
+ }
207
+ const parser = new SseParser();
208
+ const decoder = new TextDecoder();
209
+ for await (const chunk of response.body) {
210
+ armIdle();
211
+ for (const message of parser.push(decoder.decode(chunk, { stream: true }))) {
212
+ const outcome = handle(message);
213
+ if (outcome !== null)
214
+ return outcome;
215
+ if (message.event === "activity")
216
+ delivered = true;
217
+ }
218
+ }
219
+ return { kind: "dropped", healthy: healthy() };
220
+ }
221
+ catch {
222
+ return { kind: "dropped", healthy: healthy() };
223
+ }
224
+ finally {
225
+ clearTimeout(idle);
226
+ controller.abort();
227
+ }
228
+ };
229
+ for (;;) {
230
+ const outcome = await connectOnce();
231
+ if (outcome.kind === "ended") {
232
+ if (outcome.reason === "superseded" || outcome.reason === "replaced") {
233
+ deps.write(`${LINE_PREFIX} stopped: another listener for this session took over (${outcome.reason}). ` +
234
+ `If you did not just call plan_connect, something else did — ${REARM}`);
235
+ return 0;
236
+ }
237
+ deps.write(`${LINE_PREFIX} stopped: the dashboard ended this listener (${outcome.reason}). ${REARM}`);
238
+ return 1;
239
+ }
240
+ if (outcome.kind === "refused") {
241
+ deps.write(`${LINE_PREFIX} gave up: the dashboard refused the listener ticket (${outcome.detail}). ${REARM}`);
242
+ return 1;
243
+ }
244
+ if (outcome.healthy) {
245
+ unhealthySince = null;
246
+ attempt = 0;
247
+ }
248
+ const now = deps.now();
249
+ unhealthySince ??= now;
250
+ if (now - unhealthySince >= options.leaseMs) {
251
+ deps.write(`${LINE_PREFIX} gave up: no healthy connection to ${options.streamUrl} for ` +
252
+ `${Math.round((now - unhealthySince) / 60_000)} minutes, past the ticket's lease. ${REARM}`);
253
+ return 1;
254
+ }
255
+ await deps.sleep(Math.min(MAX_BACKOFF_MS, INITIAL_BACKOFF_MS * 2 ** attempt));
256
+ attempt += 1;
257
+ }
258
+ }
259
+ /** The bin's `listen` subcommand, wired to the real process. */
260
+ export async function runListenCommand(argv) {
261
+ let options;
262
+ try {
263
+ options = parseListenArgs(argv);
264
+ }
265
+ catch (err) {
266
+ process.stderr.write(`${err.message}\n`);
267
+ return 2;
268
+ }
269
+ return runListener(options, {
270
+ fetch,
271
+ write: (line) => {
272
+ process.stdout.write(`${line}\n`);
273
+ },
274
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
275
+ now: Date.now,
276
+ readIdleTimeoutMs: READ_IDLE_TIMEOUT_MS,
277
+ });
278
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thehammer/danx-dashboard-mcp",
3
- "version": "0.1.52",
3
+ "version": "0.1.53",
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",