@thehammer/danx-dashboard-mcp 0.1.284 → 0.1.286

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
@@ -65,7 +65,7 @@ A program calls this, not a person: the danxbot Claude Code plugin's relay loop,
65
65
 
66
66
  Input `{plan_id: number, cursor: string | null, timeout_ms: 1000..30000, transcript_path?: string}`. Answer, one of:
67
67
 
68
- - `{events: [{cursor: string, text: string}]}`: 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.
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.
70
70
 
71
71
  The contract the plugin builds against:
@@ -48,6 +48,9 @@ async function resolveBoard(client, args, deps) {
48
48
  return { ok: true, board };
49
49
  }
50
50
  export async function designPublish(client, args, deps) {
51
+ // DX-4729: refused before any call, so a blank note never leaves a built page, a screenshot or a card behind.
52
+ if (args.note.trim() === "")
53
+ fail("`note` is required: say what changed since the last revision, which decision note it answers and what is still open");
51
54
  // With `card_id` the card already exists: its title, summary and description are not this call's to set, and silently ignoring them would hide the mistake.
52
55
  if (args.card_id !== undefined) {
53
56
  const given = ["title", "summary", "description"].filter((k) => args[k] !== undefined);
@@ -103,6 +106,7 @@ export async function designPublish(client, args, deps) {
103
106
  method: "POST",
104
107
  path: `/api/issues/${encodeURIComponent(cardId)}/design/revisions`,
105
108
  body: {
109
+ note: args.note,
106
110
  target: target.name,
107
111
  entry: repoRelative(repoRoot, path.join(checked.dirAbs, DESIGN_FILE)),
108
112
  source_files: sourceFiles,
@@ -1,3 +1,4 @@
1
+ import { isUrgent } from "./urgent-records.js";
1
2
  export const DIGEST_INTERVAL_MS = 10 * 60_000;
2
3
  export const DIGEST_PREFIX = "[plan events]";
3
4
  /** ONE combined record for every event held since the last flush. */
@@ -39,7 +40,7 @@ export class DigestHold {
39
40
  const entries = this.held;
40
41
  this.held = [];
41
42
  const ids = entries.map((e) => e.id).filter((id) => id !== null);
42
- this.emit({ type: "event", id: ids.length === 0 ? null : Math.max(...ids), ids, text: formatDigest(entries), origin: null });
43
+ this.emit({ type: "event", id: ids.length === 0 ? null : Math.max(...ids), ids, text: formatDigest(entries), origin: null, urgent: isUrgent({ source: "digest" }) });
43
44
  }
44
45
  /**
45
46
  * Stop the timer WITHOUT flushing. For a stop where a newer listener already streams this session and will receive (and
@@ -85,7 +85,7 @@ export class HeartbeatReporter {
85
85
  // dashboard call says the same.
86
86
  if (answer.revocation !== null && !this.revocationAnnounced) {
87
87
  this.revocationAnnounced = true;
88
- this.deps.announce(keyRevokedHalt(answer.revocation, false));
88
+ this.deps.announce(keyRevokedHalt(answer.revocation, false), "key_revoked");
89
89
  }
90
90
  return answer;
91
91
  })
@@ -104,7 +104,7 @@ export class HeartbeatReporter {
104
104
  // The session finds out, rather than continuing to believe it is connected.
105
105
  this.deps.announce(oneLine(`${DIGEST_PREFIX} this session's listener stopped reaching the dashboard for ` +
106
106
  `${Math.round((HEARTBEAT_SILENT_FAILURE_THRESHOLD * HEARTBEAT_INTERVAL_MS) / 1000)}s while it still ` +
107
- `believed it was streaming — forcing a fresh connection now.`));
107
+ `believed it was streaming — forcing a fresh connection now.`), "relay_notice");
108
108
  // End the CURRENT connection now, rather than wait on the stream reader's own read-idle timeout (which this exact
109
109
  // failure mode can outlive indefinitely: the connection reads as established and keeps producing keep-alive bytes,
110
110
  // only the outbound heartbeat silently never lands). The new attempt reconnects with the SAME ticket; the
package/dist/index.js CHANGED
@@ -650,8 +650,12 @@ strictTool("design_publish", "Build a design against this repo's REAL components
650
650
  "`captured` (1280x800) or `none: <reason>` (no Playwright in the repo: the publish still succeeds). Build errors and a " +
651
651
  "refused revision (400, 413) come back verbatim; a refusal after the card was created carries its `card_id`: fix the " +
652
652
  "design and call again with it. Limits: the frame has no network, so a component that fetches its own data fails inside " +
653
- "it (use fixture props); React only; targets are edited in Settings > Board > General, never in a file.", {
654
- target: z.string().min(1).describe("A design target's name, as declared in Settings > Board > General (Design targets)."),
653
+ "it (use fixture props); React only; targets are a dashboard setting, read from `design_targets` of `GET /api/repos/<repo>`, never a file.", {
654
+ target: z.string().min(1).describe("A design target's name, as listed in `design_targets` of `GET /api/repos/<repo>` (`danxbot_api`, fields {design_targets: true})."),
655
+ note: z
656
+ .string()
657
+ .min(1)
658
+ .describe("The revision note, shown beside the revision on the card: what changed since the last revision, which decision note it answers, what is still open."),
655
659
  dir: z
656
660
  .string()
657
661
  .min(1)
@@ -790,14 +794,16 @@ async (args) => {
790
794
  strictTool("plan_events_wait",
791
795
  // DX-4233 - for the danxbot plugin's relay loop. DX-4667 - and for a background waiter sub-agent of a plan orchestrator, which
792
796
  // loops on it until events exist and returns them (and the cursor) to the orchestrator it wakes. It answers `events` (each
793
- // `{cursor, text}`; empty after `timeout_ms`), the cursor to send being the last delivered event's, or `stopped` with the remedy
797
+ // `{cursor, text, urgent}`; empty after `timeout_ms`), the cursor to send being the last delivered event's, or `stopped` with the remedy
794
798
  // to tell the session. General-audience text (DX-4550): nothing here names our own setup.
795
799
  "Waits for events on the plan this session is connected to. For a relay or a background waiter that calls it in a loop " +
796
800
  "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 " +
797
801
  "different plan than the last call's starts clean). `cursor` is the `cursor` of the last event delivered, as answered (null " +
798
- "when none). Answers `{events: [{cursor, text}]}`, with `events` empty once `timeout_ms` passes, or `{stopped: " +
802
+ "when none). Answers `{events: [{cursor, text, urgent}]}`, with `events` empty once `timeout_ms` passes, or `{stopped: " +
799
803
  "{reason, detail, fix}}` when the stream ended: tell the session the `fix`. An event stays until a later call's " +
800
- "`cursor` passes it. Only one wait is pending per session: a newer call ends the older. " +
804
+ "`cursor` passes it. `urgent` is true for an event the session must hear at once (a critical usage limit, a revoked " +
805
+ "key) and false for every other: deliver an urgent one to the session even while it is working, and any other once its " +
806
+ "current turn ends. Only one wait is pending per session: a newer call ends the older. " +
801
807
  "`transcript_path` is the session's transcript file, for its usage and title.", {
802
808
  plan_id: z.number().int().positive().describe("Id of the plan this session is connected to."),
803
809
  cursor: z
package/dist/listen.js CHANGED
@@ -51,6 +51,7 @@
51
51
  import { errorCodeOf } from "./json-body.js";
52
52
  import { LINE_PREFIX, oneLine } from "./one-line.js";
53
53
  import { pacingRelayLine } from "./pacing-relay.js";
54
+ import { isUrgent } from "./urgent-records.js";
54
55
  export const INITIAL_BACKOFF_MS = 1_000;
55
56
  export const MAX_BACKOFF_MS = 30_000;
56
57
  /** Three of the dashboard's 15 s keep-alives without a byte means the connection is dead. */
@@ -503,7 +504,7 @@ export async function runListener(options, deps) {
503
504
  * session; a pacing change is an instruction to act now). A frame that is not JSON or not the right shape is reported from an explicit check
504
505
  * naming the fault, and the next frame still emits.
505
506
  */
506
- const emitAdvisory = (message, what, invalidReason, format, scope) => {
507
+ const emitAdvisory = (message, what, invalidReason, format, sourceOf, scope) => {
507
508
  let parsed;
508
509
  let invalid;
509
510
  try {
@@ -514,7 +515,9 @@ export async function runListener(options, deps) {
514
515
  invalid = `not JSON: ${err.message}`;
515
516
  }
516
517
  const text = invalid === null ? format(parsed) : `${LINE_PREFIX} could not read ${what} (${invalid}): ${oneLine(message.data, 300)}`;
517
- deps.write({ type: "event", id: null, text, origin: null, ...(scope === undefined ? {} : { scope }) });
518
+ // DX-4721: an unreadable frame is a fault line, never the frame it failed to be (a malformed `critical` pacing frame is not a stop).
519
+ const urgent = isUrgent(invalid === null ? sourceOf(parsed) : { source: "fault" });
520
+ deps.write({ type: "event", id: null, text, origin: null, urgent, ...(scope === undefined ? {} : { scope }) });
518
521
  return null;
519
522
  };
520
523
  const handle = (message) => {
@@ -537,13 +540,13 @@ export async function runListener(options, deps) {
537
540
  return { kind: "ended", reason: isKnownWireEndReason(reason) ? reason : "unknown" };
538
541
  }
539
542
  if (message.event === "nudge")
540
- return emitAdvisory(message, "nudge", invalidNudgeReason, (p) => formatNudgeLine(p));
543
+ return emitAdvisory(message, "nudge", invalidNudgeReason, (p) => formatNudgeLine(p), () => ({ source: "nudge" }));
541
544
  // DX-4340 - the pacing tick's one message for this session's Claude account (level or budget changed): advisory like a nudge.
542
545
  if (message.event === "pacing")
543
- return emitAdvisory(message, "pacing message", invalidPacingReason, (p) => formatPacingLine(p), "session");
546
+ return emitAdvisory(message, "pacing message", invalidPacingReason, (p) => formatPacingLine(p), (p) => ({ source: "pacing", level: p.level }), "session");
544
547
  // DX-4342 - the auto-resume's message once the limit that paused or stopped this session has reset: advisory like a pacing message.
545
548
  if (message.event === "resume")
546
- return emitAdvisory(message, "resume message", invalidResumeReason, (p) => formatResumeLine(p), "session");
549
+ return emitAdvisory(message, "resume message", invalidResumeReason, (p) => formatResumeLine(p), () => ({ source: "resume" }), "session");
547
550
  if (message.event !== "activity")
548
551
  return null;
549
552
  // `Number(null)` is 0, so a frame with no id must never be read as event 0: that id
@@ -577,7 +580,9 @@ export async function runListener(options, deps) {
577
580
  // 10-minute digest wait just because it happened to come from an agent or
578
581
  // a machine writer.
579
582
  const origin = invalid === null ? parsed.origin : null;
580
- deps.write({ type: "event", id: knownId ? id : null, text, origin });
583
+ // DX-4721: an unreadable event is a fault line; a readable one is urgent by its kind (none is today).
584
+ const urgent = isUrgent(invalid === null ? { source: "activity", kind: parsed.kind } : { source: "fault" });
585
+ deps.write({ type: "event", id: knownId ? id : null, text, origin, urgent });
581
586
  if (knownId)
582
587
  remember(id);
583
588
  return null;
@@ -57,6 +57,7 @@ import { HeartbeatReporter } from "./heartbeat-reporter.js";
57
57
  import { capResumeIds, DELIVERED_ID_MEMORY, HEALTHY_CONNECTION_MS, INITIAL_BACKOFF_MS, MAX_BACKOFF_MS, runListener, } from "./listen.js";
58
58
  import { classifyAdmissionRefusal, fetchPlanInventory, HEARTBEAT_INTERVAL_MS, heartbeatConnection, MAX_REFUSED_TICKETS_IN_A_ROW, mintTicket, } from "./session-stream.js";
59
59
  import { fixForStop } from "./stop-fixes.js";
60
+ import { isUrgent } from "./urgent-records.js";
60
61
  /** The shortest and longest wait one call may ask for; a short ceiling keeps an orphaned waiter's life short. */
61
62
  export const WAIT_MIN_MS = 1_000;
62
63
  export const WAIT_MAX_MS = 30_000;
@@ -183,7 +184,7 @@ export class PlanEventRelay {
183
184
  }
184
185
  /** Everything buffered, each record with the cursor to store once it is delivered. */
185
186
  answer() {
186
- return { events: this.buffer.map(({ seq, recent, text }) => ({ cursor: formatCursor({ seq, ids: recent }), text })) };
187
+ return { events: this.buffer.map(({ seq, recent, text, urgent }) => ({ cursor: formatCursor({ seq, ids: recent }), text, urgent })) };
187
188
  }
188
189
  // ------------------------------------------------------------------------------------------------------------ the buffer
189
190
  /**
@@ -200,7 +201,7 @@ export class PlanEventRelay {
200
201
  this.seenIds.shift();
201
202
  }
202
203
  this.seq += 1;
203
- this.buffer.push({ seq: this.seq, own, recent: [...this.recentIds], text: event.text, scope: event.scope ?? "plan" });
204
+ this.buffer.push({ seq: this.seq, own, recent: [...this.recentIds], text: event.text, scope: event.scope ?? "plan", urgent: event.urgent });
204
205
  const waiter = this.waiter;
205
206
  if (waiter === null)
206
207
  return;
@@ -246,7 +247,7 @@ export class PlanEventRelay {
246
247
  connection: () => heartbeatConnection(this.connectionState, this.stoppedReason, this.lastInventoryError),
247
248
  streaming: () => this.connectionState === "streaming",
248
249
  // `origin: null` skips the agent/machine hold: the notice reaches the session at once. It is about the session, not a plan.
249
- announce: (text) => this.digest.route({ type: "event", id: null, text, origin: null, scope: "session" }),
250
+ announce: (text, source) => this.digest.route({ type: "event", id: null, text, origin: null, scope: "session", urgent: isUrgent({ source }) }),
250
251
  forceReconnect: () => this.run?.forceReconnect?.(),
251
252
  }, transcriptPath ?? null);
252
253
  }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * DX-4721 - THE ONE PLACE that decides whether a relayed plan-event record is URGENT.
3
+ *
4
+ * Background. `plan_events_wait` hands the caller (the danxbot plugin's relay, or a plan orchestrator's waiter sub-agent) a
5
+ * stream of records. The caller delivers an ordinary record after the session's running turn ends, and adds an URGENT one to
6
+ * the running turn at once. Which record is which is decided here, by the record's KIND, and nowhere else: every site that
7
+ * creates a record names its source and calls `isUrgent`; none writes an `urgent` literal of its own. The flag is a required
8
+ * boolean on `ListenEvent`, so a site that forgets it fails `make typecheck`, and no reader defaults a missing one.
9
+ *
10
+ * Urgent: a critical usage-pacing stop (the account is about to hit its limit and the session must wind down now), and the
11
+ * notice that this session's key was revoked (it can no longer call the dashboard). Everything else is not: activity events
12
+ * (comments, answers, problems, blocks, design and pull-request outcomes), the idle nudge, the auto-resume message, a digest,
13
+ * the relay's own failure notice, and any line that reports an unreadable event or frame (a fault line).
14
+ *
15
+ * The operator's direct stop. The dashboard writes no stop record today (the activity vocabulary, `IssueActivityKind` in
16
+ * `src/issues/db/issue-activity.ts`, and the stream's frames have none), so there is no kind to key on. When the dashboard adds
17
+ * one, it becomes a `RecordSource` here, urgent, in this same function: never a text match.
18
+ */
19
+ /** DX-4721: true for a critical pacing stop and a revoked key; false for every other source (see the module docblock). */
20
+ export function isUrgent(record) {
21
+ switch (record.source) {
22
+ case "pacing":
23
+ return record.level === "critical";
24
+ case "key_revoked":
25
+ return true;
26
+ case "activity":
27
+ case "nudge":
28
+ case "resume":
29
+ case "digest":
30
+ case "relay_notice":
31
+ case "fault":
32
+ return false;
33
+ }
34
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thehammer/danx-dashboard-mcp",
3
- "version": "0.1.284",
3
+ "version": "0.1.286",
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",