@thehammer/danx-dashboard-mcp 0.1.65 → 0.1.67
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 +14 -25
- 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",
|
|
@@ -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; 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
|
|
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.67",
|
|
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",
|