@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 +163 -7
- package/dist/listen.js +110 -11
- package/package.json +1 -1
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(
|
|
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
|
-
|
|
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 `${
|
|
169
|
-
return `${
|
|
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 = `${
|
|
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 `${
|
|
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 `${
|
|
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 `${
|
|
222
|
+
return `${who} changed the answer to ${quoted(problem.statement)}: now ${now}`;
|
|
204
223
|
}
|
|
205
224
|
case "blocked":
|
|
206
|
-
return `${
|
|
225
|
+
return `${who} blocked the card: ${quoted(d.reason)}`;
|
|
207
226
|
case "unblocked":
|
|
208
|
-
return `${
|
|
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 `${
|
|
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
|
-
|
|
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.
|
|
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",
|