@thehammer/danx-dashboard-mcp 0.1.271 → 0.1.274

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.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * DX-2954 — an operator session's live stats, collected by the bridge and sent
2
+ * DX-2954 — an operator session's live stats, collected by the relay and sent
3
3
  * in its DX-2952 heartbeat.
4
4
  *
5
5
  * Workers and operator sessions report the same stats the same way (epic
@@ -14,7 +14,7 @@
14
14
  *
15
15
  * ## Process start: the first heartbeat's answer, never the mint (round 22)
16
16
  *
17
- * A restarted bridge (watchdog restart, compact, resume) may fail its mint and
17
+ * A restarted session's relay (a resume or a restarted MCP server) may fail its mint and
18
18
  * still heartbeat (DX-2952), so the heartbeat is the one call that always
19
19
  * answers. Until this process holds that answer it sends ONLY its connection
20
20
  * state: no ledger entries, no cursors — the transcript is not even opened. The
@@ -37,22 +37,13 @@
37
37
  */
38
38
  import { basename, dirname, isAbsolute } from "node:path";
39
39
  import { DispatchUsage, SessionLogWatcher, createActivityObserver, } from "@thehammer/danx-session-telemetry";
40
- /** The environment variable the plugin passes the hook input's `transcript_path` in. */
41
- export const TRANSCRIPT_PATH_ENV = "DANX_BRIDGE_TRANSCRIPT_PATH";
42
40
  /**
43
- * The session's transcript path from the environment, or `null` when the
44
- * plugin supplied none (an older plugin, a hand run): the bridge then runs
45
- * exactly as before, heartbeating its connection state only. A value that is
46
- * present but is not an absolute `.jsonl` path is refused loudly — silently
47
- * ignoring it would leave the session's usage uncounted with nothing to say why.
41
+ * DX-4233 - the one rule for a transcript path, wherever it comes from (the `transcript_path` argument of
42
+ * `plan_events_wait`): an absolute `.jsonl` path, or a loud refusal naming `source`.
48
43
  */
49
- export function transcriptPathFromEnv(env) {
50
- const raw = env[TRANSCRIPT_PATH_ENV];
51
- if (raw === undefined || raw.trim() === "")
52
- return null;
53
- const path = raw.trim();
44
+ export function checkedTranscriptPath(path, source) {
54
45
  if (!isAbsolute(path) || !path.endsWith(".jsonl")) {
55
- throw new Error(`${TRANSCRIPT_PATH_ENV} must be an absolute path to a session .jsonl transcript (got "${path}")`);
46
+ throw new Error(`${source} must be an absolute path to a session .jsonl transcript (got "${path}")`);
56
47
  }
57
48
  return path;
58
49
  }
@@ -60,7 +51,7 @@ const realWatcher = (options) => new SessionLogWatcher(options);
60
51
  /** The collector's dispatch-id argument; the heartbeat is addressed by session, not dispatch. */
61
52
  const OPERATOR_DISPATCH_REF = "operator-session";
62
53
  /**
63
- * DX-3962 - the bridge's collector. `"operator-bridge"` is the ONE mode that credits a sub-agent's replies to the card
54
+ * DX-3962 - the relay's collector. `"operator-bridge"` (the telemetry package's published mode value, kept under its old name) is the ONE mode that credits a sub-agent's replies to the card
64
55
  * it claimed (from its own transcript's pickup); a worker builds its collector with attribution off.
65
56
  */
66
57
  function newCollector() {
@@ -85,7 +76,7 @@ export class OperatorSessionStats {
85
76
  * connection-state heartbeat, whose answer starts collection. After: the
86
77
  * collector's flush, carried by the heartbeat (a flush with nothing new
87
78
  * sends one plain heartbeat instead, so an idle session still heartbeats).
88
- * Returns whether a heartbeat landed, for the bridge's silent-failure count.
79
+ * Returns whether a heartbeat landed, for the relay's silent-failure count.
89
80
  */
90
81
  async tick(send) {
91
82
  if (this.watcher === null) {
@@ -120,7 +111,7 @@ export class OperatorSessionStats {
120
111
  this.watcher?.stop();
121
112
  }
122
113
  begin(stored) {
123
- // `this.watcher !== null`: the bridge fires `tick` without awaiting it, so
114
+ // `this.watcher !== null`: the relay fires `tick` without awaiting it, so
124
115
  // two ticks can both be waiting on a pre-collection heartbeat. The first
125
116
  // answer starts collection; a second must not start (and leak) another
126
117
  // tailer over the same transcript.
@@ -141,7 +132,7 @@ export class OperatorSessionStats {
141
132
  this.usage.attachCursors(() => watcher.cursorState(), stored.sourceCursors);
142
133
  this.usage.setTranscriptPath(path);
143
134
  // Replays are credited (the ledger is idempotent) and marked so the
144
- // collector's own estimate never counts them twice; the bridge has no
135
+ // collector's own estimate never counts them twice; the relay has no
145
136
  // side-effecting transcript subscriber to skip them for.
146
137
  watcher.onUsageLine((raw, meta) => this.usage.observeRawLine(raw, meta));
147
138
  // DX-4498 — the raw line feed: the brief and Claude Code's task notifications are not entries.
@@ -0,0 +1,358 @@
1
+ /**
2
+ * DX-4233 - the dashboard HTTP contract of a session's event stream, spoken by the in-process relay behind
3
+ * `plan_events_wait` (`plan-events-relay.ts`).
4
+ *
5
+ * One authenticated request with a timeout, the listener-ticket mint, the best-effort plan inventory read and the 15 s
6
+ * heartbeat. Everything here is about talking to the dashboard; WHEN to talk (the mint/stream loop, the heartbeat schedule,
7
+ * what a stop means for the session) belongs to the relay.
8
+ */
9
+ import { errorCodeOf } from "./json-body.js";
10
+ import { keyRevocationOf, keyRevokedHalt } from "./key-revoked-halt.js";
11
+ import { oneLine } from "./one-line.js";
12
+ import { SESSION_ID_HEADER } from "./session-options.js";
13
+ import { isClaimState } from "@thehammer/danx-session-telemetry";
14
+ /**
15
+ * DX-3912 — the longest heartbeat `reason` the relay sends: one under the dashboard's own
16
+ * limit (`HEARTBEAT_REASON_MAX_CHARS` = 200, `src/issues/db/plan-session-heartbeat.ts`),
17
+ * because `oneLine` appends a "…" to a truncated string and a longer reason refuses the whole
18
+ * heartbeat. `plan-events-wait.test.ts` asserts the cap.
19
+ */
20
+ const HEARTBEAT_REASON_MAX_CODE_POINTS = 199;
21
+ export const STREAM_TICKET_PATH = "/api/plan-sessions/me/stream-ticket";
22
+ // DX-2970 — the bare (no `fields=`) cheap-scalars read, used ONLY to report the
23
+ // connected plan's real `boards`/`cardCount` on the `ready` record (see
24
+ // `fetchPlanInventory`). This is NOT a revival of DX-2920's deleted
25
+ // `verifyPlanBoardsReadable` gate — it never refuses a mint, never blocks
26
+ // streaming, and is best-effort (one attempt, no retry/backoff).
27
+ export const PLAN_MINE_PATH = "/api/plans/mine";
28
+ // DX-3028 (AC1) — `POST /api/plan-sessions/me/heartbeat` (DX-2952's server
29
+ // route, `plan-sessions-routes.ts#handleHeartbeat`). Every 15s this process
30
+ // reports its truthful streaming state; see `sendHeartbeat` below.
31
+ export const HEARTBEAT_PATH = "/api/plan-sessions/me/heartbeat";
32
+ /** How long one dashboard request may take before it counts as a transient failure. */
33
+ export const REQUEST_TIMEOUT_MS = 10_000;
34
+ /** A freshly minted ticket refused this many times in a row is a terminal outcome, not a loop. */
35
+ export const MAX_REFUSED_TICKETS_IN_A_ROW = 2;
36
+ /** DX-3028 (AC1) — the dashboard's own heartbeat cadence; see the card's AC1. */
37
+ export const HEARTBEAT_INTERVAL_MS = 15_000;
38
+ /**
39
+ * DX-3099 — how many consecutive 15s heartbeat POSTs may fail to LAND
40
+ * (network error, timeout, 5xx, 408/429 — never an ordinary 401/403 answer,
41
+ * which proves the pipe itself still works) while this process still
42
+ * believes it is `"streaming"`, before that counts as evidence the current
43
+ * stream connection is dead without having errored — the exact "alive but
44
+ * not reporting" signature the investigation found: the SSE connection can
45
+ * read as established at the OS level and never trip `listen.ts`'s own
46
+ * read-idle timeout, while every outbound heartbeat silently never lands
47
+ * (the send call's own result used to be discarded entirely — see the
48
+ * card). `3 * HEARTBEAT_INTERVAL_MS` = 45s, deliberately UNDER
49
+ * `SESSION_LIVENESS_WINDOW_MS` (60s, `plan-session-heartbeat.ts`) so this
50
+ * process self-heals before, or right around, the moment the dashboard
51
+ * would itself start reporting the session `unattached` — never a shorter
52
+ * freshness window (that constant is untouched) and never a blind
53
+ * "restart more eagerly" (no external watchdog, no timer-driven kill): this
54
+ * fires only on repeated, OBSERVED delivery failure, self-diagnosed by the
55
+ * SAME process that is already streaming. Card: DX-3099 (AC3 forbids both
56
+ * of the obvious wrong fixes this constant is not).
57
+ */
58
+ export const HEARTBEAT_SILENT_FAILURE_THRESHOLD = 3;
59
+ const TRANSIENT_CLIENT_STATUSES = new Set([408, 429]);
60
+ /**
61
+ * One authenticated request, with a timeout, classifying the failures that are
62
+ * about THIS MOMENT (network, timeout, 408/429, 5xx) as transient. Shared by
63
+ * every request this module makes so they can never disagree about which
64
+ * failures are worth retrying.
65
+ */
66
+ async function request(options, deps, what, url, init = { method: "GET" }) {
67
+ const controller = new AbortController();
68
+ const timer = setTimeout(() => controller.abort(), deps.requestTimeoutMs);
69
+ const abort = () => controller.abort();
70
+ if (deps.signal?.aborted === true)
71
+ abort();
72
+ deps.signal?.addEventListener("abort", abort, { once: true });
73
+ try {
74
+ let status;
75
+ let text;
76
+ try {
77
+ const headers = {
78
+ Authorization: `Bearer ${options.token}`,
79
+ Accept: "application/json",
80
+ [SESSION_ID_HEADER]: options.sessionId,
81
+ };
82
+ if (init.body !== undefined)
83
+ headers["Content-Type"] = "application/json";
84
+ const response = await deps.fetch(url, {
85
+ method: init.method,
86
+ headers,
87
+ body: init.body,
88
+ signal: controller.signal,
89
+ });
90
+ status = response.status;
91
+ text = await response.text();
92
+ }
93
+ catch (err) {
94
+ return {
95
+ kind: "transient",
96
+ detail: controller.signal.aborted
97
+ ? `${what} timed out after ${deps.requestTimeoutMs} ms`
98
+ : `${what} failed: ${err.message}`,
99
+ };
100
+ }
101
+ if (status >= 500 || TRANSIENT_CLIENT_STATUSES.has(status)) {
102
+ return { kind: "transient", detail: `${what} HTTP ${status}` };
103
+ }
104
+ let body = null;
105
+ try {
106
+ body = text === "" ? null : JSON.parse(text);
107
+ }
108
+ catch {
109
+ body = null;
110
+ }
111
+ return { kind: "answer", status, text, body };
112
+ }
113
+ finally {
114
+ clearTimeout(timer);
115
+ deps.signal?.removeEventListener("abort", abort);
116
+ }
117
+ }
118
+ /** What is wrong with a 2xx mint body, or `null` when it is a ticket. Never echoes the body. */
119
+ function badTicketReason(body) {
120
+ if (typeof body !== "object" || body === null)
121
+ return "the body is not a JSON object";
122
+ const b = body;
123
+ if (typeof b.ticket !== "string" || b.ticket === "")
124
+ return "ticket is not a non-empty string";
125
+ if (typeof b.streamPath !== "string" || !b.streamPath.startsWith("/"))
126
+ return "streamPath is not a path";
127
+ if (typeof b.leaseMs !== "number" || !Number.isSafeInteger(b.leaseMs) || b.leaseMs <= 0) {
128
+ return "leaseMs is not a positive integer";
129
+ }
130
+ return null;
131
+ }
132
+ /** Mint this session's listener ticket, classifying every way that can end. */
133
+ export async function mintTicket(options, deps) {
134
+ const outcome = await request(options, deps, "ticket mint", `${options.dashboardUrl}${STREAM_TICKET_PATH}`, {
135
+ method: "POST",
136
+ body: "{}",
137
+ });
138
+ if (outcome.kind === "transient")
139
+ return outcome;
140
+ const { status, text, body } = outcome;
141
+ if (status === 409 && errorCodeOf(body) === "session_not_connected") {
142
+ return { kind: "terminal", reason: "not_connected", detail: "the session is not connected to a plan" };
143
+ }
144
+ // DX-3028 (AC2) — matched BEFORE the generic mint_refused fallback below,
145
+ // which every other 409 falls into: this session id belongs to a worker
146
+ // dispatch (`SessionIsWorkerError`, the same shape the heartbeat route
147
+ // already gives it, DX-2952).
148
+ if (status === 409 && errorCodeOf(body) === "session_is_worker") {
149
+ return { kind: "terminal", reason: "session_is_worker", detail: "this session id belongs to a worker dispatch, not an operator plan session" };
150
+ }
151
+ // DX-4418 - a key a person revoked is not a lapsed one: the detail IS the stop halt (who revoked it, what to do), so the record
152
+ // the plugin relays to the session says it in full.
153
+ const revocation = keyRevocationOf({ ok: false, status, body });
154
+ if (revocation !== null) {
155
+ return { kind: "terminal", reason: "key_revoked", detail: keyRevokedHalt(revocation, false) };
156
+ }
157
+ if (status === 401 || status === 403) {
158
+ return { kind: "terminal", reason: "unauthorized", detail: `ticket mint HTTP ${status} ${oneLine(text, 300)}` };
159
+ }
160
+ if (status < 200 || status >= 300) {
161
+ return { kind: "terminal", reason: "mint_refused", detail: `ticket mint HTTP ${status} ${oneLine(text, 300)}` };
162
+ }
163
+ const bad = badTicketReason(body);
164
+ if (bad !== null) {
165
+ return { kind: "terminal", reason: "mint_bad_response", detail: `ticket mint HTTP ${status}: ${bad}` };
166
+ }
167
+ const ticket = body;
168
+ return {
169
+ kind: "ticket",
170
+ ticket: ticket.ticket,
171
+ streamUrl: `${options.dashboardUrl}${ticket.streamPath}`,
172
+ leaseMs: ticket.leaseMs,
173
+ };
174
+ }
175
+ /**
176
+ * What is wrong with a 2xx bare `/api/plans/mine` body, or `null` when it carries a usable inventory.
177
+ *
178
+ * DX-3912 — the field names here are the plan resource's WIRE names (`boards`, `card_count`,
179
+ * snake_case since DX-3428), the same ones `src/resources/plan/plan-resource.ts` emits; the
180
+ * contract test `src/issues/__tests__/plan-inventory-relay-contract.test.ts` runs this parser
181
+ * on the server's own response, so a rename there fails that test instead of silently
182
+ * degrading every listener. A missing field lists the keys the body DID carry, so a rename is
183
+ * named in the first message anyone reads.
184
+ */
185
+ function badInventoryReason(body) {
186
+ if (!isRecord(body))
187
+ return "the body is not a JSON object";
188
+ if (!("boards" in body))
189
+ return `boards is missing (response keys: ${Object.keys(body).join(", ")})`;
190
+ if (!Array.isArray(body.boards) || body.boards.some((x) => typeof x !== "string")) {
191
+ return "boards is not an array of strings";
192
+ }
193
+ if (!("card_count" in body))
194
+ return `card_count is missing (response keys: ${Object.keys(body).join(", ")})`;
195
+ const count = body.card_count;
196
+ if (typeof count !== "number" || !Number.isSafeInteger(count) || count < 0) {
197
+ return `card_count is not a non-negative integer (got ${typeof count === "number" ? count : typeof count})`;
198
+ }
199
+ return null;
200
+ }
201
+ /**
202
+ * DX-2970 — a single BEST-EFFORT read of the connected plan's real
203
+ * `boards`/`cardCount`, purely for accurate `ready`-record reporting. NEVER a
204
+ * gate: unlike DX-2920's deleted `verifyPlanBoardsReadable`, a failure here
205
+ * (network, timeout, non-2xx, unusable body) is NOT retried and NEVER blocks
206
+ * or delays streaming — the relay reports `degraded: true` with `boards`/
207
+ * `cardCount` both `null` and proceeds exactly as it would have before this
208
+ * read existed. The bare (no `fields=`) call is deliberately the cheapest
209
+ * read the route offers — the plan's own "cheap scalars," always present on
210
+ * every `GET /api/plans/mine` response regardless of `fields`.
211
+ */
212
+ export async function fetchPlanInventory(options, deps) {
213
+ const outcome = await request(options, deps, "plan inventory read", `${options.dashboardUrl}${PLAN_MINE_PATH}`);
214
+ if (outcome.kind === "transient")
215
+ return { kind: "unavailable", detail: outcome.detail };
216
+ const { status, text, body } = outcome;
217
+ if (status < 200 || status >= 300) {
218
+ return { kind: "unavailable", detail: `plan inventory read HTTP ${status} ${oneLine(text, 300)}` };
219
+ }
220
+ const bad = badInventoryReason(body);
221
+ if (bad !== null) {
222
+ return { kind: "unavailable", detail: `plan inventory read HTTP ${status}: ${bad}` };
223
+ }
224
+ const b = body;
225
+ return { kind: "ok", boards: b.boards, cardCount: b.card_count };
226
+ }
227
+ /**
228
+ * DX-2920 — a stream admission refusal (`listen.ts`'s STRUCTURED `refused`
229
+ * outcome — `{status, errorCode, body}`, parsed once in `connectOnce`) whose
230
+ * shape matches one the mint route already gives its OWN name to: `409
231
+ * session_not_connected`, the SAME shape the mint route uses for "no plan
232
+ * connected" (`plan-session-stream.ts`, DX-2863 review finding M2). A session
233
+ * with no plan connected is the identical terminal state whether the
234
+ * dashboard says so at mint or at the stream's own admission check, and the
235
+ * session should see ONE remedy either way.
236
+ *
237
+ * Classifies by STATUS + ERROR CODE, never by re-deriving them from the
238
+ * flattened `detail` string — the second-source-of-truth shape this card's fix
239
+ * round closed (the regex-based `isSessionNotConnectedRefusal` it replaced).
240
+ */
241
+ export function classifyAdmissionRefusal(refusal) {
242
+ if (refusal === undefined)
243
+ return null;
244
+ if (refusal.status === 409 && refusal.errorCode === "session_not_connected") {
245
+ return { reason: "not_connected", detail: "the session is not connected to a plan" };
246
+ }
247
+ return null;
248
+ }
249
+ /**
250
+ * The one real `scheduleInterval`: a `setInterval` and its canceler, for the relay's heartbeat. Unref'd: a heartbeat never keeps
251
+ * the MCP server alive after its session ended.
252
+ */
253
+ export function realScheduleInterval(tick, ms) {
254
+ const timer = setInterval(tick, ms);
255
+ timer.unref();
256
+ return { stop: () => clearInterval(timer) };
257
+ }
258
+ /**
259
+ * The one real sleep of the relay's mint / stream loop: unref'd, so a backoff never keeps the MCP server alive after its session
260
+ * ended, and cut short by `signal` (the run was ended), so a suspended relay does not leave a timer behind.
261
+ */
262
+ export function realSleep(ms, signal) {
263
+ return new Promise((resolve) => {
264
+ if (signal?.aborted)
265
+ return resolve();
266
+ const done = () => {
267
+ clearTimeout(timer);
268
+ signal?.removeEventListener("abort", done);
269
+ resolve();
270
+ };
271
+ const timer = setTimeout(done, ms);
272
+ timer.unref();
273
+ signal?.addEventListener("abort", done, { once: true });
274
+ });
275
+ }
276
+ /**
277
+ * The truthful `connection` for a heartbeat: `streaming` only while the SSE connection is open (with whether the plan
278
+ * inventory read worked), `stopped` with the reason the stream ended, otherwise `reconnecting`.
279
+ */
280
+ export function heartbeatConnection(state, stoppedReason, inventoryError) {
281
+ if (state === "streaming") {
282
+ return inventoryError === null
283
+ ? { state, inventory: "ok" }
284
+ : { state, inventory: "unavailable", reason: oneLine(inventoryError, HEARTBEAT_REASON_MAX_CODE_POINTS) };
285
+ }
286
+ return state === "stopped" ? { state, reason: stoppedReason ?? undefined } : { state };
287
+ }
288
+ /**
289
+ * One heartbeat POST. Never throws on a failure of the request itself and NEVER stops the relay on one
290
+ * (the one throw is a contract breach: a `key_revoked` answer that does not say who revoked the key, DX-4418; `heartbeatTick`'s
291
+ * `.catch` keeps it from taking the relay down) (card AC1 — "a failed heartbeat never stops the relay"): `request()`
292
+ * itself already classifies every network/timeout/5xx failure as
293
+ * `{kind:"transient"}` rather than throwing, and a 401/403 (a revoked
294
+ * credential) simply comes back as an ordinary non-2xx answer this function
295
+ * discards — there is nothing meaningful to retry a heartbeat POST for, and
296
+ * the NEXT tick will just try again. A heartbeat refused with 401/403 cannot
297
+ * report itself, so `computeListenerHealth`'s staleness fallback
298
+ * (`heartbeatIsFresh`) is what eventually reflects it: the row's
299
+ * `last_heartbeat_at` stops advancing, and once it falls outside
300
+ * `SESSION_LIVENESS_WINDOW_MS` the badge reads `unattached`, NOT
301
+ * `credential_mismatch` — `credential_mismatch` is computed only from a
302
+ * heartbeat that DID land with `state:"streaming"` and a live ticket whose
303
+ * issuer hash differs from the caller's; it has no way to represent "the
304
+ * heartbeat call itself was rejected". This gap is real and is recorded on
305
+ * the card rather than silently papered over.
306
+ */
307
+ export async function sendHeartbeat(options, deps, connection, stats,
308
+ // DX-4461 - the session's current Claude title, read from its transcript (`session-title.ts`).
309
+ title) {
310
+ const body = { state: connection.state };
311
+ if (connection.reason !== undefined)
312
+ body.reason = connection.reason;
313
+ if (connection.inventory !== undefined)
314
+ body.inventory = connection.inventory;
315
+ const outcome = await request(options, deps, "heartbeat", `${options.dashboardUrl}${HEARTBEAT_PATH}`, {
316
+ method: "POST",
317
+ body: JSON.stringify({ connection: body, ...(stats !== undefined ? { stats } : {}), ...(title !== undefined ? { title } : {}) }),
318
+ });
319
+ // DX-3099 — this is now the ONE place a heartbeat's own outcome is
320
+ // inspected. `request()` still never throws, and a 401/403 is still an
321
+ // ordinary answer this function does not retry for (the credential is
322
+ // whatever it is; there is nothing to redo about THIS request) — only a
323
+ // "transient" outcome (network error, timeout, 5xx, 408/429) means the
324
+ // request never landed at all, which is exactly the signal the silent-
325
+ // failure counter in the relay below needs. Still never throws.
326
+ if (outcome.kind !== "answer")
327
+ return { landed: false, status: null, stats: null, revocation: null, title: null };
328
+ // DX-2954 — the answer's `stats` (the operator dispatch's stored or
329
+ // just-written totals and cursors) feed the session's stats collector.
330
+ const ok = outcome.status >= 200 && outcome.status < 300;
331
+ // A heartbeat whose carried stats were NOT applied (`statsError`) still
332
+ // landed; its `stats` are the stored values, so the collector must re-queue
333
+ // rather than treat them as the answer to its write.
334
+ const applied = ok && isRecord(outcome.body) && outcome.body.statsError === undefined;
335
+ const statsBody = applied && isRecord(outcome.body) && isRecord(outcome.body.stats) ? outcome.body.stats : null;
336
+ // DX-3962: an answer from a server that predates attribution stores cursors with no `claims`. Treated exactly like
337
+ // an answer with no stats (the older-server path): the collector never starts, the heartbeat still lands, and
338
+ // nothing is recorded until the server is deployed. Handing it on would crash the collector at attach.
339
+ const answered = statsBody !== null &&
340
+ isRecord(statsBody.sourceCursors) &&
341
+ isRecord(statsBody.totals) &&
342
+ Object.values(statsBody.sourceCursors).every((cursor) => isRecord(cursor) && isClaimState(cursor.claims))
343
+ ? statsBody
344
+ : null;
345
+ // DX-4418 - the dashboard refuses the beat of a session whose key a person revoked, and says so.
346
+ // DX-4461 - the dashboard's stored title for this session; an answer without one is an older dashboard's.
347
+ const storedTitle = ok && isRecord(outcome.body) && typeof outcome.body.title === "string" ? outcome.body.title : null;
348
+ return {
349
+ landed: true,
350
+ status: outcome.status,
351
+ stats: answered,
352
+ revocation: keyRevocationOf({ ok: false, status: outcome.status, body: outcome.body }),
353
+ title: storedTitle,
354
+ };
355
+ }
356
+ function isRecord(value) {
357
+ return typeof value === "object" && value !== null && !Array.isArray(value);
358
+ }
@@ -6,9 +6,9 @@
6
6
  * again. DX-2816 found no env var, hook input or MCP-reachable signal carrying the title, but Claude Code writes every
7
7
  * title change into the session's transcript as a `{"type":"custom-title","customTitle":"..."}` line the moment the
8
8
  * rename happens, even while the session is idle (observed on session 952f0739: 76 records of the old title, then the
9
- * renamed one, no turn in between). The bridge already holds the transcript path (the plugin passes the hook input's
10
- * `transcript_path` as `DANX_BRIDGE_TRANSCRIPT_PATH`, `session-stats.ts`), so it reads the last such record on each
11
- * heartbeat and reports it when it differs from the title the dashboard answered with (`bridge.ts`); the dashboard
9
+ * renamed one, no turn in between). the relay already holds the transcript path (the plugin passes the hook input's
10
+ * `transcript_path` as the `transcript_path` argument, `session-stats.ts`), so it reads the last such record on each
11
+ * heartbeat and reports it when it differs from the title the dashboard answered with (the relay); the dashboard
12
12
  * stores it (`plan-sessions-routes.ts`).
13
13
  *
14
14
  * INCREMENTAL, NEVER A RE-READ. The reader remembers how far it has scanned (always a line boundary) and the title it
@@ -16,7 +16,7 @@
16
16
  * bounded to its last {@link TRANSCRIPT_TITLE_MAX_SCAN_BYTES}: Claude Code repeats the record on every turn, so the
17
17
  * latest one is always near the end, and a transcript with none in that window has nothing to report.
18
18
  *
19
- * It reads synchronously on purpose: the bridge's heartbeat tick is synchronous up to its first request, and a read
19
+ * It reads synchronously on purpose: the relay's heartbeat tick is synchronous up to its first request, and a read
20
20
  * of a few appended kilobytes every 15 seconds costs less than the plumbing an async read would need.
21
21
  */
22
22
  import { closeSync, fstatSync, openSync, readSync } from "node:fs";
@@ -111,7 +111,7 @@ function parseTitleRecord(line, path) {
111
111
  }
112
112
  /**
113
113
  * At most {@link SESSION_TITLE_MAX_CHARS} UTF-16 units, cut on a code-point boundary. A cut that lands after a space is
114
- * trimmed again: the dashboard stores the trimmed title, so an untrimmed one would never equal it and the bridge would
114
+ * trimmed again: the dashboard stores the trimmed title, so an untrimmed one would never equal it and the relay would
115
115
  * resend it on every heartbeat.
116
116
  */
117
117
  function boundTitle(title) {
@@ -0,0 +1,12 @@
1
+ /**
2
+ * DX-4523 / DX-4233 - what the server lets go of when its session ends: the permission watch's open stream and the plan event
3
+ * relay (its pending call, stream, heartbeat and tailer), so nothing keeps the process alive after the client is gone.
4
+ *
5
+ * Two triggers, both needed: the transport's `onclose` (an orderly close of the connection) and the end of stdin. The SDK's stdio
6
+ * transport does not watch stdin for its end (only a transport `close()` reaches `onclose`), so a client that simply exits
7
+ * would leave the process running without the second one.
8
+ */
9
+ export function stopWhenSessionEnds(wiring) {
10
+ wiring.server.onclose = wiring.stop;
11
+ wiring.stdin.once("end", wiring.stop);
12
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * DX-4233 - the remedy a session is shown for each way its dashboard event stream can stop, in ONE place.
3
+ *
4
+ * The client that ends in these texts is the in-process relay behind `plan_events_wait` (`plan-events-relay.ts`); the one-shot
5
+ * subcommands use `fixForStop` for a start they cannot make. Pure data and one function with no imports.
6
+ */
7
+ /**
8
+ * The remedy per terminal reason. ONE map, so the message a session is shown and the message a log carries can never drift.
9
+ * `KnownStopReason` is only a SUBSET of `ListenStopReason` (which also has `unknown`, for a newer dashboard's wire reason this
10
+ * build predates), so `fixForStop` accepts a plain string and falls back to `DEFAULT_STOP_FIX`.
11
+ *
12
+ * DX-4233 - the texts name the session's listener, never a process: with the relay inside the session's own MCP server there
13
+ * is no separate process to restart.
14
+ */
15
+ export const STOP_FIXES = {
16
+ no_connection_record: "call plan_connect again in this session — its danx-dashboard MCP server records the connection a hook command needs " +
17
+ "(a server older than the one that introduced the record writes none)",
18
+ credential_unavailable: "call plan_connect again to request access: this session's key file is missing or unreadable",
19
+ credential_mismatch: "restart this Claude Code session so its MCP server and the plugin's hook commands pick up the same credential, then call plan_connect again",
20
+ not_connected: "call plan_connect to connect this session to a plan",
21
+ plan_archived: "the plan was archived or deleted, so this session was disconnected; ask the operator to restore it, then call plan_connect again",
22
+ unauthorized: "the dashboard no longer accepts this session's key (it lapsed after a day unused): call plan_connect again to request access",
23
+ key_revoked: "stop all work now, commit your work in progress and end this session: the person who owns this session's access revoked it; " +
24
+ "do not call plan_connect or request access again",
25
+ mint_refused: "check that this session is connected to a plan on the dashboard its MCP server points at, then call plan_connect again",
26
+ mint_bad_response: "check that DANXBOT_DASHBOARD_URL for this session's MCP server points at a danxbot dashboard",
27
+ // A newer ticket exists (superseded) or another connection is already using this one (replaced): THIS listener is ending
28
+ // because the session is already served elsewhere, not because anything is broken.
29
+ superseded: "no action needed — a newer ticket was minted for this session, and a fresh listener is already using it",
30
+ replaced: "no action needed — another connection is already using this session's ticket; call plan_connect again only if that was unexpected",
31
+ revoked: "call plan_connect again to mint a new listener ticket for this session",
32
+ refused: "call plan_connect again to mint a new listener ticket for this session",
33
+ // DX-3028 - this session id belongs to a worker dispatch; there is no listener ticket a worker session can ever use.
34
+ session_is_worker: "this session id belongs to a worker dispatch; start a new session",
35
+ relay_failed: "the event relay inside this session's danx-dashboard MCP server failed (the detail says why); the next call tries again, and if it keeps failing restart this Claude Code session",
36
+ };
37
+ export const DEFAULT_STOP_FIX = "call plan_connect again in this session to restart the listener";
38
+ export function fixForStop(reason) {
39
+ return STOP_FIXES[reason] ?? DEFAULT_STOP_FIX;
40
+ }
@@ -3,12 +3,12 @@
3
3
  * sub-agents for the danxbot plugin's plan pane (DX-4508).
4
4
  *
5
5
  * WHY IT EXISTS. The pane's dashboard read (`GET /api/plan-sessions/:id/subagents`) lags the transcript by about 80s
6
- * (the bridge's 5s transcript poll, its 15s heartbeat flush, the pane's 60s read). A hooks module cannot tail the files
6
+ * (the relay's 5s transcript poll, its 15s heartbeat flush, the pane's 60s read). A hooks module cannot tail the files
7
7
  * itself (`$.fs.read` has no offset and refuses files over 4 MiB), but it can spawn a long-lived child and read its
8
8
  * stdout. This is that child: it tails the session's transcript tree on this machine and prints each change within one
9
9
  * 1s poll of Claude Code writing it.
10
10
  *
11
- * ONE PLACE COMPUTES EACH NUMBER. It runs the same `SessionLogWatcher` the bridge runs (`session-stats.ts`), over the
11
+ * ONE PLACE COMPUTES EACH NUMBER. It runs the same `SessionLogWatcher` the relay runs (`session-stats.ts`), over the
12
12
  * same session layout (the main transcript, and `<sessionDir>/<sessionId>/subagents/agent-<id>.jsonl` beside it), into
13
13
  * the shared `createSubagentLiveView`, which feeds the same activity observer, usage ledger counter and pricing rule
14
14
  * the dashboard's numbers come from. Nothing is counted or priced here.
@@ -27,6 +27,7 @@ export const CALLBACK_ENV = {
27
27
  };
28
28
  const BOOTSTRAP = { permission: null, callback: null };
29
29
  const needs = (permission) => ({ permission, callback: null });
30
+ const operatorSessionNeeds = (permission) => ({ permission, callback: null, operatorSessionOnly: true });
30
31
  const needsAndPostsTo = (permission, callback) => ({ permission, callback });
31
32
  /**
32
33
  * Every tool this server registers, with what makes it usable. `index.ts` refuses to register a tool that is not
@@ -40,6 +41,9 @@ export const TOOL_ACCESS = {
40
41
  danxbot_api_spec: BOOTSTRAP,
41
42
  // `POST /api/issues/:id/attachments`
42
43
  attach_file: needs("cards.edit"),
44
+ // DX-4233 - the plugin's relay waits on the session's plan events here: it opens the listener ticket
45
+ // (`POST /api/plan-sessions/me/stream-ticket`) and heartbeats (`.../me/heartbeat`), both `plans.view`.
46
+ plan_events_wait: operatorSessionNeeds("plans.view"),
43
47
  // The one dispatch callback that stays a tool (DX-4525): it reads local files. Its dashboard route's own policy names the
44
48
  // permission (`route-policies/index.ts`); every other callback is a `danxbot_api` call to a route under `callback-routes.ts`.
45
49
  danxbot_slack_reply: needsAndPostsTo("slack.threads.post", "slackReply"),
@@ -50,6 +54,8 @@ export function isUsable(access, grant) {
50
54
  return false;
51
55
  if (access.callback !== null && !grant.callbacks.has(access.callback))
52
56
  return false;
57
+ if (access.operatorSessionOnly === true && !grant.operatorSession)
58
+ return false;
53
59
  return true;
54
60
  }
55
61
  /** The ONE mapping from what a session holds to the tool names it advertises, sorted. */
@@ -58,5 +64,5 @@ export function toolsFor(grant) {
58
64
  }
59
65
  /** The bootstrap set: usable with no key and no callback. */
60
66
  export function bootstrapTools() {
61
- return toolsFor({ permissions: new Set(), callbacks: new Set() });
67
+ return toolsFor({ permissions: new Set(), callbacks: new Set(), operatorSession: false });
62
68
  }
@@ -11,17 +11,15 @@ import { toolsFor } from "./tool-access.js";
11
11
  export class ToolSurface {
12
12
  tools;
13
13
  callbacks;
14
- onChanged;
15
- constructor(tools, callbacks,
16
- /** Announces that the tool list changed: the server's `sendToolListChanged` (a no-op until a client is connected). */
17
- onChanged) {
14
+ options;
15
+ constructor(tools, callbacks, options) {
18
16
  this.tools = tools;
19
17
  this.callbacks = callbacks;
20
- this.onChanged = onChanged;
18
+ this.options = options;
21
19
  }
22
20
  /** Show exactly the tools `permissions` and this dispatch's callbacks allow. Returns the names whose visibility changed. */
23
21
  apply(permissions) {
24
- const wanted = new Set(toolsFor({ permissions, callbacks: this.callbacks }));
22
+ const wanted = new Set(toolsFor({ permissions, callbacks: this.callbacks, operatorSession: this.options.operatorSession }));
25
23
  const changed = [];
26
24
  for (const [name, tool] of this.tools) {
27
25
  const want = wanted.has(name);
@@ -31,7 +29,7 @@ export class ToolSurface {
31
29
  changed.push(name);
32
30
  }
33
31
  if (changed.length > 0)
34
- this.onChanged();
32
+ this.options.onChanged();
35
33
  return changed.sort();
36
34
  }
37
35
  /** The tool names currently shown. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thehammer/danx-dashboard-mcp",
3
- "version": "0.1.271",
3
+ "version": "0.1.274",
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, request_permission, attach_file, and danxbot_slack_reply) (DX-3465, PLN-11 R-25).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -21,6 +21,7 @@
21
21
  "node": ">=20"
22
22
  },
23
23
  "scripts": {
24
+ "prepublishOnly": "bash ../../scripts/assert-tree-contains-origin-main.sh @thehammer/danx-dashboard-mcp",
24
25
  "build": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.json && node -e \"require('fs').chmodSync('dist/index.js', 0o755)\"",
25
26
  "verify:boot": "bash scripts/verify-boot.sh",
26
27
  "start": "node dist/index.js",
@@ -31,7 +32,7 @@
31
32
  },
32
33
  "dependencies": {
33
34
  "@modelcontextprotocol/sdk": "1.29.0",
34
- "@thehammer/danx-session-telemetry": "0.1.29",
35
+ "@thehammer/danx-session-telemetry": "0.1.44",
35
36
  "zod": "^3.25.76"
36
37
  },
37
38
  "devDependencies": {