@thehammer/danx-dashboard-mcp 0.1.201 → 0.1.203

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -46,6 +46,10 @@ CLAUDE_CODE_SESSION_ID=<session> npx -y @thehammer/danx-dashboard-mcp@<version>
46
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
+ ## `restart-notice` — what a restarted session was working on (DX-3928)
50
+
51
+ Run by the danxbot plugin's SessionStart hook for a session that is **not** plan-connected. A restarted Claude Code session gets a new session id and no plan binding, so nothing told it that the plan it had been working was unattended. `plan_connect` writes the session's working directory into its connection record (`projectKey`, record schema v2); this subcommand finds the newest OTHER session's record for the same project within `STALE_RECORD_MS`, resolves that record's own credential, and asks `GET /api/plan-sessions/<predecessor>/restart-notice` which plan it was on and how many operator events wait there (the connect digest's own query, read-only). Exit 0 with text on stdout = say it (the registry's `plan_restart.waiting_events` text — this package carries no wording); exit 0 with nothing = nothing to say (never connected from this project, plan archived or deleted, record stale); exit 1 = the lookup failed. A record from an older server (schema v1) is skipped here and refused, with the fix, everywhere a session reads its OWN record.
52
+
49
53
  ## Build + test
50
54
 
51
55
  ```bash
package/dist/index.js CHANGED
@@ -77,8 +77,9 @@ import { BRIDGE_SUBCOMMAND, runBridgeCommand } from "./bridge.js";
77
77
  import { BACKGROUND_WORK_SUBCOMMAND, runBackgroundWorkCommand } from "./background-work.js";
78
78
  import { ACTIVITY_SUBCOMMAND, runActivityCommand } from "./activity.js";
79
79
  import { EVENT_TEXT_SUBCOMMAND, runEventTextCommand } from "./event-text.js";
80
+ import { RESTART_NOTICE_SUBCOMMAND, runRestartNoticeCommand } from "./restart-notice.js";
80
81
  import { resolveDeclaredCredential } from "./credential.js";
81
- import { recordSessionConnectionAfterConnect } from "./session-connection.js";
82
+ import { projectKeyOf, recordSessionConnectionAfterConnect } from "./session-connection.js";
82
83
  import { createListenerReminder, isPlanConnectedOnThisMachine, } from "./listener-reminder.js";
83
84
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
84
85
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
@@ -448,7 +449,7 @@ strictTool("attach_file", "Attach a LOCAL file to a card: `card_id` + `path` (an
448
449
  // no existing plan for an id to name yet.
449
450
  strictTool("plan_connect",
450
451
  // DX-2735: trimmed with the problem tools to stay inside the work-profile budget.
451
- "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.", {
452
+ "Connect THIS session to a plan. ONE CALL IS ENOUGH TO START: the reply carries `{session, movedFrom, browserInstruction, briefing, listenerHealth, missedEvents}` — `missedEvents` is `{events, omitted}`: the operator events on this plan that no session had been told about before you connected, oldest first, newest 50 at most (`omitted` counts older ones) — read them before anything else. `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.", {
452
453
  plan_id: z.number().int().positive().describe("The plan id, from the plans list (`danxbot_api` `GET /api/plans`)."),
453
454
  title: z
454
455
  .string()
@@ -468,6 +469,7 @@ async (args) => {
468
469
  ...result,
469
470
  ...recordSessionConnectionAfterConnect(result, {
470
471
  sessionId: config.session?.id,
472
+ projectKey: projectKeyOf(process.cwd()),
471
473
  dashboardUrl: connection.dashboardUrl,
472
474
  source: connection.source,
473
475
  fingerprint: connection.fingerprint,
@@ -534,9 +536,16 @@ if (isEntrypointModule(import.meta.url, process.argv[1])) {
534
536
  process.exit(1);
535
537
  });
536
538
  }
539
+ else if (subcommand === RESTART_NOTICE_SUBCOMMAND) {
540
+ // DX-3928 — same exit-code contract as event-text: 0 (text, or nothing to say) / 1 (lookup failed).
541
+ runRestartNoticeCommand(rest).then((code) => process.exit(code), (err) => {
542
+ console.error(`[danx-dashboard-mcp] restart-notice fatal: ${err.message}`);
543
+ process.exit(1);
544
+ });
545
+ }
537
546
  else if (subcommand !== undefined) {
538
547
  console.error(`[danx-dashboard-mcp] unknown subcommand "${subcommand}" (the only ones are ` +
539
- `"${BRIDGE_SUBCOMMAND}", "${BACKGROUND_WORK_SUBCOMMAND}", "${ACTIVITY_SUBCOMMAND}", and "${EVENT_TEXT_SUBCOMMAND}")`);
548
+ `"${BRIDGE_SUBCOMMAND}", "${BACKGROUND_WORK_SUBCOMMAND}", "${ACTIVITY_SUBCOMMAND}", "${EVENT_TEXT_SUBCOMMAND}", and "${RESTART_NOTICE_SUBCOMMAND}")`);
540
549
  process.exit(2);
541
550
  }
542
551
  else {
@@ -0,0 +1,140 @@
1
+ /**
2
+ * `danx-dashboard-mcp restart-notice` — DX-3928. The ONE-SHOT client the
3
+ * claude-plugins `danxbot` plugin's SessionStart hook runs for a session that is
4
+ * NOT plan-connected, to tell it when the previous session of the same project
5
+ * was working a plan.
6
+ *
7
+ * WHY IT EXISTS. A restarted Claude Code session gets a NEW session id and no
8
+ * plan binding, and nothing told it (or the operator) that the plan it had been
9
+ * working was unattended: six operator answers sat unread for an hour on
10
+ * 2026-10-01. The session's own connection record cannot help (it is the new
11
+ * session's, and does not exist yet); the PREVIOUS session's record can, found by
12
+ * the project key `plan_connect` wrote into it (`findPredecessorConnection`).
13
+ *
14
+ * THE CONTRACT, which `event-hook.sh` reads by exit code:
15
+ * - exit 0, text on stdout: say this to the agent (the registry's
16
+ * `plan_restart.waiting_events` text — this module carries no wording).
17
+ * - exit 0, nothing on stdout: nothing to say — no earlier session of this
18
+ * project connected a plan, or the plan is archived/deleted, or the record is
19
+ * stale. The plugin stays silent, as PLN-11 R-10 requires of a project that
20
+ * never connected a plan.
21
+ * - exit 1, reason on stderr: the lookup itself failed (credential, network,
22
+ * non-2xx, bad shape). Never silent: the hook prints a could-not-load notice.
23
+ *
24
+ * The credential is the PREDECESSOR's own record's source, resolved through the
25
+ * same resolver the bridge uses, so the lookup reaches the dashboard that
26
+ * connected it. READ-ONLY: it advances no cursor on the server.
27
+ */
28
+ import { SESSION_ID_HEADER, resolveBridgeOptions } from "./bridge.js";
29
+ import { findPredecessorConnection, projectKeyOf } from "./session-connection.js";
30
+ export const RESTART_NOTICE_SUBCOMMAND = "restart-notice";
31
+ export const RESTART_NOTICE_REQUEST_TIMEOUT_MS = 5_000;
32
+ /** The registry row whose text is the notice (`src/issues/reminders/seed.yaml`). */
33
+ export const RESTART_NOTICE_REMINDER_KEY = "plan_restart.waiting_events";
34
+ const USAGE = `usage: CLAUDE_CODE_SESSION_ID=<session-id> danx-dashboard-mcp ${RESTART_NOTICE_SUBCOMMAND} [--cwd <project directory>]`;
35
+ /** One authenticated GET with a hard timeout, answering the notice text or `null`. */
36
+ export async function fetchRestartNotice(target, deps) {
37
+ const controller = new AbortController();
38
+ const timer = setTimeout(() => controller.abort(), deps.requestTimeoutMs);
39
+ let status;
40
+ let text;
41
+ try {
42
+ try {
43
+ const response = await deps.fetch(`${target.dashboardUrl}/api/plan-sessions/${encodeURIComponent(target.predecessorId)}/restart-notice`, {
44
+ method: "GET",
45
+ headers: {
46
+ Authorization: `Bearer ${target.token}`,
47
+ Accept: "application/json",
48
+ [SESSION_ID_HEADER]: target.askingSessionId,
49
+ },
50
+ signal: controller.signal,
51
+ });
52
+ status = response.status;
53
+ text = await response.text();
54
+ }
55
+ catch {
56
+ return { ok: false, reason: controller.signal.aborted ? "timeout" : "request_failed" };
57
+ }
58
+ }
59
+ finally {
60
+ clearTimeout(timer);
61
+ }
62
+ if (status === 401 || status === 403)
63
+ return { ok: false, reason: "unauthorized" };
64
+ if (status === 404)
65
+ return { ok: false, reason: "not_found" };
66
+ if (status < 200 || status >= 300)
67
+ return { ok: false, reason: "http_error" };
68
+ let body;
69
+ try {
70
+ body = JSON.parse(text);
71
+ }
72
+ catch {
73
+ return { ok: false, reason: "bad_response" };
74
+ }
75
+ if (typeof body !== "object" || body === null || !("restart" in body))
76
+ return { ok: false, reason: "bad_response" };
77
+ if (body.restart === null)
78
+ return { ok: true, text: null };
79
+ // A notice exists, so the registry row MUST have answered with its text: a
80
+ // missing one is a server/registry fault, reported rather than papered over.
81
+ const reminders = Array.isArray(body.reminders) ? body.reminders : [];
82
+ const notice = reminders.find((r) => r.key === RESTART_NOTICE_REMINDER_KEY);
83
+ if (typeof notice?.text !== "string" || notice.text === "")
84
+ return { ok: false, reason: "bad_response" };
85
+ return { ok: true, text: notice.text };
86
+ }
87
+ function parseCwd(argv) {
88
+ if (argv.length === 0)
89
+ return process.cwd();
90
+ if (argv.length === 2 && argv[0] === "--cwd" && argv[1] !== "")
91
+ return argv[1];
92
+ return "invalid";
93
+ }
94
+ /** The bin's `restart-notice` subcommand, wired to the real process. */
95
+ export async function runRestartNoticeCommand(argv, env = process.env,
96
+ /** Test seam: the home the connection records are read from. */
97
+ resolveFrom = {}) {
98
+ const cwd = parseCwd(argv);
99
+ if (cwd === "invalid") {
100
+ process.stderr.write(`bad arguments\n${USAGE}\n`);
101
+ return 1;
102
+ }
103
+ const sessionId = env.CLAUDE_CODE_SESSION_ID;
104
+ if (!sessionId) {
105
+ process.stderr.write(`no_session_id: CLAUDE_CODE_SESSION_ID is not set\n${USAGE}\n`);
106
+ return 1;
107
+ }
108
+ let predecessor;
109
+ try {
110
+ predecessor = findPredecessorConnection(projectKeyOf(cwd), sessionId, resolveFrom);
111
+ }
112
+ catch (err) {
113
+ process.stderr.write(`unreadable_records: ${err.message}\n`);
114
+ return 1;
115
+ }
116
+ if (predecessor === null)
117
+ return 0;
118
+ let options;
119
+ try {
120
+ options = resolveBridgeOptions({ sessionId: predecessor.sessionId, resumeIds: [] }, env, resolveFrom);
121
+ }
122
+ catch (err) {
123
+ const start = err;
124
+ process.stderr.write(`${start.reason}: ${start.message}. Fix: ${start.fix}.\n`);
125
+ return 1;
126
+ }
127
+ const output = await fetchRestartNotice({
128
+ dashboardUrl: options.dashboardUrl,
129
+ token: options.token,
130
+ predecessorId: predecessor.sessionId,
131
+ askingSessionId: sessionId,
132
+ }, { fetch: (input, init) => fetch(input, init), requestTimeoutMs: RESTART_NOTICE_REQUEST_TIMEOUT_MS });
133
+ if (!output.ok) {
134
+ process.stderr.write(`${output.reason}: could not ask the dashboard what the previous session of this project was working\n`);
135
+ return 1;
136
+ }
137
+ if (output.text !== null)
138
+ process.stdout.write(output.text);
139
+ return 0;
140
+ }
@@ -31,10 +31,17 @@
31
31
  */
32
32
  import { mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
33
33
  import { homedir } from "node:os";
34
- import { join } from "node:path";
34
+ import { join, resolve } from "node:path";
35
35
  /** One canonical shape (CLAUDE.md Core Principle 1) — a record of any other version is refused, never adapted. */
36
- export const SESSION_CONNECTION_SCHEMA_VERSION = 1;
36
+ // DX-3928 — v2 adds `projectKey`, the directory the connecting session ran in, so a
37
+ // restarted session (a NEW session id, no record of its own) can find which plan the
38
+ // previous session of the same project was on (`findPredecessorConnection`).
39
+ export const SESSION_CONNECTION_SCHEMA_VERSION = 2;
37
40
  export const STALE_RECORD_MS = 7 * 24 * 60 * 60 * 1000;
41
+ /** DX-3928 — the one definition of "the same project": the resolved working directory. */
42
+ export function projectKeyOf(cwd) {
43
+ return resolve(cwd);
44
+ }
38
45
  export function sessionConnectionDir(home = homedir()) {
39
46
  return join(home, ".config", "danxbot", "plan-sessions");
40
47
  }
@@ -140,6 +147,7 @@ export function recordSessionConnectionAfterConnect(result, inputs, write = writ
140
147
  try {
141
148
  write({
142
149
  sessionId: inputs.sessionId,
150
+ projectKey: inputs.projectKey,
143
151
  dashboardUrl: inputs.dashboardUrl,
144
152
  source: inputs.source,
145
153
  fingerprint: inputs.fingerprint,
@@ -159,9 +167,10 @@ export function invalidRecordReason(value) {
159
167
  return "it is not an object";
160
168
  const r = value;
161
169
  if (r.schemaVersion !== SESSION_CONNECTION_SCHEMA_VERSION) {
162
- return `schemaVersion is ${JSON.stringify(r.schemaVersion)}, not ${SESSION_CONNECTION_SCHEMA_VERSION}`;
170
+ return (`schemaVersion is ${JSON.stringify(r.schemaVersion)}, not ${SESSION_CONNECTION_SCHEMA_VERSION} — ` +
171
+ "a session connected by an older MCP server wrote it; call plan_connect again to rewrite it");
163
172
  }
164
- for (const field of ["sessionId", "dashboardUrl", "fingerprint", "connectedAt"]) {
173
+ for (const field of ["sessionId", "projectKey", "dashboardUrl", "fingerprint", "connectedAt"]) {
165
174
  if (typeof r[field] !== "string" || r[field] === "")
166
175
  return `${field} is not a non-empty string`;
167
176
  }
@@ -174,3 +183,47 @@ export function invalidRecordReason(value) {
174
183
  return typeof source.name === "string" && source.name !== "" ? null : "source.name is not a variable name";
175
184
  return `source.kind is ${JSON.stringify(source.kind)}, which this version does not know`;
176
185
  }
186
+ /**
187
+ * DX-3928 — the record of the session that ran in `projectKey` before
188
+ * `currentSessionId`: the newest record for that project, written by another
189
+ * session, within `STALE_RECORD_MS`. `null` when there is none.
190
+ *
191
+ * A record that cannot be understood (another schema version, not JSON) is
192
+ * SKIPPED here and nowhere else: this answers "was a plan being worked in this
193
+ * project", and a record from an older server cannot say. Every consumer of a
194
+ * session's OWN record (`readSessionConnection`) still fails loud.
195
+ */
196
+ export function findPredecessorConnection(projectKey, currentSessionId, options = {}) {
197
+ const dir = sessionConnectionDir(options.home ?? homedir());
198
+ const now = options.now ?? Date.now();
199
+ let names;
200
+ try {
201
+ names = readdirSync(dir);
202
+ }
203
+ catch {
204
+ return null; // nothing written yet
205
+ }
206
+ let newest = null;
207
+ for (const name of names) {
208
+ if (!name.endsWith(".json"))
209
+ continue;
210
+ let parsed;
211
+ try {
212
+ parsed = JSON.parse(readFileSync(join(dir, name), "utf-8"));
213
+ }
214
+ catch {
215
+ continue;
216
+ }
217
+ if (invalidRecordReason(parsed) !== null)
218
+ continue;
219
+ const record = parsed;
220
+ if (record.projectKey !== projectKey || record.sessionId === currentSessionId)
221
+ continue;
222
+ const connectedAt = Date.parse(record.connectedAt);
223
+ if (!Number.isFinite(connectedAt) || now - connectedAt > STALE_RECORD_MS)
224
+ continue;
225
+ if (newest === null || connectedAt > Date.parse(newest.connectedAt))
226
+ newest = record;
227
+ }
228
+ return newest;
229
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thehammer/danx-dashboard-mcp",
3
- "version": "0.1.201",
3
+ "version": "0.1.203",
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",