@thehammer/danx-dashboard-mcp 0.1.204 → 0.1.206

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/dist/index.js CHANGED
@@ -463,7 +463,7 @@ strictTool("attach_file", "Attach a LOCAL file to a card: `card_id` + `path` (an
463
463
  // no existing plan for an id to name yet.
464
464
  strictTool("plan_connect",
465
465
  // DX-2735: trimmed with the problem tools to stay inside the work-profile budget.
466
- "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.", {
466
+ "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, capped to the newest few (`omitted` counts the older ones left out) — 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.", {
467
467
  plan_id: z.number().int().positive().describe("The plan id, from the plans list (`danxbot_api` `GET /api/plans`)."),
468
468
  title: z
469
469
  .string()
@@ -9,7 +9,7 @@
9
9
  * working was unattended: six operator answers sat unread for an hour on
10
10
  * 2026-10-01. The session's own connection record cannot help (it is the new
11
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`).
12
+ * the project key `plan_connect` wrote into it (`findPredecessorCandidates`).
13
13
  *
14
14
  * THE CONTRACT, which `event-hook.sh` reads by exit code:
15
15
  * - exit 0, text on stdout: say this to the agent (the registry's
@@ -26,9 +26,15 @@
26
26
  * connected it. READ-ONLY: it advances no cursor on the server.
27
27
  */
28
28
  import { SESSION_ID_HEADER, resolveBridgeOptions } from "./bridge.js";
29
- import { findPredecessorConnection, projectKeyOf } from "./session-connection.js";
29
+ import { findPredecessorCandidates, projectKeyOf } from "./session-connection.js";
30
30
  export const RESTART_NOTICE_SUBCOMMAND = "restart-notice";
31
31
  export const RESTART_NOTICE_REQUEST_TIMEOUT_MS = 5_000;
32
+ /**
33
+ * How many earlier sessions of the project one run asks about, newest first. Many
34
+ * sessions share one checkout and the dashboard answers "nothing" for a LIVE sibling
35
+ * (it is not a predecessor), so the newest candidate is often not the one.
36
+ */
37
+ export const RESTART_NOTICE_MAX_CANDIDATES = 10;
32
38
  /** The registry row whose text is the notice (`src/issues/reminders/seed.yaml`). */
33
39
  export const RESTART_NOTICE_REMINDER_KEY = "plan_restart.waiting_events";
34
40
  const USAGE = `usage: CLAUDE_CODE_SESSION_ID=<session-id> danx-dashboard-mcp ${RESTART_NOTICE_SUBCOMMAND} [--cwd <project directory>]`;
@@ -105,36 +111,31 @@ resolveFrom = {}) {
105
111
  process.stderr.write(`no_session_id: CLAUDE_CODE_SESSION_ID is not set\n${USAGE}\n`);
106
112
  return 1;
107
113
  }
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;
114
+ const candidates = findPredecessorCandidates(projectKeyOf(cwd), sessionId, resolveFrom).slice(0, RESTART_NOTICE_MAX_CANDIDATES);
115
+ for (const predecessor of candidates) {
116
+ let options;
117
+ try {
118
+ options = resolveBridgeOptions({ sessionId: predecessor.sessionId, resumeIds: [] }, env, resolveFrom);
119
+ }
120
+ catch (err) {
121
+ const start = err;
122
+ process.stderr.write(`${start.reason}: ${start.message}. Fix: ${start.fix}.\n`);
123
+ return 1;
124
+ }
125
+ const output = await fetchRestartNotice({
126
+ dashboardUrl: options.dashboardUrl,
127
+ token: options.token,
128
+ predecessorId: predecessor.sessionId,
129
+ askingSessionId: sessionId,
130
+ }, { fetch: (input, init) => fetch(input, init), requestTimeoutMs: RESTART_NOTICE_REQUEST_TIMEOUT_MS });
131
+ if (!output.ok) {
132
+ process.stderr.write(`${output.reason}: could not ask the dashboard what the previous session of this project was working\n`);
133
+ return 1;
134
+ }
135
+ if (output.text !== null) {
136
+ process.stdout.write(output.text);
137
+ return 0;
138
+ }
136
139
  }
137
- if (output.text !== null)
138
- process.stdout.write(output.text);
139
140
  return 0;
140
141
  }
@@ -31,16 +31,25 @@
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, resolve } from "node:path";
34
+ import { join, resolve, win32 } from "node:path";
35
35
  /** One canonical shape (CLAUDE.md Core Principle 1) — a record of any other version is refused, never adapted. */
36
36
  // DX-3928 — v2 adds `projectKey`, the directory the connecting session ran in, so a
37
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`).
38
+ // previous session of the same project was on (`findPredecessorCandidates`).
39
39
  export const SESSION_CONNECTION_SCHEMA_VERSION = 2;
40
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. */
41
+ /**
42
+ * DX-3928 — the one definition of "the same project": the resolved working
43
+ * directory, normalised so the SAME directory reached two ways compares equal.
44
+ * A Windows path is written with forward slashes and a lower-case drive letter
45
+ * (`C:\Work\p` and `c:/Work/p` are one key), because the MCP server and the
46
+ * plugin's hook can each report the cwd in either spelling.
47
+ */
42
48
  export function projectKeyOf(cwd) {
43
- return resolve(cwd);
49
+ if (!/^[A-Za-z]:[\\/]/.test(cwd))
50
+ return resolve(cwd);
51
+ const resolved = win32.resolve(cwd).replace(/\\/g, "/");
52
+ return resolved[0].toLowerCase() + resolved.slice(1);
44
53
  }
45
54
  export function sessionConnectionDir(home = homedir()) {
46
55
  return join(home, ".config", "danxbot", "plan-sessions");
@@ -184,16 +193,18 @@ export function invalidRecordReason(value) {
184
193
  return `source.kind is ${JSON.stringify(source.kind)}, which this version does not know`;
185
194
  }
186
195
  /**
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.
196
+ * DX-3928 — the records of the sessions that ran in `projectKey` before
197
+ * `currentSessionId`, NEWEST FIRST: other sessions' records for that project,
198
+ * within `STALE_RECORD_MS`. Which of them is a real predecessor (not a live
199
+ * sibling in the same checkout) is the dashboard's call, not this reader's, so
200
+ * all candidates are returned; the caller asks about them in order.
190
201
  *
191
202
  * A record that cannot be understood (another schema version, not JSON) is
192
203
  * SKIPPED here and nowhere else: this answers "was a plan being worked in this
193
204
  * project", and a record from an older server cannot say. Every consumer of a
194
205
  * session's OWN record (`readSessionConnection`) still fails loud.
195
206
  */
196
- export function findPredecessorConnection(projectKey, currentSessionId, options = {}) {
207
+ export function findPredecessorCandidates(projectKey, currentSessionId, options = {}) {
197
208
  const dir = sessionConnectionDir(options.home ?? homedir());
198
209
  const now = options.now ?? Date.now();
199
210
  let names;
@@ -201,9 +212,9 @@ export function findPredecessorConnection(projectKey, currentSessionId, options
201
212
  names = readdirSync(dir);
202
213
  }
203
214
  catch {
204
- return null; // nothing written yet
215
+ return []; // nothing written yet
205
216
  }
206
- let newest = null;
217
+ const found = [];
207
218
  for (const name of names) {
208
219
  if (!name.endsWith(".json"))
209
220
  continue;
@@ -222,8 +233,7 @@ export function findPredecessorConnection(projectKey, currentSessionId, options
222
233
  const connectedAt = Date.parse(record.connectedAt);
223
234
  if (!Number.isFinite(connectedAt) || now - connectedAt > STALE_RECORD_MS)
224
235
  continue;
225
- if (newest === null || connectedAt > Date.parse(newest.connectedAt))
226
- newest = record;
236
+ found.push({ record, connectedAt });
227
237
  }
228
- return newest;
238
+ return found.sort((x, y) => y.connectedAt - x.connectedAt).map((f) => f.record);
229
239
  }
@@ -143,7 +143,7 @@ export class OperatorSessionStats {
143
143
  // Replays are credited (the ledger is idempotent) and marked so the
144
144
  // collector's own estimate never counts them twice; the bridge has no
145
145
  // side-effecting transcript subscriber to skip them for.
146
- watcher.onEntry((entry, meta) => this.usage.observeEntry(entry, meta.replay));
146
+ watcher.onUsageLine((raw, meta) => this.usage.observeRawLine(raw, meta));
147
147
  watcher.onEntry(activities.onEntry);
148
148
  this.watcher = watcher;
149
149
  void watcher
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thehammer/danx-dashboard-mcp",
3
- "version": "0.1.204",
3
+ "version": "0.1.206",
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",
@@ -31,7 +31,7 @@
31
31
  },
32
32
  "dependencies": {
33
33
  "@modelcontextprotocol/sdk": "1.29.0",
34
- "@thehammer/danx-session-telemetry": "0.1.22",
34
+ "@thehammer/danx-session-telemetry": "0.1.23",
35
35
  "zod": "^3.25.76"
36
36
  },
37
37
  "devDependencies": {