@thehammer/danx-dashboard-mcp 0.1.85 → 0.1.87

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bridge.js CHANGED
@@ -89,6 +89,71 @@ import { oneLine } from "./one-line.js";
89
89
  import { credentialFingerprint, describeCredentialSource, readCredential, } from "./credential.js";
90
90
  import { readSessionConnection } from "./session-connection.js";
91
91
  import { DELIVERED_ID_MEMORY, HEALTHY_CONNECTION_MS, READ_IDLE_TIMEOUT_MS, runListener, } from "./listen.js";
92
+ /**
93
+ * DX-2730 D4 — WHICH EVENTS WAKE THE SESSION, NOW THAT EVERY ONE OF THEM CAN.
94
+ *
95
+ * Every `ListenEvent` carries the origin its writer set (`operator` / `agent` /
96
+ * `machine`, or `null` for the idle-nudge exemption and an event this listener
97
+ * could not parse — see `ListenEvent.origin`'s docblock in `listen.ts`). This
98
+ * module is the ONLY place that decides what happens next:
99
+ *
100
+ * - `operator` (a real human) and `null` (nudge / unreadable) emit AT ONCE,
101
+ * exactly as every event did before this card — a session must never wait
102
+ * to hear from the person it is working with, and a fault or a nudge must
103
+ * never be hidden behind a delay either.
104
+ * - `agent` and `machine` are HELD. Every dispatched agent's own writes and
105
+ * every server-side guard/reviewer/recovery write share this treatment:
106
+ * gate reviews, triage notes, auto-blocks. Held events are combined into
107
+ * ONE digest record, flushed the moment either `DIGEST_INTERVAL_MS`
108
+ * elapses with no operator event, or an operator event arrives (flushed
109
+ * just ahead of it, never after — a session must never read the digest
110
+ * as if it postdates the operator's own message).
111
+ *
112
+ * NOTHING HELD IS EVER DROPPED, AND NOTHING HELD IS EVER DOUBLE-DELIVERED
113
+ * EITHER — across a full process restart OR a same-process re-mint. An id is
114
+ * added to `delivered` (this run's own resume cursor, seeded from
115
+ * `options.resumeIds` and handed to the next mint/re-mint as `--resume-ids`)
116
+ * ONLY at the moment it is actually written to stdout — never at the moment it
117
+ * is merely held. So a process that dies mid-hold has, by construction, never
118
+ * marked those ids delivered: the next process starts from the SAME cursor,
119
+ * the dashboard's replay resends exactly the events this process never
120
+ * finished relaying, and they are held and flushed again there. This is "the
121
+ * existing resumeIds/delivered-id cursor" the card's AC names — no new
122
+ * persistence was needed, only NOT marking a held event delivered until it is
123
+ * actually flushed.
124
+ *
125
+ * THE SAME REPLAY CAN ALSO ARRIVE WITHIN ONE STILL-RUNNING PROCESS, on a
126
+ * same-process re-mint (a real `lease_expired`, not merely a dropped
127
+ * connection): a fresh `runListener()` call starts a FRESH internal duplicate
128
+ * guard, seeded only from the `resumeIds` this call hands it — and a held id
129
+ * was, by the paragraph above, never in `delivered`. Left unhandled this
130
+ * would let the SAME held event be counted into TWO digests once flushed
131
+ * twice. `heldIds()` closes this: every `runListener()` call is seeded with
132
+ * `[...delivered, ...heldIds()]`, so the fresh listener's own guard
133
+ * (`listen.ts`'s `delivered` Set) already knows about a held id and silently
134
+ * drops a replay of it before it ever reaches `routeEvent` again —
135
+ * `routeEvent`'s own id check on `held` is the second, cheaper layer behind
136
+ * that, for defense in depth.
137
+ *
138
+ * A digest record rides the exact same `{type:"event", id, text}` shape a
139
+ * single event does (`id` is the highest id it combines, or `null` if every
140
+ * combined event carried none) — the plugin that relays bridge output
141
+ * (`plan-event-bridge.mjs`, outside this repo) already forwards any such
142
+ * record generically, so a digest needs no change on that side.
143
+ *
144
+ * A REAL TIMER, NOT `deps.sleep`. Unlike every other timing decision in this
145
+ * module (mint backoff, reconnect backoff), the digest interval is not tied to
146
+ * stream activity — it must fire even while the stream sits idle between
147
+ * events — so it is a plain `setTimeout`, mockable with fake timers in tests,
148
+ * rather than routed through the deliberately stream-driven `deps.sleep`.
149
+ */
150
+ export const DIGEST_INTERVAL_MS = 10 * 60_000;
151
+ const DIGEST_PREFIX = "[danx-dashboard bridge]";
152
+ /** ONE combined record for every event held since the last flush. */
153
+ function formatDigest(entries) {
154
+ const header = `${DIGEST_PREFIX} digest — ${entries.length} agent/machine event${entries.length === 1 ? "" : "s"} since the last update:`;
155
+ return [header, ...entries.map((e) => e.text)].join("\n");
156
+ }
92
157
  export const BRIDGE_SUBCOMMAND = "bridge";
93
158
  export const STREAM_TICKET_PATH = "/api/plan-sessions/me/stream-ticket";
94
159
  export const SESSION_ID_HEADER = "x-danx-session-id";
@@ -394,6 +459,20 @@ function classifyAdmissionRefusal(refusal) {
394
459
  export async function runBridge(options, deps) {
395
460
  const delivered = options.resumeIds.slice(-DELIVERED_ID_MEMORY);
396
461
  function stop(reason, detail, code, extra) {
462
+ // DX-2730 D4 — every terminal return goes through here, so this is the ONE
463
+ // place that cancels a pending digest timer. CANCELLED, never flushed: for
464
+ // `superseded`/`replaced` a newer process is already streaming this same
465
+ // session and will receive (and hold, and eventually flush) these same
466
+ // held events itself via the dashboard's replay — flushing here too would
467
+ // double-deliver the digest. For every other terminal reason, whatever is
468
+ // still held was never marked delivered (see `emitNow`), so a future
469
+ // process picks it up the same way — a live, un-fired `setTimeout` would
470
+ // otherwise also keep this process's event loop alive for up to
471
+ // `DIGEST_INTERVAL_MS` after it should have exited.
472
+ if (digestTimer !== null) {
473
+ clearTimeout(digestTimer);
474
+ digestTimer = null;
475
+ }
397
476
  if (reason === "scope_narrowed") {
398
477
  if (extra?.boards === undefined) {
399
478
  throw new Error('stop("scope_narrowed", ...) requires boards — unreachable through the exported overloads above');
@@ -407,6 +486,77 @@ export async function runBridge(options, deps) {
407
486
  let backoff = MINT_INITIAL_BACKOFF_MS;
408
487
  let refusedInRow = 0;
409
488
  let readyEmitted = false;
489
+ // DX-2730 D4 — digest state, declared OUTSIDE the mint/reconnect loop below
490
+ // so a re-mint (a lapsed lease, still the SAME process) never loses a
491
+ // pending hold — see the module docblock for the restart case, which the
492
+ // resume cursor handles instead.
493
+ let held = [];
494
+ let digestTimer = null;
495
+ function markDelivered(id) {
496
+ if (id === null)
497
+ return;
498
+ delivered.push(id);
499
+ if (delivered.length > DELIVERED_ID_MEMORY)
500
+ delivered.shift();
501
+ }
502
+ /** Write an event downstream at once, and mark its id delivered — never before now. */
503
+ function emitNow(output) {
504
+ markDelivered(output.id);
505
+ deps.write(output);
506
+ }
507
+ /** Combine every held event into ONE record and emit it, if there is anything to combine. */
508
+ function flushDigest() {
509
+ if (digestTimer !== null) {
510
+ clearTimeout(digestTimer);
511
+ digestTimer = null;
512
+ }
513
+ if (held.length === 0)
514
+ return;
515
+ const entries = held;
516
+ held = [];
517
+ const ids = entries.map((e) => e.id).filter((id) => id !== null);
518
+ emitNow({ type: "event", id: ids.length === 0 ? null : Math.max(...ids), text: formatDigest(entries), origin: null });
519
+ }
520
+ /** Every id currently held, unflushed — also fed back into the next `runListener` call's
521
+ * `resumeIds` below, so a re-mint's fresh listener knows these were already seen and its
522
+ * OWN duplicate guard (`listen.ts`'s `delivered` Set) silently drops a replay of one,
523
+ * rather than it reaching here a second time. */
524
+ function heldIds() {
525
+ return held.map((e) => e.id).filter((id) => id !== null);
526
+ }
527
+ /**
528
+ * DX-2730 D4 — the ONE place that decides immediate vs. held-for-digest.
529
+ * `operator` and `null` (nudge / unreadable — see `ListenEvent.origin`'s
530
+ * docblock) flush any pending digest first (never let a digest arrive AFTER
531
+ * the operator event that should have superseded it) and then emit at once.
532
+ * `agent` / `machine` are appended to the hold, arming the interval timer
533
+ * only for the FIRST held event since the last flush — a later addition
534
+ * must never push the deadline out, or the "at most every 10 minutes"
535
+ * guarantee would erode into "10 minutes after the last event," which could
536
+ * never fire while events keep arriving.
537
+ *
538
+ * DEDUPED BY ID (defense in depth): a held event's id is not added to
539
+ * `delivered` until it is actually flushed (see `emitNow`), which is what
540
+ * lets a crashed-and-restarted PROCESS recover it via the dashboard's own
541
+ * replay. The same replay can also legitimately re-arrive WITHIN one still-
542
+ * running process — a re-mint after a real `lease_expired` starts a fresh
543
+ * `runListener` whose own duplicate guard is seeded from `resumeIds` alone,
544
+ * and a still-held id was never in `delivered` to seed it with. `resumeIds`
545
+ * below closes that for the ordinary case by handing the fresh listener
546
+ * every held id too; this check is the second, cheaper layer for anything
547
+ * that reaches here anyway — never trust a single guard to hold alone.
548
+ */
549
+ function routeEvent(output) {
550
+ if (output.origin === "agent" || output.origin === "machine") {
551
+ if (output.id !== null && heldIds().includes(output.id))
552
+ return;
553
+ held.push(output);
554
+ digestTimer ??= setTimeout(flushDigest, DIGEST_INTERVAL_MS);
555
+ return;
556
+ }
557
+ flushDigest();
558
+ emitNow(output);
559
+ }
410
560
  for (;;) {
411
561
  const minted = await mintTicket(options, deps);
412
562
  if (minted.kind === "terminal")
@@ -427,7 +577,18 @@ export async function runBridge(options, deps) {
427
577
  backoff = MINT_INITIAL_BACKOFF_MS;
428
578
  const run = { stopped: null, emitted: false };
429
579
  const startedAt = deps.now();
430
- await runListener({ streamUrl: minted.streamUrl, ticket: minted.ticket, leaseMs: minted.leaseMs, resumeIds: [...delivered] }, {
580
+ await runListener(
581
+ // DX-2730 D4 — `heldIds()` rides along with `delivered`: a held-but-
582
+ // not-yet-flushed event was never added to `delivered` (see `emitNow`),
583
+ // so without this a fresh listener after a re-mint would not recognize
584
+ // a replay of it as already-seen and would hold (and eventually
585
+ // digest) it a second time. See `routeEvent`'s docblock. (If this
586
+ // combined list ever exceeded `DELIVERED_ID_MEMORY`, `runListener`'s own
587
+ // `slice(-DELIVERED_ID_MEMORY)` would evict the OLDEST entries first —
588
+ // i.e. `delivered`'s ids before `heldIds()`'s, which is the right order:
589
+ // a held id is always the more recent of the two and the one a near-
590
+ // term replay is actually likely to touch.)
591
+ { streamUrl: minted.streamUrl, ticket: minted.ticket, leaseMs: minted.leaseMs, resumeIds: [...delivered, ...heldIds()] }, {
431
592
  ...deps,
432
593
  write: (output) => {
433
594
  if (output.type === "stopped") {
@@ -435,12 +596,7 @@ export async function runBridge(options, deps) {
435
596
  return;
436
597
  }
437
598
  run.emitted = true;
438
- if (output.id !== null) {
439
- delivered.push(output.id);
440
- if (delivered.length > DELIVERED_ID_MEMORY)
441
- delivered.shift();
442
- }
443
- deps.write(output);
599
+ routeEvent(output);
444
600
  },
445
601
  });
446
602
  const stopped = run.stopped;
package/dist/listen.js CHANGED
@@ -11,7 +11,10 @@
11
11
  * - `{type:"event", id, text}` — exactly one per event, the moment it arrives.
12
12
  * `text` is what the session reads; an event it cannot read still produces one
13
13
  * (`could not read event …`), never silence. `id` is `null` only for a frame
14
- * that carried no usable id.
14
+ * that carried no usable id — which includes DX-2885's `nudge` frame
15
+ * (`formatNudgeLine`): it is advisory, re-derived fresh every tick by the
16
+ * dashboard's own keep-alive tick, and never replayed, so it rides this
17
+ * same shape with `id: null` rather than growing a THIRD output variant.
15
18
  * - `{type:"stopped", reason, detail, refusal?, boards?}` — once, last. `reason`
16
19
  * is the dashboard's own end reason (`superseded`, `replaced`, `revoked`,
17
20
  * `scope_narrowed`, `not_connected`), `refused` (the ticket was not
@@ -61,6 +64,7 @@ export const HEALTHY_CONNECTION_MS = 20_000;
61
64
  /** How many delivered event ids the duplicate guard remembers — well past the replay overlap. */
62
65
  export const DELIVERED_ID_MEMORY = 1_000;
63
66
  const LINE_PREFIX = "[danx-dashboard listen]";
67
+ const ACTIVITY_ORIGINS = new Set(["operator", "agent", "machine"]);
64
68
  function quoted(value) {
65
69
  return `"${value.text}${value.truncated ? "…" : ""}"`;
66
70
  }
@@ -88,6 +92,11 @@ export function invalidEventReason(value) {
88
92
  if (typeof value[field] !== "string")
89
93
  return `${field} is not a string`;
90
94
  }
95
+ // DX-2730 D4 — checked with the rest of the envelope, before any kind-specific
96
+ // detail check, exactly like every other envelope field above.
97
+ if (typeof value.origin !== "string" || !ACTIVITY_ORIGINS.has(value.origin)) {
98
+ return "origin is not one of operator, agent, machine";
99
+ }
91
100
  const d = value.detail;
92
101
  if (!isRecord(d))
93
102
  return "detail is not an object";
@@ -154,9 +163,19 @@ export function invalidEventReason(value) {
154
163
  return null;
155
164
  }
156
165
  }
166
+ /**
167
+ * DX-2730 D4 — `actor` alone cannot distinguish an `agent` event from a
168
+ * `machine` one (both stamp `actor: "danxbot"` today), so every line names
169
+ * the origin next to the actor rather than leaving it to a separate field a
170
+ * reader would have to cross-reference.
171
+ */
172
+ function actorLabel(event) {
173
+ return `${event.actor} (${event.origin})`;
174
+ }
157
175
  /** Only ever called on an event `invalidEventReason` accepted, so its casts are checked. */
158
176
  function describe(event) {
159
177
  const d = event.detail;
178
+ const who = actorLabel(event);
160
179
  switch (event.kind) {
161
180
  case "comment_added": {
162
181
  // DX-2906 — a comment naming a problem (a follow-up question, without
@@ -165,15 +184,15 @@ function describe(event) {
165
184
  // got a reply.
166
185
  const problem = d.problem;
167
186
  if (problem === undefined)
168
- return `${event.actor} commented: ${quoted(d.excerpt)}`;
169
- return `${event.actor} commented on problem ${quoted(problem.statement)}: ${quoted(d.excerpt)}`;
187
+ return `${who} commented: ${quoted(d.excerpt)}`;
188
+ return `${who} commented on problem ${quoted(problem.statement)}: ${quoted(d.excerpt)}`;
170
189
  }
171
190
  case "solution_answered": {
172
191
  // DX-2735: every answer answers ONE problem on a card that may carry several,
173
192
  // so the line names the problem — otherwise the agent cannot tell which of its
174
193
  // questions just closed.
175
194
  const problem = d.problem;
176
- const answered = `${event.actor} answered ${quoted(problem.statement)}:`;
195
+ const answered = `${who} answered ${quoted(problem.statement)}:`;
177
196
  const solution = d.solution;
178
197
  if (solution === null)
179
198
  return `${answered} ${quoted(d.freeform)}`;
@@ -184,14 +203,14 @@ function describe(event) {
184
203
  // DX-2830 — this IS the "needs a human" signal now: a card needs one
185
204
  // exactly when it has an open problem, and this is one being opened.
186
205
  const problem = d.problem;
187
- return `${event.actor} opened a problem: ${quoted(problem.statement)}`;
206
+ return `${who} opened a problem: ${quoted(problem.statement)}`;
188
207
  }
189
208
  case "problem_unanswered": {
190
209
  // DX-2935 — MUST read as "a previous answer was withdrawn", never as a
191
210
  // brand-new problem: a connected session that already acted on the
192
211
  // retracted answer has to stop, not treat this as fresh work to start.
193
212
  const problem = d.problem;
194
- return `${event.actor} retracted the answer to ${quoted(problem.statement)}`;
213
+ return `${who} retracted the answer to ${quoted(problem.statement)}`;
195
214
  }
196
215
  case "problem_answer_changed": {
197
216
  // DX-2935 — ONE line for the whole retract-and-reanswer (never two
@@ -200,16 +219,16 @@ function describe(event) {
200
219
  const problem = d.problem;
201
220
  const solution = d.solution;
202
221
  const now = solution === null ? quoted(d.freeform) : `"${solution.title}"`;
203
- return `${event.actor} changed the answer to ${quoted(problem.statement)}: now ${now}`;
222
+ return `${who} changed the answer to ${quoted(problem.statement)}: now ${now}`;
204
223
  }
205
224
  case "blocked":
206
- return `${event.actor} blocked the card: ${quoted(d.reason)}`;
225
+ return `${who} blocked the card: ${quoted(d.reason)}`;
207
226
  case "unblocked":
208
- return `${event.actor} unblocked the card`;
227
+ return `${who} unblocked the card`;
209
228
  default:
210
229
  // A kind newer than this listener still produces a line: an event the
211
230
  // agent is never told about is exactly the failure this reader prevents.
212
- return `${event.actor}: ${event.kind}`;
231
+ return `${who}: ${event.kind}`;
213
232
  }
214
233
  }
215
234
  /** The one readable line for an event. Always a single line; throws, naming the fault, on a malformed event. */
@@ -219,6 +238,46 @@ export function formatActivityLine(event) {
219
238
  throw new Error(`malformed activity event: ${invalid}`);
220
239
  return oneLine(`[${event.cardId} "${event.cardTitle}" ${event.boardId}] ${describe(event)}`);
221
240
  }
241
+ function isNudgeCardRef(value) {
242
+ return isRecord(value) && typeof value.id === "string" && typeof value.title === "string";
243
+ }
244
+ function isNudgeCardRefArray(value) {
245
+ return Array.isArray(value) && value.every(isNudgeCardRef);
246
+ }
247
+ /** Why a nudge cannot be read, or `null` when it can — same checked-before-describing shape as `invalidEventReason`. */
248
+ export function invalidNudgeReason(value) {
249
+ if (!isRecord(value))
250
+ return "the nudge is not an object";
251
+ if (typeof value.idleMinutes !== "number" || !Number.isFinite(value.idleMinutes)) {
252
+ return "idleMinutes is not a number";
253
+ }
254
+ if (!isNudgeCardRefArray(value.startable))
255
+ return "startable is not a {id,title}[] array";
256
+ if (!isNudgeCardRefArray(value.held))
257
+ return "held is not a {id,title}[] array";
258
+ return null;
259
+ }
260
+ function cardList(cards) {
261
+ return cards.map((c) => `${c.id} "${c.title}"`).join(", ");
262
+ }
263
+ /**
264
+ * The one readable line for a nudge (DX-2885). Always names the actual
265
+ * waiting cards — never a generic "keep going" — because that is the whole
266
+ * point of the feature: a session that only sees "you're idle" learns
267
+ * nothing a bare timer couldn't have told it. Always a single line; throws,
268
+ * naming the fault, on a malformed nudge.
269
+ */
270
+ export function formatNudgeLine(event) {
271
+ const invalid = invalidNudgeReason(event);
272
+ if (invalid !== null)
273
+ throw new Error(`malformed nudge event: ${invalid}`);
274
+ const parts = [];
275
+ if (event.startable.length > 0)
276
+ parts.push(`startable: ${cardList(event.startable)}`);
277
+ if (event.held.length > 0)
278
+ parts.push(`held: ${cardList(event.held)}`);
279
+ return oneLine(`${LINE_PREFIX} this session has been idle ${event.idleMinutes}m with work waiting — ${parts.join("; ")}`);
280
+ }
222
281
  /**
223
282
  * Incremental SSE parser. Comment lines (keep-alives, the connect marker) and
224
283
  * field-less blocks produce no message, which is what keeps them out of the output.
@@ -347,6 +406,40 @@ export async function runListener(options, deps) {
347
406
  // elsewhere — see `ListenStopReason`'s docblock.
348
407
  return { kind: "ended", reason: isKnownWireEndReason(reason) ? reason : "unknown" };
349
408
  }
409
+ if (message.event === "nudge") {
410
+ // DX-2885 — a nudge carries no usable id (it is advisory, re-derived
411
+ // fresh every tick, never replayed — see `plan-session-stream.ts`'s
412
+ // module doc), so it rides the SAME `{type:"event", id:null, text}`
413
+ // shape an ordinary activity line uses (`id` is `null` only for a frame
414
+ // that carried no usable id — a nudge is exactly that case). That is
415
+ // what lets `bridge.ts` and the plugin's relay
416
+ // (`danxbot/scripts/plan-event-bridge.mjs`, outside this repo) forward
417
+ // it with NO changes at all: both already forward any `{type:"event",
418
+ // id, text}` line generically, keyed only on `type`.
419
+ //
420
+ // DX-2885 — this frame must stay exempt from DX-2730 D4's digest-
421
+ // batching when that lands (see DX-2730 AC 29939, and the matching note
422
+ // at the dashboard's own emit site): it must keep delivering
423
+ // immediately, never held for a later digest flush.
424
+ let parsed;
425
+ let invalid;
426
+ try {
427
+ parsed = JSON.parse(message.data);
428
+ invalid = invalidNudgeReason(parsed);
429
+ }
430
+ catch (err) {
431
+ invalid = `not JSON: ${err.message}`;
432
+ }
433
+ const text = invalid === null
434
+ ? formatNudgeLine(parsed)
435
+ : `${LINE_PREFIX} could not read nudge (${invalid}): ${oneLine(message.data, 300)}`;
436
+ // DX-2730 D4 — `origin: null` here, not the parsed event's own origin
437
+ // (a nudge has none): this is the exemption the module doc above
438
+ // describes, letting `bridge.ts` treat it exactly like an operator
439
+ // event (immediate) without needing a fourth vocabulary member.
440
+ deps.write({ type: "event", id: null, text, origin: null });
441
+ return null;
442
+ }
350
443
  if (message.event !== "activity")
351
444
  return null;
352
445
  // `Number(null)` is 0, so a frame with no id must never be read as event 0: that id
@@ -374,7 +467,13 @@ export async function runListener(options, deps) {
374
467
  // DX-2735: oneLine caps by code point, so a bad event carrying an emoji
375
468
  // at the cut can never leave a lone surrogate in the line.
376
469
  oneLine(message.data, 300);
377
- deps.write({ type: "event", id: knownId ? id : null, text });
470
+ // DX-2730 D4 — an event this listener could not parse carries `origin: null`
471
+ // (emit immediately) rather than the unreadable payload's own claimed
472
+ // origin: a fault worth surfacing at all must never be hidden behind a
473
+ // 10-minute digest wait just because it happened to come from an agent or
474
+ // a machine writer.
475
+ const origin = invalid === null ? parsed.origin : null;
476
+ deps.write({ type: "event", id: knownId ? id : null, text, origin });
378
477
  if (knownId)
379
478
  remember(id);
380
479
  return null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thehammer/danx-dashboard-mcp",
3
- "version": "0.1.85",
3
+ "version": "0.1.87",
4
4
  "description": "Stdio MCP server wrapping danxbot's dashboard /api/issues/* normalized DB-backed HTTP routes for dispatched agents (DX-704 Phase 2).",
5
5
  "license": "MIT",
6
6
  "type": "module",