@thehammer/danx-dashboard-mcp 0.1.287 → 0.1.289

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
@@ -63,7 +63,7 @@ Card and plan reads/writes go through `danxbot_api` against the issue/plan route
63
63
 
64
64
  A program calls this, not a person: the danxbot Claude Code plugin's relay loop, inside the session, calls it over and over on the session's own danx-dashboard MCP server and hands every event it gets to the session. A plan-orchestrator dispatch (a headless session that works one plan) has no plugin relay, so it keeps ONE background waiter sub-agent that loops on this tool until events exist and returns them with the last cursor, and the orchestrator passes that cursor to the next waiter it arms (DX-4667); the `plan-orchestrator` profile alone is granted the tool on a dispatch (it is an opt-in tool). The `bridge` subcommand is gone: no process is spawned, no `npx` cold start, and nothing outlives the session, because the stream lives in the MCP server, which ends with its session.
65
65
 
66
- Input `{plan_id: number, cursor: string | null, timeout_ms: 1000..30000, transcript_path?: string}`. Answer, one of:
66
+ Input `{plan_id: number | null, cursor: string | null, timeout_ms: 1000..30000, transcript_path?: string}`. `plan_id` is `null` for a signed-in session on no plan (DX-4882): its stream still carries the pacing and resume messages of its Claude account, and it receives no plan events. A move between a plan and `null` starts clean like a move between plans: the plan-scoped records of the plan left are dropped, the session-scoped ones kept. Answer, one of:
67
67
 
68
68
  - `{events: [{cursor: string, text: string, urgent: boolean}]}`: every record the caller has not delivered, in arrival order (empty at the timeout). Each record carries the cursor to store once THAT record is delivered. There is no `id`: the caller compares nothing. `urgent` (DX-4721) is decided in one place by the record's kind (`src/urgent-records.ts`): true for a critical usage-pacing stop and the revoked-key notice, false for everything else (activity events, nudge, resume message, digest, the relay's failure notice, an unreadable line). The caller adds an urgent record to a running turn at once and delivers any other after the turn ends; `urgent` never changes order, seq or acknowledgement. The dashboard writes no operator-stop record yet; when it does, it becomes one more kind in that function.
69
69
  - `{stopped: {reason, detail, fix}}`: a terminal outcome (`not_connected`, `plan_archived`, `unauthorized`, `key_revoked`, `mint_refused`, `mint_bad_response`, `session_is_worker`, `superseded`, `replaced`, `revoked`, `refused`, `credential_unavailable`, `relay_failed`). `fix` is the remedy text (`STOP_FIXES`, `src/stop-fixes.ts`). The next call starts from scratch.
package/dist/index.js CHANGED
@@ -768,7 +768,7 @@ async (args) => {
768
768
  await syncToolsAfterSignIn();
769
769
  // DX-3997 — a (re)connect starts or re-keys the plugin's relay loop; the next `danxbot_api` must re-read health.
770
770
  listenerReminder.invalidate();
771
- // DX-2985 — a leave drops the local connection record (the relay's stream ends `not_connected`; a later connect writes a fresh one).
771
+ // DX-2985 — a leave drops the local connection record (its stream stays open, DX-4882: the relay keeps serving the session's pacing and resume messages; a later connect writes a fresh one).
772
772
  if (args.disconnect === true) {
773
773
  removeSessionConnectionAfterLeave(result, config.session?.id);
774
774
  return jsonResult(result);
@@ -789,20 +789,22 @@ async (args) => {
789
789
  });
790
790
  // ---------------- plan_events_wait (DX-4233) ----------------
791
791
  strictTool("plan_events_wait",
792
+ // DX-4882 - `plan_id` is null for a signed-in session on no plan: its stream still carries its account's pacing and resume messages.
792
793
  // DX-4233 - for the danxbot plugin's relay loop. DX-4667 - and for a background waiter sub-agent of a plan orchestrator, which
793
794
  // loops on it until events exist and returns them (and the cursor) to the orchestrator it wakes. It answers `events` (each
794
795
  // `{cursor, text, urgent}`; empty after `timeout_ms`), the cursor to send being the last delivered event's, or `stopped` with the remedy
795
796
  // to tell the session. General-audience text (DX-4550): nothing here names our own setup.
796
- "Waits for events on the plan this session is connected to. For a relay or a background waiter that calls it in a loop " +
797
- "and hands every event to the session; do not call it for any other reason. `plan_id` is the plan the session is on (a " +
798
- "different plan than the last call's starts clean). `cursor` is the `cursor` of the last event delivered, as answered (null " +
797
+ "Waits for events for this session: the events of the plan it is connected to, and the messages about its usage. For a relay " +
798
+ "or a background waiter that calls it in a loop and hands every event to the session; do not call it for any other reason. " +
799
+ "`plan_id` is the plan the session is on, or null when it is on no plan (then only the usage messages arrive; a different " +
800
+ "plan than the last call's, or a move to or from null, starts clean). `cursor` is the `cursor` of the last event delivered, as answered (null " +
799
801
  "when none). Answers `{events: [{cursor, text, urgent}]}`, with `events` empty once `timeout_ms` passes, or `{stopped: " +
800
802
  "{reason, detail, fix}}` when the stream ended: tell the session the `fix`. An event stays until a later call's " +
801
803
  "`cursor` passes it. `urgent` is true for an event the session must hear at once (a critical usage limit, a revoked " +
802
804
  "key) and false for every other: deliver an urgent one to the session even while it is working, and any other once its " +
803
805
  "current turn ends. Only one wait is pending per session: a newer call ends the older. " +
804
806
  "`transcript_path` is the session's transcript file, for its usage and title.", {
805
- plan_id: z.number().int().positive().describe("Id of the plan this session is connected to."),
807
+ plan_id: z.number().int().positive().nullable().describe("Id of the plan this session is connected to, or null when it is on no plan."),
806
808
  cursor: z
807
809
  .string()
808
810
  .nullable()
package/dist/json-body.js CHANGED
@@ -1,20 +1,9 @@
1
1
  /**
2
2
  * DX-2920 — the ONE place that reads a JSON body's top-level `.error` string.
3
3
  *
4
- * WHY THIS EXISTS. the stream client (`session-stream.ts`, classifying a mint refusal) and `listen.ts`
5
- * (classifying a stream admission refusal) both need to answer "what error
6
- * code did this JSON body carry", and before this card each answered it a
7
- * different way: the mint code parsed the body directly, `listen.ts` never
8
- * looked past a flattened, human-readable detail string at all — a caller
9
- * that wanted to react to a SPECIFIC refusal (the admission-time 409 the
10
- * mint route also uses) had to regex-scrape that flattened string back apart,
11
- * a second, fragile source of truth for the same fact. This module is generic
12
- * — it knows nothing about "session_not_connected" or "stream_ticket_invalid",
13
- * only that a JSON object may carry a top-level string field named `error` —
14
- * so it can live in BOTH the domain-aware `session-stream.ts` and the deliberately
15
- * generic `listen.ts` (whose own docblock states it carries no domain
16
- * knowledge of the dashboard's specific error shapes) without either one
17
- * importing the other.
4
+ * WHY THIS EXISTS. the stream client (`session-stream.ts`, classifying a mint refusal such as `409 session_is_worker`) reads a refusal's
5
+ * error code from the parsed body, and this is the one place that decides what "carries an error code" means. Generic: it knows no
6
+ * dashboard error name, only that a JSON object may carry a top-level string field named `error`.
18
7
  */
19
8
  export function errorCodeOf(body) {
20
9
  return typeof body === "object" && body !== null && typeof body.error === "string"
package/dist/listen.js CHANGED
@@ -16,16 +16,10 @@
16
16
  * DX-4342's `resume` frame (`formatResumeLine`): all are advisory, written fresh by the dashboard's own keep-alive tick,
17
17
  * and never replayed, so they ride this same shape with `id: null` rather
18
18
  * than growing a THIRD output variant.
19
- * - `{type:"stopped", reason, detail, refusal?}` — once, last. `reason`
19
+ * - `{type:"stopped", reason, detail}` — once, last. `reason`
20
20
  * is the dashboard's own end reason (`superseded`, `replaced`, `revoked`,
21
21
  * `not_connected`), `refused` (the ticket was not admitted), or
22
- * `lease_expired` (no healthy connection for the lease). DX-2920 —
23
- * `refusal` is populated ONLY for `reason === "refused"`: `{status,
24
- * errorCode, body}`, the admission response parsed ONCE here, so a
25
- * consumer can recognize a SPECIFIC refusal (the dashboard's own
26
- * `409 session_not_connected`, the same shape the mint route uses) by
27
- * status + error code rather than re-deriving it from the flattened
28
- * `detail` string. A wire `reason` value this build does not recognize (a
22
+ * `lease_expired` (no healthy connection for the lease). A wire `reason` value this build does not recognize (a
29
23
  * newer dashboard's reason an older client hasn't shipped yet)
30
24
  * normalizes to `"unknown"` rather than passing the raw string through,
31
25
  * so the closed set stays closed — `fixForStop`'s generic
@@ -48,7 +42,6 @@
48
42
  * (`src/issues/db/issue-activity.ts`); this published package cannot import
49
43
  * danxbot source.
50
44
  */
51
- import { errorCodeOf } from "./json-body.js";
52
45
  import { LINE_PREFIX, oneLine } from "./one-line.js";
53
46
  import { pacingRelayLine } from "./pacing-relay.js";
54
47
  import { isUrgent } from "./urgent-records.js";
@@ -490,8 +483,8 @@ export async function runListener(options, deps) {
490
483
  };
491
484
  for (const id of capResumeIds(options.resumeIds))
492
485
  remember(id);
493
- function stop(reason, detail, code, extra) {
494
- deps.write({ type: "stopped", reason, detail, refusal: extra?.refusal });
486
+ function stop(reason, detail, code) {
487
+ deps.write({ type: "stopped", reason, detail });
495
488
  return code;
496
489
  }
497
490
  /**
@@ -614,24 +607,7 @@ export async function runListener(options, deps) {
614
607
  const response = await deps.fetch(options.streamUrl, { headers, signal: controller.signal });
615
608
  if (response.status >= 400 && response.status < 500 && !RETRYABLE_CLIENT_STATUSES.has(response.status)) {
616
609
  const text = await response.text();
617
- // DX-2920 — parsed ONCE, here, into a structured outcome a consumer can
618
- // classify by status + error code (the relay does, matching a specific
619
- // admission refusal such as the dashboard's own `409 session_not_connected`)
620
- // rather than re-deriving it from the flattened `detail` string below.
621
- let body = null;
622
- try {
623
- body = text === "" ? null : JSON.parse(text);
624
- }
625
- catch {
626
- body = null;
627
- }
628
- return {
629
- kind: "refused",
630
- status: response.status,
631
- errorCode: errorCodeOf(body),
632
- body,
633
- detail: `HTTP ${response.status} ${oneLine(text, 300)}`,
634
- };
610
+ return { kind: "refused", detail: `HTTP ${response.status} ${oneLine(text, 300)}` };
635
611
  }
636
612
  if (!response.ok || response.body === null) {
637
613
  await response.body?.cancel();
@@ -682,9 +658,7 @@ export async function runListener(options, deps) {
682
658
  return stop(outcome.reason, "the dashboard ended this listener", 1);
683
659
  }
684
660
  if (outcome.kind === "refused") {
685
- return stop("refused", `the dashboard refused the listener ticket: ${outcome.detail}`, 1, {
686
- refusal: { status: outcome.status, errorCode: outcome.errorCode, body: outcome.body },
687
- });
661
+ return stop("refused", `the dashboard refused the listener ticket: ${outcome.detail}`, 1);
688
662
  }
689
663
  if (outcome.detail !== undefined)
690
664
  lastDropDetail = outcome.detail;
@@ -30,8 +30,9 @@
30
30
  * THE ACCEPTED GAP: an insert that commits more than that 30 s overlap after the event it follows is missed by a RESTART
31
31
  * resume (the dashboard's own limit); a running relay never misses one. And a burst of more than `CURSOR_IDS_MAX` events
32
32
  * inside the overlap can replay the oldest of them once (a duplicate, never a loss).
33
- * 3. ONE PLAN. A call names the plan the session is on (`planId`). A plan different from the last call's drops the
34
- * PLAN-scoped records buffered or held for the plan it left, so a move starts clean. SESSION-scoped records (pacing, resume
33
+ * 3. ONE PLAN. A call names the plan the session is on (`planId`), or `null` for a signed-in session on no plan (DX-4882: its stream
34
+ * still carries the pacing and resume messages of its account). A plan different from the last call's, to or from `null` included,
35
+ * drops the PLAN-scoped records buffered or held for the plan it left, so a move starts clean. SESSION-scoped records (pacing, resume
35
36
  * and the relay's own failure and revoked-key notices: they say nothing about a plan, and the dashboard never replays
36
37
  * them) are kept, here and across the 40 s suspend below. The ids of a dropped record leave the cursors of the records that
37
38
  * stay: delivering a kept record must not claim an event that was dropped undelivered. The stream stays on a move: it
@@ -55,7 +56,7 @@
55
56
  import { DigestHold } from "./digest-hold.js";
56
57
  import { HeartbeatReporter } from "./heartbeat-reporter.js";
57
58
  import { capResumeIds, DELIVERED_ID_MEMORY, HEALTHY_CONNECTION_MS, INITIAL_BACKOFF_MS, MAX_BACKOFF_MS, runListener, } from "./listen.js";
58
- import { classifyAdmissionRefusal, fetchPlanInventory, HEARTBEAT_INTERVAL_MS, heartbeatConnection, MAX_REFUSED_TICKETS_IN_A_ROW, mintTicket, } from "./session-stream.js";
59
+ import { fetchPlanInventory, HEARTBEAT_INTERVAL_MS, heartbeatConnection, MAX_REFUSED_TICKETS_IN_A_ROW, mintTicket, } from "./session-stream.js";
59
60
  import { fixForStop } from "./stop-fixes.js";
60
61
  import { isUrgent } from "./urgent-records.js";
61
62
  /** The shortest and longest wait one call may ask for; a short ceiling keeps an orphaned waiter's life short. */
@@ -91,7 +92,8 @@ export class PlanEventRelay {
91
92
  buffer = [];
92
93
  waiter = null;
93
94
  run = null;
94
- lastPlanId = null;
95
+ /** The plan the last call named (`null` = a session on no plan); `undefined` before the first call, which has no plan to have moved from. */
96
+ lastPlanId = undefined;
95
97
  /** The last seq given: it continues from a presented cursor and survives a suspend, so a cursor stays valid across the gap. */
96
98
  seq = 0;
97
99
  /** The dashboard event ids that arrived (or were presented in a cursor), oldest first, bounded: the next record's `recent`. */
@@ -103,6 +105,8 @@ export class PlanEventRelay {
103
105
  connectionState = "reconnecting";
104
106
  stoppedReason = null;
105
107
  lastInventoryError = null;
108
+ /** DX-4882: whether the connected plan's inventory has been read in this run (a session on no plan has none to read). */
109
+ inventoryRead = false;
106
110
  lastActivityAt = 0;
107
111
  heartbeat = null;
108
112
  reporter = null;
@@ -120,8 +124,10 @@ export class PlanEventRelay {
120
124
  // DX-4233: one wait at a time; the older call answers empty so its caller is never left hanging.
121
125
  this.supersedeWaiter();
122
126
  // 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).
123
- if (this.lastPlanId !== null && args.planId !== this.lastPlanId)
127
+ if (this.lastPlanId !== undefined && args.planId !== this.lastPlanId) {
124
128
  this.startClean();
129
+ this.planMovedInventory(args.planId);
130
+ }
125
131
  this.lastPlanId = args.planId;
126
132
  if (cursor !== null) {
127
133
  // DX-4233: a fresh relay (a restarted server) continues the caller's sequence and remembers the ids it was handed; a
@@ -224,6 +230,31 @@ export class PlanEventRelay {
224
230
  // dropped events belong to a plan the session left, and the dashboard replays only the session's current plan, so nothing
225
231
  // 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.
226
232
  }
233
+ /** Whether the last call named a plan (not `null` = no plan, not `undefined` = no call yet). */
234
+ onAPlan() {
235
+ return typeof this.lastPlanId === "number";
236
+ }
237
+ /**
238
+ * DX-4882: the plan changed, so the inventory read for the old one no longer describes the session. On no plan there is nothing to read (and
239
+ * nothing to report as unavailable); on a plan, a live run reads it now (a run that is not live reads it when it starts).
240
+ */
241
+ planMovedInventory(planId) {
242
+ this.inventoryRead = false;
243
+ this.lastInventoryError = null;
244
+ const run = this.run;
245
+ const auth = this.deps.auth();
246
+ if (planId === null || run === null || auth === null)
247
+ return;
248
+ void this.readInventory(run, auth, { ...this.deps, signal: run.abort.signal });
249
+ }
250
+ /** The inventory is a property of the credential and the plan: read best effort, never a gate; a failed read is reported on the heartbeat (`inventory: unavailable`) and tried again at the next re-mint. */
251
+ async readInventory(run, auth, http) {
252
+ const inventory = await fetchPlanInventory(auth, http);
253
+ if (!this.current(run))
254
+ return;
255
+ this.inventoryRead = inventory.kind === "ok";
256
+ this.lastInventoryError = inventory.kind === "ok" ? null : inventory.detail;
257
+ }
227
258
  /** The session moved to another plan: drop what was buffered or held for the plan it left; session-scoped records stay. */
228
259
  startClean() {
229
260
  this.dropPlanScoped();
@@ -297,6 +328,7 @@ export class PlanEventRelay {
297
328
  // replays its 30 s overlap before the highest of them, so a replayed one is dropped and a never-delivered one is delivered.
298
329
  this.seenIds = capResumeIds([...(cursor?.ids ?? []), ...this.buffer.flatMap((r) => r.own)]);
299
330
  this.stoppedReason = null;
331
+ this.inventoryRead = false;
300
332
  const run = { abort: new AbortController(), forceReconnect: null };
301
333
  this.run = run;
302
334
  void this.loop(run).catch((err) => {
@@ -336,7 +368,6 @@ export class PlanEventRelay {
336
368
  const http = { ...this.deps, signal: run.abort.signal };
337
369
  let backoff = INITIAL_BACKOFF_MS;
338
370
  let refusedInRow = 0;
339
- let inventoryRead = false;
340
371
  for (;;) {
341
372
  if (!this.current(run))
342
373
  return;
@@ -356,14 +387,11 @@ export class PlanEventRelay {
356
387
  backoff = Math.min(MAX_BACKOFF_MS, backoff * 2);
357
388
  continue;
358
389
  }
359
- // The inventory is a property of the credential and the plan: read best effort, never a gate. A failed read is reported
360
- // on the heartbeat (`inventory: unavailable`), never silently healthy, and is tried again at the next re-mint.
361
- if (!inventoryRead) {
362
- const inventory = await fetchPlanInventory(auth, http);
390
+ // DX-4882: only a session on a plan has an inventory to read.
391
+ if (!this.inventoryRead && this.onAPlan()) {
392
+ await this.readInventory(run, auth, http);
363
393
  if (!this.current(run))
364
394
  return;
365
- inventoryRead = inventory.kind === "ok";
366
- this.lastInventoryError = inventory.kind === "ok" ? null : inventory.detail;
367
395
  }
368
396
  backoff = INITIAL_BACKOFF_MS;
369
397
  const attempt = { stopped: null, emitted: false };
@@ -412,9 +440,6 @@ export class PlanEventRelay {
412
440
  continue;
413
441
  }
414
442
  if (stopped.reason === "refused") {
415
- const classified = classifyAdmissionRefusal(stopped.refusal);
416
- if (classified !== null)
417
- return this.terminal(run, classified.reason, classified.detail);
418
443
  const provedHealthy = attempt.emitted || this.deps.now() - startedAt >= HEALTHY_CONNECTION_MS;
419
444
  refusedInRow = provedHealthy ? 1 : refusedInRow + 1;
420
445
  if (refusedInRow >= MAX_REFUSED_TICKETS_IN_A_ROW) {
@@ -133,9 +133,8 @@ export async function mintTicket(options, deps) {
133
133
  if (outcome.kind === "transient")
134
134
  return outcome;
135
135
  const { status, text, body } = outcome;
136
- if (status === 409 && errorCodeOf(body) === "session_not_connected") {
137
- return { kind: "terminal", reason: "not_connected", detail: "the session is not connected to a plan" };
138
- }
136
+ // DX-4882: a session on no plan is minted a ticket like any other (its stream carries its account's pacing and resume messages), so
137
+ // `409 session_not_connected` is no longer an answer the mint gives.
139
138
  // DX-3028 (AC2) — matched BEFORE the generic mint_refused fallback below,
140
139
  // which every other 409 falls into: this session id belongs to a worker
141
140
  // dispatch (`SessionIsWorkerError`, the same shape the heartbeat route
@@ -219,28 +218,6 @@ export async function fetchPlanInventory(options, deps) {
219
218
  const b = body;
220
219
  return { kind: "ok", boards: b.boards, cardCount: b.card_count };
221
220
  }
222
- /**
223
- * DX-2920 — a stream admission refusal (`listen.ts`'s STRUCTURED `refused`
224
- * outcome — `{status, errorCode, body}`, parsed once in `connectOnce`) whose
225
- * shape matches one the mint route already gives its OWN name to: `409
226
- * session_not_connected`, the SAME shape the mint route uses for "no plan
227
- * connected" (`plan-session-stream.ts`, DX-2863 review finding M2). A session
228
- * with no plan connected is the identical terminal state whether the
229
- * dashboard says so at mint or at the stream's own admission check, and the
230
- * session should see ONE remedy either way.
231
- *
232
- * Classifies by STATUS + ERROR CODE, never by re-deriving them from the
233
- * flattened `detail` string — the second-source-of-truth shape this card's fix
234
- * round closed (the regex-based `isSessionNotConnectedRefusal` it replaced).
235
- */
236
- export function classifyAdmissionRefusal(refusal) {
237
- if (refusal === undefined)
238
- return null;
239
- if (refusal.status === 409 && refusal.errorCode === "session_not_connected") {
240
- return { reason: "not_connected", detail: "the session is not connected to a plan" };
241
- }
242
- return null;
243
- }
244
221
  /**
245
222
  * The one real `scheduleInterval`: a `setInterval` and its canceler, for the relay's heartbeat. Unref'd: a heartbeat never keeps
246
223
  * the MCP server alive after its session ended.
@@ -24,7 +24,7 @@ export const STOP_FIXES = {
24
24
  unauthorized: "the dashboard no longer accepts this session's key (it lapsed after a day unused): call plan_connect again to request access",
25
25
  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; " +
26
26
  "do not call plan_connect or request access again",
27
- mint_refused: "check that this session is connected to a plan on the dashboard its MCP server points at, then call plan_connect again",
27
+ mint_refused: "check that this session is signed in to the dashboard its MCP server points at, then call plan_connect again",
28
28
  mint_bad_response: "check that DANXBOT_DASHBOARD_URL for this session's MCP server points at a danxbot dashboard",
29
29
  // A newer ticket exists (superseded) or another connection is already using this one (replaced): THIS listener is ending
30
30
  // because the session is already served elsewhere, not because anything is broken.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thehammer/danx-dashboard-mcp",
3
- "version": "0.1.287",
3
+ "version": "0.1.289",
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",