@thehammer/danx-dashboard-mcp 0.1.88 → 0.1.89

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/listen.js CHANGED
@@ -11,10 +11,31 @@
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.
15
- * - `{type:"stopped", reason, detail}` — once, last. `reason` is the dashboard's
16
- * own end reason (`superseded`, `replaced`, `revoked`), `refused` (the ticket
17
- * was not admitted), or `lease_expired` (no healthy connection for the lease).
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.
18
+ * - `{type:"stopped", reason, detail, refusal?, boards?}` — once, last. `reason`
19
+ * is the dashboard's own end reason (`superseded`, `replaced`, `revoked`,
20
+ * `scope_narrowed`, `not_connected`), `refused` (the ticket was not
21
+ * admitted), `bad_end_payload` (a `scope_narrowed` end whose `boards` array
22
+ * was missing/invalid — a protocol error, never silently downgraded), or
23
+ * `lease_expired` (no healthy connection for the lease). DX-2920 —
24
+ * `refusal` is populated ONLY for `reason === "refused"`: `{status,
25
+ * errorCode, body}`, the admission response parsed ONCE here, so a
26
+ * consumer can recognize a SPECIFIC refusal (the dashboard's own
27
+ * `409 session_not_connected`, the same shape the mint route uses) by
28
+ * status + error code rather than re-deriving it from the flattened
29
+ * `detail` string. `boards` is REQUIRED (round 4 — a discriminated union,
30
+ * not an optional field: see `ListenStopped` / `ListenStopReason`) for
31
+ * `reason === "scope_narrowed"` and absent for every other reason, parsed
32
+ * from the dashboard's `end` payload the SAME way `reason` itself is
33
+ * parsed — never re-derived from `detail`. A wire `reason` value this
34
+ * build does not recognize (a newer dashboard's reason an older bridge
35
+ * hasn't shipped yet) normalizes to `"unknown"` rather than passing the
36
+ * raw string through, so the closed set stays closed — `fixForStop`'s
37
+ * generic `DEFAULT_STOP_FIX` handles it exactly as it already handled a
38
+ * verbatim unrecognized string.
18
39
  * - nothing for keep-alives, the connect marker, or a successful reconnect.
19
40
  *
20
41
  * RECONNECT AND RESUME. The stream drops whenever the dashboard restarts, and can
@@ -32,6 +53,7 @@
32
53
  * (`src/issues/db/issue-activity.ts`); this published package cannot import
33
54
  * danxbot source.
34
55
  */
56
+ import { errorCodeOf } from "./json-body.js";
35
57
  import { oneLine } from "./one-line.js";
36
58
  export const INITIAL_BACKOFF_MS = 1_000;
37
59
  export const MAX_BACKOFF_MS = 30_000;
@@ -41,7 +63,18 @@ export const READ_IDLE_TIMEOUT_MS = 45_000;
41
63
  export const HEALTHY_CONNECTION_MS = 20_000;
42
64
  /** How many delivered event ids the duplicate guard remembers — well past the replay overlap. */
43
65
  export const DELIVERED_ID_MEMORY = 1_000;
44
- const LINE_PREFIX = "[danx-dashboard listen]";
66
+ /**
67
+ * DX-2829 — the ONE place that caps a resume-id list to the last
68
+ * `DELIVERED_ID_MEMORY` entries. Both `runListener` (seeding its own
69
+ * duplicate-detection `Set` below) and `bridge.ts`'s `runBridge` (seeding its
70
+ * own delivered-id array) used to each carry their own `slice(-DELIVERED_ID_MEMORY)`
71
+ * — the same cap, on the same kind of input, computed twice.
72
+ */
73
+ export function capResumeIds(ids) {
74
+ return ids.slice(-DELIVERED_ID_MEMORY);
75
+ }
76
+ const LINE_PREFIX = "[danx-dashboard stream]";
77
+ const ACTIVITY_ORIGINS = new Set(["operator", "agent", "machine"]);
45
78
  function quoted(value) {
46
79
  return `"${value.text}${value.truncated ? "…" : ""}"`;
47
80
  }
@@ -69,12 +102,27 @@ export function invalidEventReason(value) {
69
102
  if (typeof value[field] !== "string")
70
103
  return `${field} is not a string`;
71
104
  }
105
+ // DX-2730 D4 — checked with the rest of the envelope, before any kind-specific
106
+ // detail check, exactly like every other envelope field above.
107
+ if (typeof value.origin !== "string" || !ACTIVITY_ORIGINS.has(value.origin)) {
108
+ return "origin is not one of operator, agent, machine";
109
+ }
72
110
  const d = value.detail;
73
111
  if (!isRecord(d))
74
112
  return "detail is not an object";
75
113
  switch (value.kind) {
76
- case "comment_added":
77
- return isCappedText(d.excerpt) ? null : "detail.excerpt is not capped text";
114
+ case "comment_added": {
115
+ if (!isCappedText(d.excerpt))
116
+ return "detail.excerpt is not capped text";
117
+ // DX-2906 — `problem` is OPTIONAL (only present when the comment named
118
+ // one); absent is valid, but a present-and-malformed one is not.
119
+ if (d.problem === undefined)
120
+ return null;
121
+ const problem = d.problem;
122
+ return isRecord(problem) && typeof problem.id === "number" && isCappedText(problem.statement)
123
+ ? null
124
+ : "detail.problem is not {id, statement}";
125
+ }
78
126
  case "solution_answered": {
79
127
  const problem = d.problem;
80
128
  if (!isRecord(problem) || typeof problem.id !== "number" || !isCappedText(problem.statement)) {
@@ -93,24 +141,68 @@ export function invalidEventReason(value) {
93
141
  }
94
142
  return null;
95
143
  }
144
+ // DX-2935 — the operator retracted a problem's answer with no new one yet.
145
+ case "problem_unanswered": {
146
+ const problem = d.problem;
147
+ if (!isRecord(problem) || typeof problem.id !== "number" || !isCappedText(problem.statement)) {
148
+ return "detail.problem is not {id, statement}";
149
+ }
150
+ return typeof d.decision_id === "number" ? null : "detail.decision_id is not a number";
151
+ }
152
+ // DX-2935 — the operator retracted a problem's answer AND recorded a new
153
+ // one, in one transaction — same either/or solution/freeform shape
154
+ // `solution_answered` carries, plus which decision was retracted.
155
+ case "problem_answer_changed": {
156
+ const problem = d.problem;
157
+ if (!isRecord(problem) || typeof problem.id !== "number" || !isCappedText(problem.statement)) {
158
+ return "detail.problem is not {id, statement}";
159
+ }
160
+ if (typeof d.retracted_decision_id !== "number")
161
+ return "detail.retracted_decision_id is not a number";
162
+ if (typeof d.decision_id !== "number")
163
+ return "detail.decision_id is not a number";
164
+ if (d.solution === null)
165
+ return isCappedText(d.freeform) ? null : "detail.freeform is not capped text";
166
+ if (!isRecord(d.solution) || typeof d.solution.title !== "string")
167
+ return "detail.solution is not {title, note}";
168
+ return d.solution.note === null || isCappedText(d.solution.note) ? null : "detail.solution.note is not capped text";
169
+ }
96
170
  case "blocked":
97
171
  return isCappedText(d.reason) ? null : "detail.reason is not capped text";
98
172
  default:
99
173
  return null;
100
174
  }
101
175
  }
176
+ /**
177
+ * DX-2730 D4 — `actor` alone cannot distinguish an `agent` event from a
178
+ * `machine` one (both stamp `actor: "danxbot"` today), so every line names
179
+ * the origin next to the actor rather than leaving it to a separate field a
180
+ * reader would have to cross-reference.
181
+ */
182
+ function actorLabel(event) {
183
+ return `${event.actor} (${event.origin})`;
184
+ }
102
185
  /** Only ever called on an event `invalidEventReason` accepted, so its casts are checked. */
103
186
  function describe(event) {
104
187
  const d = event.detail;
188
+ const who = actorLabel(event);
105
189
  switch (event.kind) {
106
- case "comment_added":
107
- return `${event.actor} commented: ${quoted(d.excerpt)}`;
190
+ case "comment_added": {
191
+ // DX-2906 — a comment naming a problem (a follow-up question, without
192
+ // answering it) relays distinctly from a plain comment, so an agent
193
+ // listening to the stream can tell which of its open problems just
194
+ // got a reply.
195
+ const problem = d.problem;
196
+ if (problem === undefined)
197
+ return `${who} commented: ${quoted(d.excerpt)}`;
198
+ return `${who} commented on problem ${quoted(problem.statement)}: ${quoted(d.excerpt)}`;
199
+ }
108
200
  case "solution_answered": {
109
201
  // DX-2735: every answer answers ONE problem on a card that may carry several,
110
202
  // so the line names the problem — otherwise the agent cannot tell which of its
111
203
  // questions just closed.
112
204
  const problem = d.problem;
113
- const answered = `${event.actor} answered ${quoted(problem.statement)}:`;
205
+ const answered = `${who} answered ${quoted(problem.statement)}:`;
114
206
  const solution = d.solution;
115
207
  if (solution === null)
116
208
  return `${answered} ${quoted(d.freeform)}`;
@@ -121,16 +213,32 @@ function describe(event) {
121
213
  // DX-2830 — this IS the "needs a human" signal now: a card needs one
122
214
  // exactly when it has an open problem, and this is one being opened.
123
215
  const problem = d.problem;
124
- return `${event.actor} opened a problem: ${quoted(problem.statement)}`;
216
+ return `${who} opened a problem: ${quoted(problem.statement)}`;
217
+ }
218
+ case "problem_unanswered": {
219
+ // DX-2935 — MUST read as "a previous answer was withdrawn", never as a
220
+ // brand-new problem: a connected session that already acted on the
221
+ // retracted answer has to stop, not treat this as fresh work to start.
222
+ const problem = d.problem;
223
+ return `${who} retracted the answer to ${quoted(problem.statement)}`;
224
+ }
225
+ case "problem_answer_changed": {
226
+ // DX-2935 — ONE line for the whole retract-and-reanswer (never two
227
+ // separate events), naming the statement so a listener with several
228
+ // open questions knows which one just moved.
229
+ const problem = d.problem;
230
+ const solution = d.solution;
231
+ const now = solution === null ? quoted(d.freeform) : `"${solution.title}"`;
232
+ return `${who} changed the answer to ${quoted(problem.statement)}: now ${now}`;
125
233
  }
126
234
  case "blocked":
127
- return `${event.actor} blocked the card: ${quoted(d.reason)}`;
235
+ return `${who} blocked the card: ${quoted(d.reason)}`;
128
236
  case "unblocked":
129
- return `${event.actor} unblocked the card`;
237
+ return `${who} unblocked the card`;
130
238
  default:
131
239
  // A kind newer than this listener still produces a line: an event the
132
240
  // agent is never told about is exactly the failure this reader prevents.
133
- return `${event.actor}: ${event.kind}`;
241
+ return `${who}: ${event.kind}`;
134
242
  }
135
243
  }
136
244
  /** The one readable line for an event. Always a single line; throws, naming the fault, on a malformed event. */
@@ -140,6 +248,46 @@ export function formatActivityLine(event) {
140
248
  throw new Error(`malformed activity event: ${invalid}`);
141
249
  return oneLine(`[${event.cardId} "${event.cardTitle}" ${event.boardId}] ${describe(event)}`);
142
250
  }
251
+ function isNudgeCardRef(value) {
252
+ return isRecord(value) && typeof value.id === "string" && typeof value.title === "string";
253
+ }
254
+ function isNudgeCardRefArray(value) {
255
+ return Array.isArray(value) && value.every(isNudgeCardRef);
256
+ }
257
+ /** Why a nudge cannot be read, or `null` when it can — same checked-before-describing shape as `invalidEventReason`. */
258
+ export function invalidNudgeReason(value) {
259
+ if (!isRecord(value))
260
+ return "the nudge is not an object";
261
+ if (typeof value.idleMinutes !== "number" || !Number.isFinite(value.idleMinutes)) {
262
+ return "idleMinutes is not a number";
263
+ }
264
+ if (!isNudgeCardRefArray(value.startable))
265
+ return "startable is not a {id,title}[] array";
266
+ if (!isNudgeCardRefArray(value.held))
267
+ return "held is not a {id,title}[] array";
268
+ return null;
269
+ }
270
+ function cardList(cards) {
271
+ return cards.map((c) => `${c.id} "${c.title}"`).join(", ");
272
+ }
273
+ /**
274
+ * The one readable line for a nudge (DX-2885). Always names the actual
275
+ * waiting cards — never a generic "keep going" — because that is the whole
276
+ * point of the feature: a session that only sees "you're idle" learns
277
+ * nothing a bare timer couldn't have told it. Always a single line; throws,
278
+ * naming the fault, on a malformed nudge.
279
+ */
280
+ export function formatNudgeLine(event) {
281
+ const invalid = invalidNudgeReason(event);
282
+ if (invalid !== null)
283
+ throw new Error(`malformed nudge event: ${invalid}`);
284
+ const parts = [];
285
+ if (event.startable.length > 0)
286
+ parts.push(`startable: ${cardList(event.startable)}`);
287
+ if (event.held.length > 0)
288
+ parts.push(`held: ${cardList(event.held)}`);
289
+ return oneLine(`${LINE_PREFIX} this session has been idle ${event.idleMinutes}m with work waiting — ${parts.join("; ")}`);
290
+ }
143
291
  /**
144
292
  * Incremental SSE parser. Comment lines (keep-alives, the connect marker) and
145
293
  * field-less blocks produce no message, which is what keeps them out of the output.
@@ -181,6 +329,27 @@ function parseBlock(block) {
181
329
  }
182
330
  return sawField ? { id, event, data: data.join("\n") } : null;
183
331
  }
332
+ /**
333
+ * DX-2920 (round 4) — the dashboard's own `StreamEndReason` values this
334
+ * client recognizes on the wire, EXCLUDING `scope_narrowed` (parsed by its own
335
+ * dedicated branch in `handle()` below, since it alone carries `boards`).
336
+ * Hand-copied from `StreamEndReason` in `src/issues/plan-session-listeners.ts`
337
+ * — this published package cannot import danxbot source. A wire `reason` this
338
+ * set does not contain (a newer dashboard's reason this build predates)
339
+ * normalizes to `"unknown"` rather than passing the raw string through, which
340
+ * is what lets `Outcome`'s `reason` field below be a closed union instead of
341
+ * `string` — see `ListenStopReason`'s docblock for why that closure is what
342
+ * makes the `scope_narrowed`-requires-`boards` invariant an actual compile
343
+ * error rather than a comment's promise.
344
+ */
345
+ const KNOWN_WIRE_END_REASONS = new Set(["superseded", "replaced", "revoked", "not_connected"]);
346
+ function isKnownWireEndReason(reason) {
347
+ return KNOWN_WIRE_END_REASONS.has(reason);
348
+ }
349
+ /** A thrown value's message, for the one drop path that carries a real JS error — never re-derived elsewhere. */
350
+ function describeDropError(err) {
351
+ return err instanceof Error ? err.message : String(err);
352
+ }
184
353
  /** Statuses that describe the moment, not the ticket — retry them. */
185
354
  const RETRYABLE_CLIENT_STATUSES = new Set([408, 429]);
186
355
  /**
@@ -193,22 +362,102 @@ export async function runListener(options, deps) {
193
362
  let lastEventId = null;
194
363
  let unhealthySince = null;
195
364
  let attempt = 0;
365
+ // DX-2829 — the most recent `dropped` outcome's cause, carried across
366
+ // reconnect attempts so the eventual `lease_expired` stop (the only outcome
367
+ // reached exclusively via repeated drops) can say what actually failed
368
+ // instead of only "no healthy connection ... past the ticket's lease".
369
+ let lastDropDetail = null;
196
370
  const remember = (id) => {
197
371
  delivered.add(id);
198
372
  if (delivered.size > DELIVERED_ID_MEMORY)
199
373
  delivered.delete(delivered.values().next().value);
200
374
  lastEventId = lastEventId === null ? id : Math.max(lastEventId, id);
201
375
  };
202
- for (const id of options.resumeIds.slice(-DELIVERED_ID_MEMORY))
376
+ for (const id of capResumeIds(options.resumeIds))
203
377
  remember(id);
204
- const stop = (reason, detail, code) => {
205
- deps.write({ type: "stopped", reason, detail });
378
+ function stop(reason, detail, code, extra) {
379
+ if (reason === "scope_narrowed") {
380
+ if (extra?.boards === undefined) {
381
+ throw new Error('stop("scope_narrowed", ...) requires boards — unreachable through the exported overloads above');
382
+ }
383
+ deps.write({ type: "stopped", reason, detail, boards: extra.boards });
384
+ return code;
385
+ }
386
+ deps.write({ type: "stopped", reason, detail, refusal: extra?.refusal });
206
387
  return code;
207
- };
388
+ }
208
389
  const handle = (message) => {
209
390
  if (message.event === "end") {
210
- const reason = JSON.parse(message.data).reason;
211
- return { kind: "ended", reason: typeof reason === "string" ? reason : "unknown" };
391
+ // DX-2920 (round 3) — a JSON.parse failure here falls through to the
392
+ // pre-existing "unknown" reason below via `isRecord(null)`, unchanged
393
+ // from before this round. Only a `scope_narrowed` reason additionally
394
+ // REQUIRES a valid `boards` string array (matching the server's `end`
395
+ // overload, which cannot emit `scope_narrowed` without one) — a
396
+ // `scope_narrowed` frame that fails that check is a PROTOCOL error
397
+ // (a server too old to have sent boards, or a corrupted frame), never
398
+ // silently downgraded to the generic "the dashboard ended this
399
+ // listener" message the caller cannot act on.
400
+ let parsed;
401
+ try {
402
+ parsed = JSON.parse(message.data);
403
+ }
404
+ catch {
405
+ parsed = null;
406
+ }
407
+ const reasonRaw = isRecord(parsed) ? parsed.reason : undefined;
408
+ const reason = typeof reasonRaw === "string" ? reasonRaw : "unknown";
409
+ if (reason === "scope_narrowed") {
410
+ const boardsRaw = isRecord(parsed) ? parsed.boards : undefined;
411
+ const boards = Array.isArray(boardsRaw) && boardsRaw.length > 0 && boardsRaw.every((b) => typeof b === "string")
412
+ ? boardsRaw
413
+ : null;
414
+ if (boards === null) {
415
+ return { kind: "ended", reason: "bad_end_payload" };
416
+ }
417
+ return { kind: "ended", reason, boards };
418
+ }
419
+ // DX-2920 (round 4) — a wire reason this build does not recognize (a
420
+ // newer dashboard's reason an older bridge hasn't shipped yet)
421
+ // normalizes to "unknown" rather than passing the raw string through:
422
+ // `Outcome`'s "ended" reason is now a closed set (`EndedReason`), and an
423
+ // open passthrough here would be exactly the hole that lets a
424
+ // missing-`boards` "scope_narrowed" construction slip past the compiler
425
+ // elsewhere — see `ListenStopReason`'s docblock.
426
+ return { kind: "ended", reason: isKnownWireEndReason(reason) ? reason : "unknown" };
427
+ }
428
+ if (message.event === "nudge") {
429
+ // DX-2885 — a nudge carries no usable id (it is advisory, re-derived
430
+ // fresh every tick, never replayed — see `plan-session-stream.ts`'s
431
+ // module doc), so it rides the SAME `{type:"event", id:null, text}`
432
+ // shape an ordinary activity line uses (`id` is `null` only for a frame
433
+ // that carried no usable id — a nudge is exactly that case). That is
434
+ // what lets `bridge.ts` and the plugin's relay
435
+ // (`danxbot/scripts/plan-event-bridge.mjs`, outside this repo) forward
436
+ // it with NO changes at all: both already forward any `{type:"event",
437
+ // id, text}` line generically, keyed only on `type`.
438
+ //
439
+ // DX-2885 — this frame must stay exempt from DX-2730 D4's digest-
440
+ // batching when that lands (see DX-2730 AC 29939, and the matching note
441
+ // at the dashboard's own emit site): it must keep delivering
442
+ // immediately, never held for a later digest flush.
443
+ let parsed;
444
+ let invalid;
445
+ try {
446
+ parsed = JSON.parse(message.data);
447
+ invalid = invalidNudgeReason(parsed);
448
+ }
449
+ catch (err) {
450
+ invalid = `not JSON: ${err.message}`;
451
+ }
452
+ const text = invalid === null
453
+ ? formatNudgeLine(parsed)
454
+ : `${LINE_PREFIX} could not read nudge (${invalid}): ${oneLine(message.data, 300)}`;
455
+ // DX-2730 D4 — `origin: null` here, not the parsed event's own origin
456
+ // (a nudge has none): this is the exemption the module doc above
457
+ // describes, letting `bridge.ts` treat it exactly like an operator
458
+ // event (immediate) without needing a fourth vocabulary member.
459
+ deps.write({ type: "event", id: null, text, origin: null });
460
+ return null;
212
461
  }
213
462
  if (message.event !== "activity")
214
463
  return null;
@@ -237,7 +486,13 @@ export async function runListener(options, deps) {
237
486
  // DX-2735: oneLine caps by code point, so a bad event carrying an emoji
238
487
  // at the cut can never leave a lone surrogate in the line.
239
488
  oneLine(message.data, 300);
240
- deps.write({ type: "event", id: knownId ? id : null, text });
489
+ // DX-2730 D4 — an event this listener could not parse carries `origin: null`
490
+ // (emit immediately) rather than the unreadable payload's own claimed
491
+ // origin: a fault worth surfacing at all must never be hidden behind a
492
+ // 10-minute digest wait just because it happened to come from an agent or
493
+ // a machine writer.
494
+ const origin = invalid === null ? parsed.origin : null;
495
+ deps.write({ type: "event", id: knownId ? id : null, text, origin });
241
496
  if (knownId)
242
497
  remember(id);
243
498
  return null;
@@ -262,11 +517,29 @@ export async function runListener(options, deps) {
262
517
  armIdle();
263
518
  const response = await deps.fetch(options.streamUrl, { headers, signal: controller.signal });
264
519
  if (response.status >= 400 && response.status < 500 && !RETRYABLE_CLIENT_STATUSES.has(response.status)) {
265
- return { kind: "refused", detail: `HTTP ${response.status} ${oneLine(await response.text(), 300)}` };
520
+ const text = await response.text();
521
+ // DX-2920 — parsed ONCE, here, into a structured outcome a consumer can
522
+ // classify by status + error code (`bridge.ts` does, matching a specific
523
+ // admission refusal such as the dashboard's own `409 session_not_connected`)
524
+ // rather than re-deriving it from the flattened `detail` string below.
525
+ let body = null;
526
+ try {
527
+ body = text === "" ? null : JSON.parse(text);
528
+ }
529
+ catch {
530
+ body = null;
531
+ }
532
+ return {
533
+ kind: "refused",
534
+ status: response.status,
535
+ errorCode: errorCodeOf(body),
536
+ body,
537
+ detail: `HTTP ${response.status} ${oneLine(text, 300)}`,
538
+ };
266
539
  }
267
540
  if (!response.ok || response.body === null) {
268
541
  await response.body?.cancel();
269
- return { kind: "dropped", healthy: false };
542
+ return { kind: "dropped", healthy: false, detail: `HTTP ${response.status} with no readable body` };
270
543
  }
271
544
  const parser = new SseParser();
272
545
  const decoder = new TextDecoder();
@@ -282,8 +555,14 @@ export async function runListener(options, deps) {
282
555
  }
283
556
  return { kind: "dropped", healthy: healthy() };
284
557
  }
285
- catch {
286
- return { kind: "dropped", healthy: healthy() };
558
+ catch (err) {
559
+ // DX-2829 — was a bare `catch { ... }` that discarded the causing error
560
+ // entirely: a connection that failed the same way on every retry (DNS
561
+ // failure, TLS reset, the idle-timeout abort) surfaced nothing beyond
562
+ // "no healthy connection" once the lease finally expired. Preserving the
563
+ // message here is what lets `runListener` name the actual last failure
564
+ // in its final `lease_expired` stop record below.
565
+ return { kind: "dropped", healthy: healthy(), detail: describeDropError(err) };
287
566
  }
288
567
  finally {
289
568
  clearTimeout(idle);
@@ -296,11 +575,26 @@ export async function runListener(options, deps) {
296
575
  if (outcome.reason === "superseded" || outcome.reason === "replaced") {
297
576
  return stop(outcome.reason, "another listener for this session took over", 0);
298
577
  }
578
+ if (outcome.reason === "bad_end_payload") {
579
+ return stop("bad_end_payload", "the dashboard's scope_narrowed end event carried no valid boards array — this is a protocol error, not an ordinary end", 1);
580
+ }
581
+ // DX-2920 (round 4) — split explicitly rather than the old conditional
582
+ // `outcome.boards === undefined ? undefined : {boards}` pass-through:
583
+ // `Outcome`'s "ended" kind is now itself discriminated on `reason`, so
584
+ // `outcome.boards` is GUARANTEED present here (no `?? []`, no optional
585
+ // check needed) once narrowed to `"scope_narrowed"`.
586
+ if (outcome.reason === "scope_narrowed") {
587
+ return stop("scope_narrowed", "the dashboard ended this listener", 1, { boards: outcome.boards });
588
+ }
299
589
  return stop(outcome.reason, "the dashboard ended this listener", 1);
300
590
  }
301
591
  if (outcome.kind === "refused") {
302
- return stop("refused", `the dashboard refused the listener ticket: ${outcome.detail}`, 1);
592
+ return stop("refused", `the dashboard refused the listener ticket: ${outcome.detail}`, 1, {
593
+ refusal: { status: outcome.status, errorCode: outcome.errorCode, body: outcome.body },
594
+ });
303
595
  }
596
+ if (outcome.detail !== undefined)
597
+ lastDropDetail = outcome.detail;
304
598
  if (outcome.healthy) {
305
599
  unhealthySince = null;
306
600
  attempt = 0;
@@ -308,7 +602,10 @@ export async function runListener(options, deps) {
308
602
  const now = deps.now();
309
603
  unhealthySince ??= now;
310
604
  if (now - unhealthySince >= options.leaseMs) {
311
- return stop("lease_expired", `no healthy connection to ${options.streamUrl} for ${Math.round((now - unhealthySince) / 60_000)} minutes, past the ticket's lease`, 1);
605
+ // DX-2829 — names the last connection failure when one was ever recorded,
606
+ // instead of only reporting the timeout itself.
607
+ const cause = lastDropDetail === null ? "" : ` — last connection attempt failed: ${lastDropDetail}`;
608
+ return stop("lease_expired", `no healthy connection to ${options.streamUrl} for ${Math.round((now - unhealthySince) / 60_000)} minutes, past the ticket's lease${cause}`, 1);
312
609
  }
313
610
  await deps.sleep(Math.min(MAX_BACKOFF_MS, INITIAL_BACKOFF_MS * 2 ** attempt));
314
611
  attempt += 1;
package/dist/one-line.js CHANGED
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * DX-2735 — the ONE way this package renders human text into an agent-facing
3
- * line: the listen notification line and the problems reminder both go
4
- * through it, so they can never disagree about how a statement looks.
3
+ * line: the dashboard-event stream's notification line and the problems
4
+ * reminder both go through it, so they can never disagree about how a
5
+ * statement looks.
5
6
  *
6
7
  * Every whitespace run (newlines included) collapses to a single space, because
7
8
  * a notification is one line and a reminder quotes statements inline. The