@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.
@@ -0,0 +1,425 @@
1
+ /**
2
+ * DX-4233 - the IN-PROCESS relay of a session's plan events: what the `plan_events_wait` tool serves.
3
+ *
4
+ * WHO CALLS IT. The danxbot plugin's relay loop, inside the Claude Code session, calls `plan_events_wait` over and over on
5
+ * this session's own danx-dashboard MCP server and hands every record it gets to the session. This class is everything on the
6
+ * server side of that call: it opens the session's dashboard event stream (the mint / stream / heartbeat contract of
7
+ * `session-stream.ts`), buffers records for the caller and reports the session's listener health. There is no process to start,
8
+ * watch or kill: the stream lives exactly as long as the MCP server, which lives exactly as long as its session.
9
+ *
10
+ * THE WAIT CONTRACT (`wait`). One rule, one reset, and a cursor that is opaque to the caller.
11
+ * 1. ONE RULE, BY ARRIVAL. Every record (a dashboard event, a digest, a notice, a pacing or resume message) is given the next
12
+ * number of a sequence that only ever increases: its `seq`. Each answered record carries a CURSOR, an opaque string the
13
+ * caller stores once it has DELIVERED that record and sends back on its next call. A call answers every buffered record
14
+ * whose seq is above the cursor's (at once when there is one, else when one arrives, else empty at the timeout) and
15
+ * DROPS every record at or below it, at the call (`acknowledged`, the one predicate). An answer is never a drain: a record
16
+ * stays buffered until a LATER call's cursor passes it, so a call whose caller went away (a plugin reload) loses nothing.
17
+ * The dashboard numbers its events when it INSERTS them but publishes them when it COMMITS, so an event can arrive after
18
+ * a higher-numbered one; arrival order is the only order the relay can promise, which is why the dashboard's id is never
19
+ * what is compared.
20
+ * 2. THE CURSOR. `<seq>:<id>,<id>,...`: the record's seq, then the dashboard event ids delivered up to and including it, in
21
+ * arrival order and bounded to the last `CURSOR_IDS_MAX` (`formatCursor`, `parseCursor`: the seq and every id are base-10
22
+ * whole numbers with no leading zero, the ids are distinct and positive; anything else is refused loudly). The ids are what
23
+ * makes a restart exact. After a restart of the MCP server (a resumed session) a fresh relay is presented its caller's
24
+ * cursor: its sequence continues from that seq, and the stream resumes after the highest of those ids with the listener's
25
+ * duplicate guard seeded with all of them. The dashboard replays every event created within its 30 s commit-order overlap
26
+ * (`REPLAY_COMMIT_ORDER_OVERLAP_MS`) before the last one seen and relies on that guard, so an already delivered event in
27
+ * the overlap is dropped and one never delivered is delivered.
28
+ * THE ACCEPTED GAP: an insert that commits more than that 30 s overlap after the event it follows is missed by a RESTART
29
+ * resume (the dashboard's own limit); a running relay never misses one. And a burst of more than `CURSOR_IDS_MAX` events
30
+ * inside the overlap can replay the oldest of them once (a duplicate, never a loss).
31
+ * 3. ONE PLAN. A call names the plan the session is on (`planId`). A plan different from the last call's drops the
32
+ * PLAN-scoped records buffered or held for the plan it left, so a move starts clean. SESSION-scoped records (pacing, resume
33
+ * and the relay's own failure and revoked-key notices: they say nothing about a plan, and the dashboard never replays
34
+ * them) are kept, here and across the 40 s suspend below. The ids of a dropped record leave the cursors of the records that
35
+ * stay: delivering a kept record must not claim an event that was dropped undelivered. The stream stays on a move: it
36
+ * belongs to the SESSION, and the dashboard delivers an event only while the session is on the event's plan.
37
+ *
38
+ * One wait is pending at a time: a newer call supersedes the older, which answers empty at once. A call whose request is
39
+ * aborted drops its waiter. A terminal outcome answers `{stopped}` with the remedy (`stop-fixes.ts`); the call after it tries
40
+ * again from scratch.
41
+ *
42
+ * WHO IS HEALTHY. The relay heartbeats every `HEARTBEAT_INTERVAL_MS` while a call is pending or one was made within
43
+ * `HEARTBEAT_ACTIVITY_WINDOW_MS`. After that window the stream is closed and the heartbeat stops, so the dashboard reads the
44
+ * session not healthy when nothing is reading its events (a dead or unloaded plugin module). The window is 40 s: a pending
45
+ * call counts, so it only has to outlast the caller's turnaround and failure backoff (the plugin waits 20 s and backs off at
46
+ * most 10 s), and it must stay under the dashboard's liveness window (`SESSION_LIVENESS_WINDOW_MS`, 60 s) minus one beat. The
47
+ * sequence and the highest event id survive the gap, so a cursor stays valid across it.
48
+ *
49
+ * NOTHING OUTLIVES THE SESSION. The heartbeat interval, the backoff sleep and the digest hold are unref'd, the stream and the
50
+ * mint / inventory requests are aborted by `close()`, and `close()` also clears a pending call's wait timer (the one ref'd timer,
51
+ * held only until the call answers). The server calls `close()` when its transport closes and when its stdin ends (`shutdown.ts`).
52
+ */
53
+ import { DigestHold } from "./digest-hold.js";
54
+ import { HeartbeatReporter } from "./heartbeat-reporter.js";
55
+ import { capResumeIds, DELIVERED_ID_MEMORY, HEALTHY_CONNECTION_MS, INITIAL_BACKOFF_MS, MAX_BACKOFF_MS, runListener, } from "./listen.js";
56
+ import { classifyAdmissionRefusal, fetchPlanInventory, HEARTBEAT_INTERVAL_MS, heartbeatConnection, MAX_REFUSED_TICKETS_IN_A_ROW, mintTicket, } from "./session-stream.js";
57
+ import { fixForStop } from "./stop-fixes.js";
58
+ /** The shortest and longest wait one call may ask for; a short ceiling keeps an orphaned waiter's life short. */
59
+ export const WAIT_MIN_MS = 1_000;
60
+ export const WAIT_MAX_MS = 30_000;
61
+ /** How long after a call (or the end of one) the relay keeps heartbeating and streaming; see the module docblock. */
62
+ export const HEARTBEAT_ACTIVITY_WINDOW_MS = 40_000;
63
+ /**
64
+ * How many delivered dashboard event ids a cursor carries. The dashboard replays the events created within its 30 s overlap
65
+ * before the last one seen: 64 covers a busy plan's burst in that window many times over while keeping every record's cursor
66
+ * a few hundred characters. (The listener's own memory, `DELIVERED_ID_MEMORY`, is far larger and is not what a cursor carries.)
67
+ */
68
+ export const CURSOR_IDS_MAX = 64;
69
+ const WHOLE = "(?:0|[1-9][0-9]*)";
70
+ const CURSOR_PATTERN = new RegExp(`^(${WHOLE}):((?:[1-9][0-9]*)(?:,[1-9][0-9]*)*)?$`);
71
+ export const formatCursor = (cursor) => `${cursor.seq}:${cursor.ids.join(",")}`;
72
+ /** Reads a cursor this relay issued. Anything else is refused loudly: a guess would hide a lost or repeated record. */
73
+ export function parseCursor(raw) {
74
+ const match = CURSOR_PATTERN.exec(raw);
75
+ const seq = match === null ? NaN : Number(match[1]);
76
+ const ids = match === null || match[2] === undefined ? [] : match[2].split(",").map(Number);
77
+ const valid = Number.isSafeInteger(seq) && ids.length <= CURSOR_IDS_MAX && ids.every(Number.isSafeInteger) && new Set(ids).size === ids.length;
78
+ if (!valid) {
79
+ throw new Error(`plan_events_wait: cursor ${JSON.stringify(raw)} is not one this server issued (expected "<seq>:<id>,<id>,...": a whole number, a colon, then up to ${CURSOR_IDS_MAX} distinct positive event ids)`);
80
+ }
81
+ return { seq, ids };
82
+ }
83
+ const boundedIds = (ids) => [...new Set(ids)].slice(-CURSOR_IDS_MAX);
84
+ /** DX-4233: the one rule of the cursor: a record at or below its seq has been delivered. */
85
+ export const acknowledged = (seq, cursor) => cursor !== null && seq <= cursor.seq;
86
+ export class PlanEventRelay {
87
+ deps;
88
+ buffer = [];
89
+ waiter = null;
90
+ run = null;
91
+ lastPlanId = null;
92
+ /** The last seq given: it continues from a presented cursor and survives a suspend, so a cursor stays valid across the gap. */
93
+ seq = 0;
94
+ /** The dashboard event ids that arrived (or were presented in a cursor), oldest first, bounded: the next record's `recent`. */
95
+ recentIds = [];
96
+ /** A terminal outcome nobody was waiting for: answered by the next call. */
97
+ pendingStop = null;
98
+ /** Dashboard ids already given to the buffer (and the cursor the run started from): handed to a re-mint so a replay is dropped. */
99
+ seenIds = [];
100
+ connectionState = "reconnecting";
101
+ stoppedReason = null;
102
+ lastInventoryError = null;
103
+ lastActivityAt = 0;
104
+ heartbeat = null;
105
+ reporter = null;
106
+ digest = new DigestHold((event) => this.accept(event));
107
+ constructor(deps) {
108
+ this.deps = deps;
109
+ }
110
+ /** The one entry point: see the module docblock for the contract. */
111
+ async wait(args, signal) {
112
+ // Parsed first: a cursor this server did not issue is refused before it changes anything.
113
+ const cursor = args.cursor === null ? null : parseCursor(args.cursor);
114
+ // DX-4233: a request already cancelled (a plugin reload) touches nothing and starts nothing.
115
+ if (signal?.aborted)
116
+ return { events: [] };
117
+ // DX-4233: one wait at a time; the older call answers empty so its caller is never left hanging.
118
+ this.supersedeWaiter();
119
+ // DX-4233: a plan move starts clean (the very first call has no last plan; after a suspend the last plan is still known, and `dropPlanScoped` already ran).
120
+ if (this.lastPlanId !== null && args.planId !== this.lastPlanId)
121
+ this.startClean();
122
+ this.lastPlanId = args.planId;
123
+ if (cursor !== null) {
124
+ // DX-4233: a fresh relay (a restarted server) continues the caller's sequence and remembers the ids it was handed; a
125
+ // running one is already past both.
126
+ this.seq = Math.max(this.seq, cursor.seq); // DX-4233: max, never `=`: a stale cursor (a plugin reload) must not reuse a buffered seq
127
+ this.recentIds = boundedIds([...cursor.ids, ...this.recentIds]);
128
+ // What the caller delivered precedes every record still buffered, so each of their cursors carries it (a dropped
129
+ // plan-scoped record's ids were taken out of them, and the caller's own delivered ids go back in).
130
+ this.buffer = this.buffer.map((r) => ({ ...r, recent: boundedIds([...cursor.ids, ...r.recent]) }));
131
+ }
132
+ this.touch();
133
+ this.ensureReporter(args.transcriptPath);
134
+ this.buffer = this.buffer.filter((r) => !acknowledged(r.seq, cursor));
135
+ if (this.pendingStop !== null) {
136
+ const stopped = this.pendingStop;
137
+ this.pendingStop = null;
138
+ return { stopped };
139
+ }
140
+ // DX-4233: a server holding no key answers a stop without calling the dashboard (a dispatch never reaches here).
141
+ if (this.deps.auth() === null) {
142
+ return { stopped: this.stopRecord("credential_unavailable", "this session holds no dashboard key") };
143
+ }
144
+ if (this.run === null)
145
+ this.start(cursor);
146
+ if (this.buffer.length > 0)
147
+ return this.answer();
148
+ return new Promise((resolve) => {
149
+ const onAbort = () => this.supersedeWaiter();
150
+ signal?.addEventListener("abort", onAbort, { once: true });
151
+ const waiter = {
152
+ settle: resolve,
153
+ timer: setTimeout(() => {
154
+ this.waiter = null;
155
+ this.finishCall(waiter, { events: [] });
156
+ }, args.timeoutMs),
157
+ detach: () => signal?.removeEventListener("abort", onAbort),
158
+ };
159
+ this.waiter = waiter;
160
+ });
161
+ }
162
+ /** Stop everything: the pending call, the stream, the heartbeat, the transcript tailer. For the server's shutdown. */
163
+ close() {
164
+ this.supersedeWaiter();
165
+ this.suspend();
166
+ }
167
+ // ---------------------------------------------------------------------------------------------------------------- calls
168
+ finishCall(waiter, answer) {
169
+ clearTimeout(waiter.timer);
170
+ waiter.detach();
171
+ this.lastActivityAt = this.deps.now();
172
+ waiter.settle(answer);
173
+ }
174
+ /** The pending call, if any, answers empty at once (a newer call replaced it, or its request went away). */
175
+ supersedeWaiter() {
176
+ const waiter = this.waiter;
177
+ if (waiter === null)
178
+ return;
179
+ this.waiter = null;
180
+ this.finishCall(waiter, { events: [] });
181
+ }
182
+ /** Everything buffered, each record with the cursor to store once it is delivered. */
183
+ answer() {
184
+ return { events: this.buffer.map(({ seq, recent, text }) => ({ cursor: formatCursor({ seq, ids: recent }), text })) };
185
+ }
186
+ // ------------------------------------------------------------------------------------------------------------ the buffer
187
+ /**
188
+ * A record the session should now be told: given the next seq, buffered for the caller, and the pending call answers with it.
189
+ * A replayed dashboard event never reaches here twice: the listener's duplicate guard (seeded from the cursor) drops it first.
190
+ */
191
+ accept(event) {
192
+ // DX-4233: the seq is given at arrival and never reused, so a record the dashboard publishes late is just the next one.
193
+ const own = event.ids ?? (event.id === null ? [] : [event.id]);
194
+ this.recentIds = boundedIds([...this.recentIds, ...own]);
195
+ for (const id of own) {
196
+ this.seenIds.push(id);
197
+ if (this.seenIds.length > DELIVERED_ID_MEMORY)
198
+ this.seenIds.shift();
199
+ }
200
+ this.seq += 1;
201
+ this.buffer.push({ seq: this.seq, own, recent: [...this.recentIds], text: event.text, scope: event.scope ?? "plan" });
202
+ const waiter = this.waiter;
203
+ if (waiter === null)
204
+ return;
205
+ this.waiter = null;
206
+ this.finishCall(waiter, this.answer());
207
+ }
208
+ // ---------------------------------------------------------------------------------------------------- lifecycle and health
209
+ /**
210
+ * DX-4233: drop the plan-scoped records, keeping the session-scoped ones. The ids of what is dropped leave the cursors of what
211
+ * stays (and the remembered ids): delivering a kept record must not claim an event that was dropped undelivered, or a
212
+ * restart would resume past it and the dashboard would never replay it.
213
+ */
214
+ dropPlanScoped() {
215
+ const dropped = new Set(this.buffer.filter((r) => r.scope === "plan").flatMap((r) => r.own));
216
+ this.buffer = this.buffer
217
+ .filter((r) => r.scope === "session")
218
+ .map((r) => ({ ...r, recent: r.recent.filter((id) => !dropped.has(id)) }));
219
+ this.recentIds = this.recentIds.filter((id) => !dropped.has(id));
220
+ // DX-4233: `seenIds` (the listener's duplicate guard for a re-mint on the SAME run) deliberately keeps them. A plan move's
221
+ // dropped events belong to a plan the session left, and the dashboard replays only the session's current plan, so nothing
222
+ // legitimate is replayed into the guard; and the gap, which does need them replayed, ends the run, so `start` builds a fresh `seenIds` from the next call's cursor.
223
+ }
224
+ /** The session moved to another plan: drop what was buffered or held for the plan it left; session-scoped records stay. */
225
+ startClean() {
226
+ this.dropPlanScoped();
227
+ this.digest.reset();
228
+ }
229
+ /** Creates the heartbeat reporter on the first call; a later call's transcript path (a resumed session) is handed to it. */
230
+ ensureReporter(transcriptPath) {
231
+ if (this.reporter !== null) {
232
+ this.reporter.useTranscript(transcriptPath);
233
+ return;
234
+ }
235
+ this.reporter = new HeartbeatReporter({
236
+ auth: () => {
237
+ const auth = this.deps.auth();
238
+ if (auth === null)
239
+ throw new Error("plan_events_wait: this server holds no dashboard key to heartbeat with");
240
+ return auth;
241
+ },
242
+ http: this.deps,
243
+ makeStatsWatcher: this.deps.makeStatsWatcher,
244
+ connection: () => heartbeatConnection(this.connectionState, this.stoppedReason, this.lastInventoryError),
245
+ streaming: () => this.connectionState === "streaming",
246
+ // `origin: null` skips the agent/machine hold: the notice reaches the session at once. It is about the session, not a plan.
247
+ announce: (text) => this.digest.route({ type: "event", id: null, text, origin: null, scope: "session" }),
248
+ forceReconnect: () => this.run?.forceReconnect?.(),
249
+ }, transcriptPath ?? null);
250
+ }
251
+ /** Mark the relay in use and make sure the heartbeat is scheduled. */
252
+ touch() {
253
+ this.lastActivityAt = this.deps.now();
254
+ this.heartbeat ??= this.deps.scheduleInterval(() => this.heartbeatTick(), HEARTBEAT_INTERVAL_MS);
255
+ }
256
+ heartbeatTick() {
257
+ // DX-4233: a pending call keeps the relay alive however long it waits; only a gap with no call at all suspends it.
258
+ if (this.waiter === null && this.deps.now() - this.lastActivityAt > HEARTBEAT_ACTIVITY_WINDOW_MS) {
259
+ // Nobody is reading this session's events any more: stop heartbeating (the dashboard then reads the listener not
260
+ // healthy) and let go of the stream. The next call starts it again from its cursor; the dashboard replays what it missed.
261
+ this.suspend();
262
+ return;
263
+ }
264
+ if (this.deps.auth() === null)
265
+ return;
266
+ this.reporter?.tick();
267
+ }
268
+ /**
269
+ * Let go of everything the relay holds for a caller: the stream, the heartbeat, the digest, and the plan-scoped records.
270
+ * What was held for a digest was never marked delivered, so the dashboard's replay (after the caller's cursor) returns it to
271
+ * whoever calls next. Session-scoped records stay (the dashboard never replays them), and so does the sequence, so a cursor
272
+ * stays valid across the gap; the delivered ids come back with the next call's cursor.
273
+ */
274
+ suspend() {
275
+ this.heartbeat?.stop();
276
+ this.heartbeat = null;
277
+ this.run?.abort.abort();
278
+ this.run = null;
279
+ this.reporter?.stop();
280
+ this.reporter = null;
281
+ this.digest.reset();
282
+ this.dropPlanScoped();
283
+ // DX-4233: `dropPlanScoped` already took the undelivered records' ids out of `recentIds`, so the dashboard replays them; what
284
+ // stays there was delivered. `seenIds` is reassigned by `start`, and a plan move after the gap is dropped by `dropPlanScoped`
285
+ // above, so neither is reset here.
286
+ this.pendingStop = null;
287
+ this.connectionState = "reconnecting";
288
+ this.stoppedReason = null;
289
+ this.lastInventoryError = null;
290
+ }
291
+ // ------------------------------------------------------------------------------------------------- the mint / stream loop
292
+ start(cursor) {
293
+ // The listener's duplicate guard starts with every event the caller was handed and everything still buffered: the dashboard
294
+ // replays its 30 s overlap before the highest of them, so a replayed one is dropped and a never-delivered one is delivered.
295
+ this.seenIds = capResumeIds([...(cursor?.ids ?? []), ...this.buffer.flatMap((r) => r.own)]);
296
+ this.stoppedReason = null;
297
+ const run = { abort: new AbortController(), forceReconnect: null };
298
+ this.run = run;
299
+ void this.loop(run).catch((err) => {
300
+ // A bug, not a stream outcome: say so out loud, and answer it like any terminal outcome rather than idle silently.
301
+ const message = err instanceof Error ? err.message : String(err);
302
+ process.stderr.write(`plan_events_wait: the event relay failed: ${message}\n`);
303
+ this.terminal(run, "relay_failed", message);
304
+ });
305
+ }
306
+ current(run) {
307
+ return this.run === run && !run.abort.signal.aborted;
308
+ }
309
+ stopRecord(reason, detail) {
310
+ return { reason, detail, fix: fixForStop(reason) };
311
+ }
312
+ /** A terminal outcome: the pending call (or else the next) answers `{stopped}`; the loop ends. */
313
+ terminal(run, reason, detail) {
314
+ // DX-4233: only the run that is still current may end the relay; a superseded or closed run ending late is ignored.
315
+ if (!this.current(run))
316
+ return;
317
+ this.digest.cancel();
318
+ this.run = null;
319
+ this.connectionState = "stopped";
320
+ this.stoppedReason = reason;
321
+ const stopped = this.stopRecord(reason, detail);
322
+ const waiter = this.waiter;
323
+ if (waiter === null) {
324
+ this.pendingStop = stopped;
325
+ return;
326
+ }
327
+ this.waiter = null;
328
+ this.finishCall(waiter, { stopped });
329
+ }
330
+ async loop(run) {
331
+ const sleep = (ms) => this.deps.sleep(ms, run.abort.signal);
332
+ // The mint and the inventory read end with the run, so a closed relay leaves no request in flight.
333
+ const http = { ...this.deps, signal: run.abort.signal };
334
+ let backoff = INITIAL_BACKOFF_MS;
335
+ let refusedInRow = 0;
336
+ let inventoryRead = false;
337
+ for (;;) {
338
+ if (!this.current(run))
339
+ return;
340
+ this.connectionState = "reconnecting";
341
+ this.reporter?.resetFailureStreak();
342
+ run.forceReconnect = null;
343
+ const auth = this.deps.auth();
344
+ if (auth === null)
345
+ return this.terminal(run, "credential_unavailable", "this session holds no dashboard key");
346
+ const minted = await mintTicket(auth, http);
347
+ if (!this.current(run))
348
+ return;
349
+ if (minted.kind === "terminal")
350
+ return this.terminal(run, minted.reason, minted.detail);
351
+ if (minted.kind === "transient") {
352
+ await sleep(backoff);
353
+ backoff = Math.min(MAX_BACKOFF_MS, backoff * 2);
354
+ continue;
355
+ }
356
+ // The inventory is a property of the credential and the plan: read best effort, never a gate. A failed read is reported
357
+ // on the heartbeat (`inventory: unavailable`), never silently healthy, and is tried again at the next re-mint.
358
+ if (!inventoryRead) {
359
+ const inventory = await fetchPlanInventory(auth, http);
360
+ if (!this.current(run))
361
+ return;
362
+ inventoryRead = inventory.kind === "ok";
363
+ this.lastInventoryError = inventory.kind === "ok" ? null : inventory.detail;
364
+ }
365
+ backoff = INITIAL_BACKOFF_MS;
366
+ const attempt = { stopped: null, emitted: false };
367
+ const startedAt = this.deps.now();
368
+ await runListener({
369
+ streamUrl: minted.streamUrl,
370
+ ticket: minted.ticket,
371
+ leaseMs: minted.leaseMs,
372
+ resumeIds: capResumeIds([...this.seenIds, ...this.digest.heldIds()]),
373
+ }, {
374
+ fetch: this.deps.fetch,
375
+ sleep,
376
+ now: this.deps.now,
377
+ readIdleTimeoutMs: this.deps.readIdleTimeoutMs,
378
+ signal: run.abort.signal,
379
+ write: (output) => {
380
+ if (!this.current(run))
381
+ return;
382
+ if (output.type === "stopped") {
383
+ attempt.stopped = output;
384
+ return;
385
+ }
386
+ attempt.emitted = true;
387
+ this.digest.route(output);
388
+ },
389
+ onState: (state) => {
390
+ if (!this.current(run))
391
+ return;
392
+ this.connectionState = state;
393
+ // Report `streaming` now, not up to a beat later: the session reads healthy as soon as it is.
394
+ if (state === "streaming" && this.deps.auth() !== null)
395
+ this.reporter?.tick();
396
+ },
397
+ onConnectionAttempt: (abort) => {
398
+ run.forceReconnect = abort;
399
+ },
400
+ });
401
+ if (!this.current(run))
402
+ return;
403
+ const stopped = attempt.stopped;
404
+ // DX-4233: the listener always ends with a stop record; its absence is a bug, answered as `relay_failed` by `start`'s catch.
405
+ if (stopped === null)
406
+ throw new Error("the stream reader ended without a stop record");
407
+ if (stopped.reason === "lease_expired") {
408
+ refusedInRow = 0;
409
+ continue;
410
+ }
411
+ if (stopped.reason === "refused") {
412
+ const classified = classifyAdmissionRefusal(stopped.refusal);
413
+ if (classified !== null)
414
+ return this.terminal(run, classified.reason, classified.detail);
415
+ const provedHealthy = attempt.emitted || this.deps.now() - startedAt >= HEALTHY_CONNECTION_MS;
416
+ refusedInRow = provedHealthy ? 1 : refusedInRow + 1;
417
+ if (refusedInRow >= MAX_REFUSED_TICKETS_IN_A_ROW) {
418
+ return this.terminal(run, "refused", `${stopped.detail} (a freshly minted ticket was refused ${refusedInRow} times in a row)`);
419
+ }
420
+ continue;
421
+ }
422
+ return this.terminal(run, stopped.reason, stopped.detail);
423
+ }
424
+ }
425
+ }
@@ -0,0 +1,9 @@
1
+ import { checkedTranscriptPath } from "./session-stats.js";
2
+ /**
3
+ * DX-4233 - the body of the `plan_events_wait` tool: the transcript path is checked (a refusal names it), then the relay waits,
4
+ * and a cancelled request (`signal`, the SDK's `extra.signal`) drops the pending wait. A malformed cursor is refused by the relay.
5
+ */
6
+ export function servePlanEventsWait(relay, args, signal) {
7
+ const transcriptPath = args.transcript_path === undefined ? undefined : checkedTranscriptPath(args.transcript_path, "plan_events_wait transcript_path");
8
+ return relay.wait({ planId: args.plan_id, cursor: args.cursor, timeoutMs: args.timeout_ms, transcriptPath }, signal);
9
+ }
@@ -22,10 +22,10 @@
22
22
  * non-2xx, bad shape, for every one). Never silent: the hook prints a could-not-load notice.
23
23
  *
24
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
25
+ * same resolver the relay uses, so the lookup reaches the dashboard that
26
26
  * connected it. READ-ONLY: it advances no cursor on the server.
27
27
  */
28
- import { SESSION_ID_HEADER, resolveBridgeOptions } from "./bridge.js";
28
+ import { SESSION_ID_HEADER, resolveDashboardAuth } from "./session-options.js";
29
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;
@@ -119,7 +119,7 @@ resolveFrom = {}) {
119
119
  for (const predecessor of candidates) {
120
120
  let options;
121
121
  try {
122
- options = resolveBridgeOptions({ sessionId: predecessor.sessionId, resumeIds: [] }, env, resolveFrom);
122
+ options = resolveDashboardAuth(predecessor.sessionId, env, resolveFrom);
123
123
  }
124
124
  catch (err) {
125
125
  const start = err;
@@ -49,7 +49,7 @@ const DENIED_INSTRUCTION = "The user denied the request. Do not retry unless the
49
49
  const DASHBOARD_OUTDATED_ERROR = "this dashboard does not support request-and-approve yet (POST /api/access-requests answered 404): " +
50
50
  "it must be updated before this session can sign in";
51
51
  const RATE_LIMITED_ERROR = "too many access requests from this address; wait, then call plan_connect again";
52
- const unknownTeamError = (team) => `the dashboard has no team "${team}" (DANXBOT_TEAM); fix DANXBOT_TEAM in this server's env (the repo's .mcp.json env block or the shell) and restart the session`;
52
+ const unknownTeamError = (team) => `the dashboard has no team "${team}" (DANXBOT_TEAM); fix DANXBOT_TEAM in this server's env (your shell, or a repo's committed .claude/settings.json env block) and restart the session`;
53
53
  const NO_SESSION_ID_ERROR = "this server has no CLAUDE_CODE_SESSION_ID, so it cannot request a session key";
54
54
  const CLAIMED_LOST_ERROR = "the dashboard says this request was already claimed, but this process never received the key; " +
55
55
  "start a new request";
@@ -109,7 +109,7 @@ export class SessionAccess {
109
109
  this.dropKey();
110
110
  return this.revokedHalt();
111
111
  }
112
- /** Where the current credential comes from and its fingerprint, for the bridge's connection record. */
112
+ /** Where the current credential comes from and its fingerprint, for the relay's connection record. */
113
113
  connection() {
114
114
  if (this.credential === null || this.revoked !== null)
115
115
  return null;
@@ -152,7 +152,7 @@ export class SessionAccess {
152
152
  return { result };
153
153
  }
154
154
  /**
155
- * DX-4390: the stored file is the bridge's only way to the key, and the 7-day prune ages out
155
+ * DX-4390: the stored file is the relay's only way to the key, and the 7-day prune ages out
156
156
  * a file by when it was last written. A server in use rewrites it (atomically) at most hourly,
157
157
  * which also restores a file someone deleted while this process still holds the key.
158
158
  */
@@ -1,22 +1,21 @@
1
1
  /**
2
2
  * DX-2862 — the record that hands ONE Claude Code session's dashboard
3
- * connection from its MCP server to its event bridge.
3
+ * connection from its MCP server to the plugin's hook subcommands.
4
4
  *
5
- * THE PROBLEM THIS SOLVES. `danx-dashboard-mcp bridge` runs from a plugin hook,
6
- * in the SESSION's environment — not in the MCP server's, which is the only
7
- * place the server's `.mcp.json` env block exists. So the bridge cannot see
8
- * what the server was configured with, and before this it simply used its own
9
- * ambient env credential. When that was a different credential from
10
- * the server's, every event was dropped silently (the card's incident).
5
+ * THE PROBLEM THIS SOLVES. A hook's subcommand (`activity`, `background-work`, `event-text`,
6
+ * `ready-cards`, `restart-notice`) runs in the SESSION's environment, not in the MCP server's, which is the only
7
+ * place the server's `.mcp.json` env block exists. So it cannot see what the server was configured with, and before this it
8
+ * simply used its own ambient env credential. When that was a different credential from the server's, its report was
9
+ * dropped silently (the card's incident). The plan event relay (`plan_events_wait`) needs none of this: it runs inside the
10
+ * server, with the server's own credential. The listener reminder reads this record too.
11
11
  *
12
12
  * WHAT IS WRITTEN, AND WHAT IS NOT. On a successful `plan_connect` the server
13
13
  * writes the dashboard URL, the credential SOURCE (a session key file path, or the name of
14
- * an env var — never a credential) and the credential's FINGERPRINT. The bridge
14
+ * an env var — never a credential) and the credential's FINGERPRINT. A subcommand
15
15
  * reads that record, resolves the SAME source through the same resolver
16
16
  * (`credential.ts`), and refuses to run unless what it resolved fingerprints
17
17
  * identically. A rotated file or a session environment that disagrees with the
18
- * server's therefore fails loud instead of streaming somebody else's events —
19
- * or, as in DX-2862, none at all.
18
+ * server's therefore fails loud instead of reporting as somebody else.
20
19
  *
21
20
  * WHY `plan_connect` AND NOTHING ELSE WRITES IT. A session can have several
22
21
  * dashboard MCP servers configured (gpt-manager has a deployed one and a local
@@ -92,7 +91,7 @@ export function pruneStaleSessionFiles(dir, now) {
92
91
  }
93
92
  }
94
93
  /**
95
- * Record this session's connection. Write-then-rename, so a bridge reading
94
+ * Record this session's connection. Write-then-rename, so a subcommand reading
96
95
  * concurrently sees the old record or the new one, never a torn one.
97
96
  */
98
97
  export function writeSessionConnection(record, options = {}) {
@@ -147,8 +146,8 @@ export function readSessionConnection(sessionId, options = {}) {
147
146
  * that tool's own answer when it could not be recorded.
148
147
  *
149
148
  * FAIL LOUD, IN THE ONE PLACE THE AGENT IS LOOKING. A connect whose record is
150
- * missing produces a session that is connected and a bridge that refuses to
151
- * start — so the failure belongs in the connect's answer, not only in a file
149
+ * missing produces a session that is connected but whose hook reports (activity, background work, ready cards, the
150
+ * restart notice) refuse to run — so the failure belongs in the connect's answer, not only in a file
152
151
  * nobody reads. It does NOT fail the connect itself: the session IS connected,
153
152
  * and reporting otherwise would be a second lie on top of the first.
154
153
  */
@@ -157,8 +156,8 @@ export function recordSessionConnectionAfterConnect(result, inputs, write = writ
157
156
  return {};
158
157
  if (inputs.sessionId === undefined || inputs.sessionId === "") {
159
158
  return {
160
- event_bridge_error: "connected, but this server has no CLAUDE_CODE_SESSION_ID, so the plan event bridge has no connection to read — " +
161
- "dashboard events will NOT reach this session",
159
+ connection_record_error: "connected, but this server has no CLAUDE_CODE_SESSION_ID, so no connection record can be written — " +
160
+ "the plugin's activity, background-work, ready-cards and restart-notice reports will not reach the dashboard",
162
161
  };
163
162
  }
164
163
  try {
@@ -173,8 +172,8 @@ export function recordSessionConnectionAfterConnect(result, inputs, write = writ
173
172
  }
174
173
  catch (err) {
175
174
  return {
176
- event_bridge_error: `connected, but recording the connection for the plan event bridge failed (${err.message}) — ` +
177
- "dashboard events will NOT reach this session until it succeeds",
175
+ connection_record_error: `connected, but recording this session's connection failed (${err.message}) — ` +
176
+ "the plugin's activity, background-work, ready-cards and restart-notice reports will not reach the dashboard until it succeeds",
178
177
  };
179
178
  }
180
179
  }
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * DX-4390 (PLAN-17 section 96) - the dashboard key a user approved for ONE agent session, kept on
3
- * this machine so the session's MCP server and its event bridge both authenticate with it.
3
+ * this machine so the session's MCP server and its event relay both authenticate with it.
4
4
  *
5
5
  * One file per dashboard and per session:
6
6
  * `~/.config/danxbot/session-keys/<sha12(dashboard url)>/<session id>.json`, mode 0600. The key
@@ -11,7 +11,7 @@
11
11
  * rewrites its file at least hourly (`SessionAccess.guard`), so only an unused session's file
12
12
  * ages out. The session id picks the file but never selects a key on the dashboard.
13
13
  *
14
- * Written write-then-rename, like the session connection record, so a bridge reading
14
+ * Written write-then-rename, like the session connection record, so a relay reading
15
15
  * concurrently sees the old file or the new one, never a torn one.
16
16
  */
17
17
  import { createHash } from "node:crypto";
@@ -0,0 +1,58 @@
1
+ /**
2
+ * DX-2862 / DX-4233 - how a one-shot client of the dashboard (`activity`, `background-work`, `event-text`, `ready-cards`,
3
+ * `restart-notice`) learns WHICH dashboard to call and AS WHOM: the connection record this session's own MCP server wrote when
4
+ * it connected a plan (`session-connection.ts`), resolved through the same credential resolver the server used, and refused
5
+ * unless it fingerprints identically. (The in-process relay behind `plan_events_wait` needs none of this: it lives inside the
6
+ * server that holds the credential.)
7
+ */
8
+ import { homedir } from "node:os";
9
+ import { credentialFingerprint, describeCredentialSource, readCredential } from "./credential.js";
10
+ import { readSessionConnection } from "./session-connection.js";
11
+ import { fixForStop } from "./stop-fixes.js";
12
+ export const SESSION_ID_HEADER = "x-danx-session-id";
13
+ /** A start this process cannot make, named the way the session will be told about it. */
14
+ export class SessionStartError extends Error {
15
+ // `ListenStopReason`, not `string`: a start-time failure is always one of `no_connection_record` / `credential_unavailable`
16
+ // / `credential_mismatch` (there is no stream yet).
17
+ reason;
18
+ fix;
19
+ constructor(reason, detail, fix) {
20
+ super(detail);
21
+ this.name = "SessionStartError";
22
+ this.reason = reason;
23
+ this.fix = fix;
24
+ }
25
+ }
26
+ /**
27
+ * The session's connection, as its own MCP server recorded it, and the proof that this process holds the SAME credential that
28
+ * server does. Never falls back to an ambient env credential: that fallback is precisely DX-2862.
29
+ */
30
+ export function resolveDashboardAuth(sessionId, env, options = {}) {
31
+ const home = options.home ?? homedir();
32
+ let record;
33
+ try {
34
+ record = readSessionConnection(sessionId, { home });
35
+ }
36
+ catch (err) {
37
+ throw new SessionStartError("no_connection_record", err.message, fixForStop("no_connection_record"));
38
+ }
39
+ if (record === null) {
40
+ throw new SessionStartError("no_connection_record", `session ${sessionId} has no dashboard connection record — no danx-dashboard MCP server has connected it to a plan`, fixForStop("no_connection_record"));
41
+ }
42
+ let token;
43
+ try {
44
+ token = readCredential(record.source, env);
45
+ }
46
+ catch (err) {
47
+ const credentialError = err;
48
+ throw new SessionStartError("credential_unavailable", `the credential this session's danx-dashboard MCP server declared cannot be resolved here: ${credentialError.message}`,
49
+ // CredentialError.fix is a required string, never absent — no fallback needed.
50
+ credentialError.fix);
51
+ }
52
+ const fingerprint = credentialFingerprint(token);
53
+ if (fingerprint !== record.fingerprint) {
54
+ throw new SessionStartError("credential_mismatch", `${describeCredentialSource(record.source)} now yields credential ${fingerprint}, but this session's ` +
55
+ `danx-dashboard MCP server is using ${record.fingerprint} — a one-shot call would authenticate as somebody else`, fixForStop("credential_mismatch"));
56
+ }
57
+ return { dashboardUrl: record.dashboardUrl.replace(/\/+$/, ""), token, sessionId };
58
+ }