@thehammer/danx-dashboard-mcp 0.1.52 → 0.1.54
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 +14 -0
- package/dist/handlers.js +86 -6
- package/dist/index.js +68 -37
- package/dist/listen.js +278 -0
- package/package.json +1 -1
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,22 +762,63 @@ 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
|
-
|
|
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
|
-
/** Add a goal, rule or caveat to the connected plan. */
|
|
811
|
+
/** Add a goal, rule or caveat to the connected plan. `context` is sent only when given. */
|
|
742
812
|
export async function planAddRecord(client, args) {
|
|
743
813
|
return client.request({
|
|
744
814
|
method: "POST",
|
|
745
815
|
path: "/mine/records",
|
|
746
816
|
basePath: PLANS_BASE_PATH,
|
|
747
|
-
body: {
|
|
817
|
+
body: {
|
|
818
|
+
kind: args.kind,
|
|
819
|
+
body: args.body,
|
|
820
|
+
...(args.context === undefined ? {} : { context: args.context }),
|
|
821
|
+
},
|
|
748
822
|
});
|
|
749
823
|
}
|
|
750
824
|
/** Add an existing card to the connected plan. */
|
|
@@ -813,7 +887,9 @@ export async function planGetRecord(client, args) {
|
|
|
813
887
|
* `plan_get_record`; the server compares it against the row's current hash
|
|
814
888
|
* and, on a mismatch, refuses the write ENTIRELY and fails loud with
|
|
815
889
|
* `{ok: false, status: 409, body: {error: "stale_plan_record", currentHash,
|
|
816
|
-
* currentBody}}` rather than overwriting whoever wrote in
|
|
890
|
+
* currentBody, currentContext}}` rather than overwriting whoever wrote in
|
|
891
|
+
* between. DX-2734: the hash covers body AND context, and `context` is sent
|
|
892
|
+
* only when given (omitted keeps the stored context, `null` clears it).
|
|
817
893
|
* `currentBody` rides the SAME refusal — unlike the architecture document's
|
|
818
894
|
* `stale_plan_architecture`, which carries only `currentHash` — so you can
|
|
819
895
|
* merge and retry in ONE round trip without a second `plan_get_record` call.
|
|
@@ -825,7 +901,11 @@ export async function planUpdateRecord(client, args) {
|
|
|
825
901
|
method: "PATCH",
|
|
826
902
|
path: `/mine/records/${args.record_id}`,
|
|
827
903
|
basePath: PLANS_BASE_PATH,
|
|
828
|
-
body: {
|
|
904
|
+
body: {
|
|
905
|
+
body: args.body,
|
|
906
|
+
content_hash: args.content_hash,
|
|
907
|
+
...(args.context === undefined ? {} : { context: args.context }),
|
|
908
|
+
},
|
|
829
909
|
});
|
|
830
910
|
}
|
|
831
911
|
/**
|
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",
|
|
@@ -298,7 +310,7 @@ server.tool("issue_get", "Fetch a single issue via GET /api/issues/:id. Board-sc
|
|
|
298
310
|
...boardField,
|
|
299
311
|
}, async (args) => jsonResult(await issueGet(client, args)));
|
|
300
312
|
// ---------------- issue_create ----------------
|
|
301
|
-
server.tool("issue_create", 'Create a
|
|
313
|
+
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.', {
|
|
302
314
|
type: z.enum(ISSUE_TYPES),
|
|
303
315
|
title: z.string().min(1).describe(TITLE_DESCRIBE),
|
|
304
316
|
summary: z.string().min(1).optional().describe(SUMMARY_DESCRIBE),
|
|
@@ -315,7 +327,7 @@ server.tool("issue_create", 'Create a fresh card via POST /api/issues. Board-sco
|
|
|
315
327
|
effort_level: z.enum(EFFORT_VALUES).nullable().optional(),
|
|
316
328
|
}))
|
|
317
329
|
.optional()
|
|
318
|
-
.describe(
|
|
330
|
+
.describe("One {gate, enabled, note} per board-OPTIONAL gate of the card's type: `enabled` = does it run on this card, `note` = why. Board `required` gates always run and `disabled` never do; neither takes a decision (naming one → 400). Optional `effort_level` overrides a `plan-*` gate's reviewer rung."),
|
|
319
331
|
phase_children: z
|
|
320
332
|
.array(z.object({
|
|
321
333
|
type: z.enum(NON_EPIC_TYPES),
|
|
@@ -336,21 +348,21 @@ server.tool("issue_create", 'Create a fresh card via POST /api/issues. Board-sco
|
|
|
336
348
|
effort_level: z.enum(EFFORT_VALUES).nullable().optional(),
|
|
337
349
|
}))
|
|
338
350
|
.optional()
|
|
339
|
-
.describe("
|
|
351
|
+
.describe("Same as the root gate_decisions, resolved against THIS child's type."),
|
|
340
352
|
triage_enabled: z
|
|
341
353
|
.boolean()
|
|
342
354
|
.optional()
|
|
343
|
-
.describe("
|
|
355
|
+
.describe("ALWAYS pass per child; absent → false (never auto-triaged). Not inherited from the root."),
|
|
344
356
|
}))
|
|
345
357
|
.optional(),
|
|
346
358
|
triage_enabled: z
|
|
347
359
|
.boolean()
|
|
348
360
|
.optional()
|
|
349
|
-
.describe("
|
|
361
|
+
.describe("ALWAYS pass explicitly. true = enters automatic triage/dispatch without further human review; absent → false. Operator POST /api/triage and issue_triage ignore it."),
|
|
350
362
|
...boardField,
|
|
351
363
|
}, async (args) => jsonResult(await issueCreate(client, args, config.board)));
|
|
352
364
|
// ---------------- issue_edit ----------------
|
|
353
|
-
server.tool("issue_edit", 'Patch
|
|
365
|
+
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. 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.', {
|
|
354
366
|
id: z.string().min(1),
|
|
355
367
|
title: z.string().min(1).optional().describe(TITLE_DESCRIBE),
|
|
356
368
|
summary: z
|
|
@@ -363,7 +375,7 @@ server.tool("issue_edit", 'Patch prose + structured fields via PATCH /api/issues
|
|
|
363
375
|
type: z
|
|
364
376
|
.enum(ISSUE_TYPES)
|
|
365
377
|
.optional()
|
|
366
|
-
.describe("
|
|
378
|
+
.describe("Story/Bug/Chore = dispatchable; Task/Epic/Feature = never dispatched, in any status."),
|
|
367
379
|
ac: z
|
|
368
380
|
.array(z.object({
|
|
369
381
|
title: z.string(),
|
|
@@ -371,7 +383,7 @@ server.tool("issue_edit", 'Patch prose + structured fields via PATCH /api/issues
|
|
|
371
383
|
status: z
|
|
372
384
|
.enum(CHECKLIST_ITEM_STATUSES)
|
|
373
385
|
.optional()
|
|
374
|
-
.describe(
|
|
386
|
+
.describe("Optional full status; omitted → from `checked`. `deferred` REQUIRES a non-empty `detail`; a 📡 item can never be `passing`."),
|
|
375
387
|
detail: z
|
|
376
388
|
.string()
|
|
377
389
|
.optional()
|
|
@@ -379,10 +391,10 @@ server.tool("issue_edit", 'Patch prose + structured fields via PATCH /api/issues
|
|
|
379
391
|
check_item_id: z
|
|
380
392
|
.union([z.string(), z.number()])
|
|
381
393
|
.optional()
|
|
382
|
-
.describe("
|
|
394
|
+
.describe("Optional id from issue_get's ac[].check_item_id. Only needed to tell apart two items with identical titles; otherwise items match by title."),
|
|
383
395
|
}))
|
|
384
396
|
.optional()
|
|
385
|
-
.describe("The
|
|
397
|
+
.describe("The default Acceptance Criteria checklist: checked true → passing, false → incomplete, or pass `status`. Diffed against live items — unchanged items keep their id, new titles are inserted, missing ones removed."),
|
|
386
398
|
checklists: z
|
|
387
399
|
.array(z.object({
|
|
388
400
|
name: z.string().min(1),
|
|
@@ -398,16 +410,16 @@ server.tool("issue_edit", 'Patch prose + structured fields via PATCH /api/issues
|
|
|
398
410
|
priority: z
|
|
399
411
|
.union([z.enum(PRIORITY_TIER_WORDS), z.number()])
|
|
400
412
|
.optional()
|
|
401
|
-
.describe('
|
|
413
|
+
.describe('A tier word ("lowest"…"critical", resolved to the tier midpoint) or a number in [0,6). The only way to set priority.'),
|
|
402
414
|
list_id: z.string().min(1).nullable().optional(),
|
|
403
415
|
triage_enabled: z
|
|
404
416
|
.boolean()
|
|
405
417
|
.optional()
|
|
406
|
-
.describe("
|
|
418
|
+
.describe("Per-card opt-in to the automatic triage dispatcher (default false = never auto-selected). Operator POST /api/triage and issue_triage ignore it."),
|
|
407
419
|
...boardField,
|
|
408
420
|
}, async (args) => jsonResult(await issueEdit(client, args)));
|
|
409
421
|
// ---------------- issue_transition ----------------
|
|
410
|
-
server.tool("issue_transition", "
|
|
422
|
+
server.tool("issue_transition", "Move a card's lifecycle via POST /api/issues/:id/transition — the ONLY way to; `danxbot_complete` never moves a card, so call this BEFORE it. Actions: ready (Review→ToDo); pickup (ToDo→In Progress; checks every dispatch gate — ready, blocked, requires_human, depends_on terminal, conflict_on idle — and refuses 409 with failed_gate naming the cause; `manual:true` is an operator-session self-pickup that bypasses card-flow gates and is never auto-rolled-back: use it when the work happens in YOUR session); rollback_pickup; complete (your explicit decision; refuses 409 on an Epic with non-terminal children, non_terminal_phases[], or while a required POST quality gate is not pass, failed_gate 'quality_gate_post' + failed_post_gates[]); cancel (terminal); block (non-empty reason; the CARD needs a human — env faults use `danxbot_complete({status:'failed'})` instead); unblock; archive (to Backlog, clears ready_at); reopen (terminal→active). Terminal cards refuse everything but reopen; forward stamps never clear earlier ones. Every path into In Progress needs an identified claimer: a dispatched agent's manual pickup MUST pass `assigned_agent` = your agent/profile name (409 otherwise). A manual pickup that loses a race is refused 409 `failed_gate: \"dispatch_id\"` — re-read the card's assigned_agent/dispatch_id, never retry blindly. A successful block returns `solutions_reminder: {solution_count, instruction}`: before stopping, list EVERY viable solution with issue_solution; zero means you listed none.", {
|
|
411
423
|
id: z.string().min(1),
|
|
412
424
|
action: z.enum(TRANSITION_ACTIONS),
|
|
413
425
|
reason: z.string().optional(),
|
|
@@ -418,7 +430,7 @@ server.tool("issue_transition", "Stamp a lifecycle transition via POST /api/issu
|
|
|
418
430
|
.string()
|
|
419
431
|
.min(1)
|
|
420
432
|
.optional()
|
|
421
|
-
.describe("
|
|
433
|
+
.describe("Required for a dispatched agent's manual:true pickup: your agent/profile name, never the shared dispatch-token identity. Optional for a human session; ignored by other actions."),
|
|
422
434
|
...boardField,
|
|
423
435
|
}, async (args) => jsonResult(await issueTransition(client, args)));
|
|
424
436
|
// ---------------- issue_triage ----------------
|
|
@@ -501,7 +513,7 @@ server.tool("issue_requires_human", "Set or clear the requires_human dispatch ga
|
|
|
501
513
|
...boardField,
|
|
502
514
|
}, async (args) => jsonResult(await issueRequiresHuman(client, args)));
|
|
503
515
|
// ---------------- issue_quality_gate ----------------
|
|
504
|
-
server.tool("issue_quality_gate", "
|
|
516
|
+
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.", {
|
|
505
517
|
id: z.string().min(1),
|
|
506
518
|
gate: z.enum([
|
|
507
519
|
"plan-dependency",
|
|
@@ -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
|
|
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} (each `[{id, ref, body, context, contentHash}]` — `context` is markdown detail or null), 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,30 +643,30 @@ 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)));
|
|
637
|
-
server.tool("plan_add_record", "Add a
|
|
638
|
-
kind: z
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
body: z.string().min(1).describe("The record's text. Plain text, not markdown."),
|
|
648
|
+
}, async (args) => jsonResult(await planConnect(client, args, { baseUrl: config.baseUrl, packageSpec: PACKAGE_SPEC })));
|
|
649
|
+
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.", {
|
|
650
|
+
kind: z.enum(["goal", "rule", "caveat"]).describe("goal = outcome, rule = constraint, caveat = architecture trade-off."),
|
|
651
|
+
body: z.string().min(1).describe("One plain statement, at most 250 characters. Details go in `context`."),
|
|
652
|
+
context: z.string().optional().describe("Markdown detail behind the statement: evidence, history, examples."),
|
|
642
653
|
}, async (args) => jsonResult(await planAddRecord(client, args)));
|
|
643
|
-
server.tool("plan_get_record", "Read
|
|
654
|
+
server.tool("plan_get_record", "Read one goal/rule/caveat of your connected plan (GET /api/plans/mine/records/:rid) without pulling the whole plan. Takes no plan id; an unknown or another plan's record id → 404. Returns `{record: {id, planId, kind, refNum, ref, body, context, contentHash, createdAt, updatedAt}}` — `context` is the markdown detail, or null.", {
|
|
644
655
|
record_id: z.number().int().positive().describe("A record id, from `plan_add_record`, `plan_get` or `plan_get_record` itself."),
|
|
645
656
|
}, async (args) => jsonResult(await planGetRecord(client, args)));
|
|
646
|
-
server.tool("plan_update_record", 'Edit a goal/rule/caveat of
|
|
657
|
+
server.tool("plan_update_record", 'Edit a goal/rule/caveat of your connected plan (PATCH /api/plans/mine/records/:rid). The reference never moves. `body` stays ONE plain statement of at most 250 characters (400 otherwise); detail belongs in markdown `context`. `content_hash` must be the record\'s `contentHash` from your last read, and covers body AND context. On a mismatch nothing is written and you get `{error: "stale_plan_record", currentHash, currentBody, currentContext}`: merge into those and retry with `content_hash: currentHash`, never blindly. Takes no plan id. Returns the record plus that kind\'s list.', {
|
|
647
658
|
record_id: z.number().int().positive().describe("The record id to edit."),
|
|
648
|
-
content_hash: z
|
|
659
|
+
content_hash: z.string().describe("The record's `contentHash` from your last read. Required."),
|
|
660
|
+
body: z.string().min(1).describe("The new statement, at most 250 characters. Plain text."),
|
|
661
|
+
context: z
|
|
649
662
|
.string()
|
|
650
|
-
.
|
|
651
|
-
|
|
663
|
+
.nullable()
|
|
664
|
+
.optional()
|
|
665
|
+
.describe("New markdown detail. Omit to keep the stored context; null clears it."),
|
|
652
666
|
}, async (args) => jsonResult(await planUpdateRecord(client, args)));
|
|
653
|
-
server.tool("plan_delete_record", 'Soft-delete a goal/rule/caveat of
|
|
667
|
+
server.tool("plan_delete_record", 'Soft-delete a goal/rule/caveat of your connected plan (DELETE /api/plans/mine/records/:rid). Its reference is retired permanently, never reused. `content_hash` must be the record\'s `contentHash` from your last read; a stale hash deletes nothing and returns `{error: "stale_plan_record", currentHash, currentBody, currentContext}`. Takes no plan id. Unknown or already-deleted id → 404. Returns that kind\'s remaining list.', {
|
|
654
668
|
record_id: z.number().int().positive().describe("The record id to delete."),
|
|
655
|
-
content_hash: z
|
|
656
|
-
.string()
|
|
657
|
-
.describe("The record's `contentHash` from the immediately-prior read. Required — an absent hash is not read as a wildcard."),
|
|
669
|
+
content_hash: z.string().describe("The record's `contentHash` from your last read. Required."),
|
|
658
670
|
}, async (args) => jsonResult(await planDeleteRecord(client, args)));
|
|
659
671
|
server.tool("plan_add_card", "Add an existing card to the plan this session is connected to, via POST /api/plans/mine/cards (DX-2683). The card may live on ANY board — that is what a plan is for. Idempotent: re-adding a card already on the plan is a no-op, not an error, and a card may sit in several plans at once. This adds MEMBERSHIP only; it never edits the card. TAKES NO PLAN ID: the plan is resolved from your connected session. Not connected → `{error: \"session_not_connected\"}`. Unknown card → 404. Returns the plan's full member list.", {
|
|
660
672
|
card_id: z.string().min(1).describe("An existing card id, e.g. `DX-2683`."),
|
|
@@ -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
|
-
|
|
683
|
-
|
|
684
|
-
process.exit(
|
|
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.
|
|
3
|
+
"version": "0.1.54",
|
|
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",
|