@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 +8 -8
- package/dist/bridge.js +236 -0
- package/dist/handlers.js +7 -71
- package/dist/index.js +31 -42
- package/dist/listen.js +61 -102
- package/package.json +1 -1
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
|
-
## `
|
|
41
|
+
## `bridge` — a working session's event stream client
|
|
42
42
|
|
|
43
|
-
|
|
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
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
- **
|
|
52
|
-
- **
|
|
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
|
-
*
|
|
933
|
-
*
|
|
934
|
-
*
|
|
935
|
-
*
|
|
936
|
-
*
|
|
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
|
|
944
|
-
|
|
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 {
|
|
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
|
|
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
|
|
284
|
-
const SUMMARY_DESCRIBE = "1–3 plain-language sentences
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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]:
|
|
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.
|
|
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) —
|
|
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',
|
|
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;
|
|
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;
|
|
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;
|
|
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;
|
|
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;
|
|
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;
|
|
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
|
|
669
|
-
server.tool("plan_get", "Read a plan via GET /api/plans (DX-2683). Pass `plan_id` to read ANY plan
|
|
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.
|
|
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
|
|
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
|
-
// `
|
|
773
|
-
//
|
|
774
|
-
//
|
|
775
|
-
// argument is refused rather than ignored, so a typo
|
|
776
|
-
// MCP server on stdio where
|
|
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 ===
|
|
780
|
-
|
|
781
|
-
console.error(`[danx-dashboard-mcp]
|
|
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 "${
|
|
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
|
-
*
|
|
3
|
-
*
|
|
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
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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,
|
|
14
|
-
* - exactly one
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
|
20
|
-
* silently (sleep, NAT, proxy). A read-idle timeout of three missed
|
|
21
|
-
* turns silent death into a drop. Retries use capped exponential
|
|
22
|
-
* `Last-Event-ID`; the dashboard replays what was missed
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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
|
|
45
|
-
const
|
|
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
|
|
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
|
|
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
|
|
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
|
|
202
|
-
*
|
|
203
|
-
*
|
|
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
|
|
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
|
-
|
|
212
|
-
if (
|
|
213
|
-
|
|
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
|
-
|
|
224
|
-
|
|
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
|
-
|
|
240
|
-
|
|
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 (
|
|
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
|
|
267
|
-
const healthy = () =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
309
|
-
return 1;
|
|
291
|
+
return stop(outcome.reason, "the dashboard ended this listener", 1);
|
|
310
292
|
}
|
|
311
293
|
if (outcome.kind === "refused") {
|
|
312
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|