@thehammer/danx-dashboard-mcp 0.1.187 → 0.1.189
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 +1 -1
- package/dist/bridge.js +4 -0
- package/dist/index.js +1 -1
- package/dist/listen.js +32 -12
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -43,7 +43,7 @@ CLAUDE_CODE_SESSION_ID=<session> npx -y @thehammer/danx-dashboard-mcp@<version>
|
|
|
43
43
|
- **No client-side reach check (DX-2920).** `bridge` used to prove board reach itself, before ever opening the stream (`GET /api/plans/mine?fields=cards` + a per-board `GET /api/issues/:id/problems`). That check is DELETED. The dashboard briefly enforced the identical requirement server-side (DX-2863: refusing a mint or stream admission with `403 issuer_cannot_read_plan_boards`, and ending a live stream `{"reason":"scope_narrowed","boards":[…]}`), but DX-3468 removed that mechanism too — DX-3409 made every credential team-bound, so a plan session's issuer and every board its connected plan covers are always in the same team, and the check could never fire any more. `bridge` no longer independently verifies board reach at all, client- or server-side. Once a ticket is minted, `bridge` emits, once, `{"type":"ready"}` — no `boards` field, since reach is no longer proven client-side.
|
|
44
44
|
- **Ticket.** `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.
|
|
45
45
|
- **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: "…"`, `… opened a problem: "…"`, `… 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.
|
|
46
|
-
- **Stopping.** Last, one `{"type":"stopped","reason":"…","detail":"…","fix":"…"}` and exit, only on a terminal outcome. `fix` is the remedy shown to the session (`STOP_FIXES` in `src/bridge.ts` — never a log-only hint), so a session that hits a stop it cannot otherwise see still knows what to do about it. Terminal reasons: `no_connection_record` / `credential_unavailable` / `credential_mismatch` (the start-time checks above), `not_connected` (a `409 session_not_connected` at mint OR at stream admission, or a live `end("not_connected")` — all three mapped identically), `unauthorized` (401/403), `mint_refused` (any other non-transient mint refusal), `mint_bad_response`, `superseded` / `replaced` (exit 0 — a newer or replacing connection already serves this session, so `fix` says no action is needed), `revoked`, or `refused` (two freshly minted tickets refused in a row with no more specific classification). A transient mint failure (network, timeout, 408, 429, 5xx) backs off and retries; a lapsed ticket lease re-mints.
|
|
46
|
+
- **Stopping.** Last, one `{"type":"stopped","reason":"…","detail":"…","fix":"…"}` and exit, only on a terminal outcome. `fix` is the remedy shown to the session (`STOP_FIXES` in `src/bridge.ts` — never a log-only hint), so a session that hits a stop it cannot otherwise see still knows what to do about it. Terminal reasons: `no_connection_record` / `credential_unavailable` / `credential_mismatch` (the start-time checks above), `not_connected` (a `409 session_not_connected` at mint OR at stream admission, or a live `end("not_connected")` — all three mapped identically), `plan_archived` (DX-3809 — the plan was archived or deleted while the stream was live; the session is disconnected, and reconnects by hand after a restore), `unauthorized` (401/403), `mint_refused` (any other non-transient mint refusal), `mint_bad_response`, `superseded` / `replaced` (exit 0 — a newer or replacing connection already serves this session, so `fix` says no action is needed), `revoked`, or `refused` (two freshly minted tickets refused in a row with no more specific classification). A transient mint failure (network, timeout, 408, 429, 5xx) backs off and retries; a lapsed ticket lease re-mints.
|
|
47
47
|
- **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.
|
|
48
48
|
|
|
49
49
|
## Build + test
|
package/dist/bridge.js
CHANGED
|
@@ -51,6 +51,8 @@
|
|
|
51
51
|
* - `not_connected` the mint, or the stream's admission / keep-alive,
|
|
52
52
|
* answered `session_not_connected` (409 at mint and
|
|
53
53
|
* at admission; `end("not_connected")` once live);
|
|
54
|
+
* - `plan_archived` DX-3809 — the plan was archived or deleted while the
|
|
55
|
+
* stream was live (`end("plan_archived")`);
|
|
54
56
|
* - `unauthorized` the mint answered 401 / 403;
|
|
55
57
|
* - `mint_refused` any other non-transient mint refusal (400, 404, …);
|
|
56
58
|
* - `mint_bad_response` a 2xx mint whose body is not a ticket;
|
|
@@ -209,6 +211,7 @@ export const STOP_FIXES = {
|
|
|
209
211
|
credential_unavailable: "fix the credential this session's danx-dashboard MCP server declares, then call plan_connect again",
|
|
210
212
|
credential_mismatch: "restart this Claude Code session so its MCP server and its bridge pick up the same credential, then call plan_connect again",
|
|
211
213
|
not_connected: "call plan_connect to connect this session to a plan",
|
|
214
|
+
plan_archived: "the plan was archived or deleted, so this session was disconnected; ask the operator to restore it, then call plan_connect again",
|
|
212
215
|
unauthorized: "give this session's danx-dashboard MCP server a dashboard credential that is accepted, then call plan_connect again",
|
|
213
216
|
mint_refused: "check that this session is connected to a plan on the dashboard its MCP server points at, then call plan_connect again",
|
|
214
217
|
mint_bad_response: "check that DANXBOT_DASHBOARD_URL for this session's MCP server points at a danxbot dashboard",
|
|
@@ -516,6 +519,7 @@ function classifyAdmissionRefusal(refusal) {
|
|
|
516
519
|
const EXIT_NEVER_RESTART_LIST = [
|
|
517
520
|
"no_connection_record",
|
|
518
521
|
"not_connected",
|
|
522
|
+
"plan_archived",
|
|
519
523
|
"superseded",
|
|
520
524
|
"replaced",
|
|
521
525
|
"session_is_worker",
|
package/dist/index.js
CHANGED
|
@@ -422,7 +422,7 @@ strictTool("attach_file", "Attach a LOCAL file to a card: `card_id` + `path` (an
|
|
|
422
422
|
// no existing plan for an id to name yet.
|
|
423
423
|
strictTool("plan_connect",
|
|
424
424
|
// DX-2735: trimmed with the problem tools to stay inside the work-profile budget.
|
|
425
|
-
"Connect THIS session to a plan. ONE CALL IS ENOUGH TO START: the reply carries `{session, movedFrom, browserInstruction, briefing, listenerHealth}` — `browserInstruction` is a server-built action to take NOW: it names the plan's URL and tells you to open it (in-app browser if you have one, else default), keep that tab open for the whole session without navigating it away, and use a different tab for your own browsing — the operator's tab for following and talking to you. Returned on every connect, including a re-connect, so it doubles as the post-context-loss reminder. `briefing` is every goal/rule/caveat (ref+body), every architecture section (id+title), the plan's own ref/name/status, a first page of open cards (id/type/status/title/openProblemCount/assignedAgent) with a `morePagesHint` when more exist, and the closed-card count — usually replacing the plan read + card batch-read a fresh session used to need. A session is on AT MOST ONE plan: connecting elsewhere MOVES it, and `movedFrom: {id, name} | null` says which plan it left (null = no plan, or already this one). Binds only your OWN session, resolved from the session id this server forwards; afterwards a `danxbot_api` call to a `/api/plans/mine/...` route (there is no dedicated plan-write tool) acts on this plan and takes no plan id. `listenerHealth` reports the event bridge's state — `null` (no session), or `{attached, state: \"unattached\"|\"credential_mismatch\"|\"healthy\", nextStep}` naming a concrete fix per unhealthy state (same shape the plan list/get routes report). Once healthy, every comment, answer, problem added and block/unblock on this plan's cards reaches the session unpolled, relayed by the plugin's event bridge as a line like `[DX-8 \"Title\" repo:board] newms87 answered \"<statement>\": chose \"Pause E2E\"`. Pass `title` (call `get_session({session_id:\"self\"})` first and forward its `title` verbatim) so the dashboard shows the same name Claude does — this server cannot read it itself. The response's `reminders[]` (when present) carries the mantra — read and follow it.", {
|
|
425
|
+
"Connect THIS session to a plan. ONE CALL IS ENOUGH TO START: the reply carries `{session, movedFrom, browserInstruction, briefing, listenerHealth}` — `browserInstruction` is a server-built action to take NOW: it names the plan's URL and tells you to open it (in-app browser if you have one, else default), keep that tab open for the whole session without navigating it away, and use a different tab for your own browsing — the operator's tab for following and talking to you. Returned on every connect, including a re-connect, so it doubles as the post-context-loss reminder. `briefing` is every goal/rule/caveat (ref+body), every architecture section (id+title), the plan's own ref/name/status, a first page of open cards (id/type/status/title/openProblemCount/assignedAgent) with a `morePagesHint` when more exist, and the closed-card count — usually replacing the plan read + card batch-read a fresh session used to need. A session is on AT MOST ONE plan: connecting elsewhere MOVES it, and `movedFrom: {id, name} | null` says which plan it left (null = no plan, or already this one). Binds only your OWN session, resolved from the session id this server forwards; afterwards a `danxbot_api` call to a `/api/plans/mine/...` route (there is no dedicated plan-write tool) acts on this plan and takes no plan id. `listenerHealth` reports the event bridge's state — `null` (no session), or `{attached, state: \"unattached\"|\"credential_mismatch\"|\"healthy\", nextStep}` naming a concrete fix per unhealthy state (same shape the plan list/get routes report). Once healthy, every comment, answer, problem added and block/unblock on this plan's cards reaches the session unpolled, relayed by the plugin's event bridge as a line like `[DX-8 \"Title\" repo:board] newms87 answered \"<statement>\": chose \"Pause E2E\"`. An action the operator DECLINES arrives as `... rejected the action \"<title>\" for \"<statement>\" — reason: \"<note>\". Do not perform it; re-plan from the reason` (a decision with `outcome: \"rejected\"` — never treat it as work done). Pass `title` (call `get_session({session_id:\"self\"})` first and forward its `title` verbatim) so the dashboard shows the same name Claude does — this server cannot read it itself. The response's `reminders[]` (when present) carries the mantra — read and follow it.", {
|
|
426
426
|
plan_id: z.number().int().positive().describe("The plan id, from the plans list (`danxbot_api` `GET /api/plans`)."),
|
|
427
427
|
title: z
|
|
428
428
|
.string()
|
package/dist/listen.js
CHANGED
|
@@ -78,6 +78,27 @@ function isRecord(value) {
|
|
|
78
78
|
function isCappedText(value) {
|
|
79
79
|
return isRecord(value) && typeof value.text === "string" && typeof value.truncated === "boolean";
|
|
80
80
|
}
|
|
81
|
+
/**
|
|
82
|
+
* DX-3880 — the part `solution_answered` and `problem_answer_changed` share: the
|
|
83
|
+
* decision's `outcome` and the either/or body it implies. A rejection (the
|
|
84
|
+
* operator DECLINED an action) always names the solution and carries its reason
|
|
85
|
+
* as `solution.note`; an answer is a solution (optional note) or free-form text.
|
|
86
|
+
*/
|
|
87
|
+
function invalidDecisionBody(d) {
|
|
88
|
+
if (d.outcome !== "answered" && d.outcome !== "rejected")
|
|
89
|
+
return 'detail.outcome is not "answered" or "rejected"';
|
|
90
|
+
if (d.solution === null) {
|
|
91
|
+
if (d.outcome === "rejected")
|
|
92
|
+
return "detail.solution is null on a rejection";
|
|
93
|
+
return isCappedText(d.freeform) ? null : "detail.freeform is not capped text";
|
|
94
|
+
}
|
|
95
|
+
if (!isRecord(d.solution) || typeof d.solution.title !== "string")
|
|
96
|
+
return "detail.solution is not {title, note}";
|
|
97
|
+
if (d.outcome === "rejected") {
|
|
98
|
+
return isCappedText(d.solution.note) ? null : "detail.solution.note (the rejection reason) is not capped text";
|
|
99
|
+
}
|
|
100
|
+
return d.solution.note === null || isCappedText(d.solution.note) ? null : "detail.solution.note is not capped text";
|
|
101
|
+
}
|
|
81
102
|
/**
|
|
82
103
|
* DX-2735 — why an event cannot be described, or `null` when it can.
|
|
83
104
|
*
|
|
@@ -122,11 +143,7 @@ export function invalidEventReason(value) {
|
|
|
122
143
|
if (!isRecord(problem) || typeof problem.id !== "number" || !isCappedText(problem.statement)) {
|
|
123
144
|
return "detail.problem is not {id, statement}";
|
|
124
145
|
}
|
|
125
|
-
|
|
126
|
-
return isCappedText(d.freeform) ? null : "detail.freeform is not capped text";
|
|
127
|
-
if (!isRecord(d.solution) || typeof d.solution.title !== "string")
|
|
128
|
-
return "detail.solution is not {title, note}";
|
|
129
|
-
return d.solution.note === null || isCappedText(d.solution.note) ? null : "detail.solution.note is not capped text";
|
|
146
|
+
return invalidDecisionBody(d);
|
|
130
147
|
}
|
|
131
148
|
case "problem_added": {
|
|
132
149
|
const problem = d.problem;
|
|
@@ -155,11 +172,7 @@ export function invalidEventReason(value) {
|
|
|
155
172
|
return "detail.retracted_decision_id is not a number";
|
|
156
173
|
if (typeof d.decision_id !== "number")
|
|
157
174
|
return "detail.decision_id is not a number";
|
|
158
|
-
|
|
159
|
-
return isCappedText(d.freeform) ? null : "detail.freeform is not capped text";
|
|
160
|
-
if (!isRecord(d.solution) || typeof d.solution.title !== "string")
|
|
161
|
-
return "detail.solution is not {title, note}";
|
|
162
|
-
return d.solution.note === null || isCappedText(d.solution.note) ? null : "detail.solution.note is not capped text";
|
|
175
|
+
return invalidDecisionBody(d);
|
|
163
176
|
}
|
|
164
177
|
case "blocked":
|
|
165
178
|
return isCappedText(d.reason) ? null : "detail.reason is not capped text";
|
|
@@ -196,8 +209,13 @@ function describe(event) {
|
|
|
196
209
|
// so the line names the problem — otherwise the agent cannot tell which of its
|
|
197
210
|
// questions just closed.
|
|
198
211
|
const problem = d.problem;
|
|
199
|
-
const answered = `${who} answered ${quoted(problem.statement)}:`;
|
|
200
212
|
const solution = d.solution;
|
|
213
|
+
// DX-3880 — a REJECTION is the opposite of work done: the operator declined
|
|
214
|
+
// the action, so the line says so and names the reason; the agent must not
|
|
215
|
+
// perform it and re-plans from the note.
|
|
216
|
+
if (d.outcome === "rejected")
|
|
217
|
+
return `${who} rejected the action "${solution.title}" for ${quoted(problem.statement)} — reason: ${quoted(solution.note)}. Do not perform it; re-plan from the reason`;
|
|
218
|
+
const answered = `${who} answered ${quoted(problem.statement)}:`;
|
|
201
219
|
if (solution === null)
|
|
202
220
|
return `${answered} ${quoted(d.freeform)}`;
|
|
203
221
|
const note = solution.note === null ? "" : ` — note: ${quoted(solution.note)}`;
|
|
@@ -222,6 +240,8 @@ function describe(event) {
|
|
|
222
240
|
// open questions knows which one just moved.
|
|
223
241
|
const problem = d.problem;
|
|
224
242
|
const solution = d.solution;
|
|
243
|
+
if (d.outcome === "rejected")
|
|
244
|
+
return `${who} changed the answer to ${quoted(problem.statement)}: now REJECTED the action "${solution.title}" — reason: ${quoted(solution.note)}. Do not perform it; re-plan from the reason`;
|
|
225
245
|
const now = solution === null ? quoted(d.freeform) : `"${solution.title}"`;
|
|
226
246
|
return `${who} changed the answer to ${quoted(problem.statement)}: now ${now}`;
|
|
227
247
|
}
|
|
@@ -353,7 +373,7 @@ function parseBlock(block) {
|
|
|
353
373
|
* than passing the raw string through, which is what lets `Outcome`'s
|
|
354
374
|
* `reason` field below be a closed union instead of `string`.
|
|
355
375
|
*/
|
|
356
|
-
const KNOWN_WIRE_END_REASONS = new Set(["superseded", "replaced", "revoked", "not_connected"]);
|
|
376
|
+
const KNOWN_WIRE_END_REASONS = new Set(["superseded", "replaced", "revoked", "not_connected", "plan_archived"]);
|
|
357
377
|
function isKnownWireEndReason(reason) {
|
|
358
378
|
return KNOWN_WIRE_END_REASONS.has(reason);
|
|
359
379
|
}
|
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.189",
|
|
4
4
|
"description": "Stdio MCP server fronting danxbot's dashboard API for agents (operator sessions and dispatched workers alike): the generic danxbot_api/danxbot_api_list/danxbot_api_spec tools reach any route the caller's credential may call, plus dedicated agent-specific tools (plan_connect, danxbot_complete, and other per-dispatch lifecycle callbacks) (DX-3465, PLN-11 R-25).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|