@thehammer/danx-dashboard-mcp 0.1.304 → 0.1.305

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.
@@ -0,0 +1,145 @@
1
+ /**
2
+ * DX-4993 (PLAN-42 rule R-4) - the compaction control of an OPERATOR session, as this package relays it.
3
+ *
4
+ * 50 Arms decides when a session is asked to prepare for a compaction and when its agent has said ready (the dashboard's `compaction-handshake/`). The
5
+ * session's stream carries the decision as a `compaction` frame (`event: compaction`, data `{"control": <control> | null}`, level-triggered: the current
6
+ * control on connect and whenever it changes). The relay does two things with each frame:
7
+ *
8
+ * - the REQUEST (`control.ask`) reaches the agent as an urgent record, once per request (`ask.at` names it), so it arrives inside a running turn;
9
+ * - the rest is written to ONE local file the danxbot plugin's `PreCompact` gate reads: `~/.config/danxbot/compaction/<sessionId>.json`. The gate never
10
+ * asks the network: Claude Code proposes a compaction before every model request.
11
+ *
12
+ * The file is `{v: 1, sessionId, control: {floorTokens, passAtTokens, windowTokens, releaseUntilMs, releaseId} | null, askedAt: string | null}`. `sessionId` is the
13
+ * session the file was written for (the plugin refuses a file that names another); `windowTokens` is the context window the dashboard assumed for the session's
14
+ * model line (the plugin does not arm a session whose real window is smaller). `control: null` says the session is not under the handshake (the plugin disarms). `askedAt` is the relay's own memory of the last request it showed the agent, kept in the file so a restart
15
+ * of this server does not show it again; the plugin ignores it.
16
+ *
17
+ * The shapes below mirror the dashboard's `OperatorCompactionControl` (`src/dashboard/compaction-handshake/operator-session.ts`) and the plugin's gate
18
+ * (`danxbot/hooks/compaction/control.ts` in the plugin repo): this published package cannot import either. Each side tolerates the other's previous
19
+ * version by ignoring a frame or a file key it does not know and refusing nothing else (see the release order on DX-4993).
20
+ */
21
+ import { mkdirSync, readFileSync, renameSync, utimesSync, writeFileSync } from "node:fs";
22
+ import { homedir } from "node:os";
23
+ import { dirname, join } from "node:path";
24
+ import { prefixedLine } from "./one-line.js";
25
+ import { assertSessionIdSegment, pruneStaleSessionFiles } from "./session-connection.js";
26
+ /** The longest request line relayed whole (the pacing line's bound): the dashboard's request is a few hundred characters. */
27
+ export const COMPACTION_ASK_MAX_CODE_POINTS = 1000;
28
+ const isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
29
+ const isCount = (v) => typeof v === "number" && Number.isInteger(v) && v >= 0;
30
+ /** Why a `compaction` frame's data cannot be read, or `null` when it can. */
31
+ export function invalidCompactionReason(value) {
32
+ if (!isRecord(value) || !("control" in value))
33
+ return "the frame has no control";
34
+ const c = value.control;
35
+ if (c === null)
36
+ return null;
37
+ if (!isRecord(c))
38
+ return "control is not an object or null";
39
+ if (c.v !== 1)
40
+ return `control version ${JSON.stringify(c.v)} is not 1`;
41
+ if (!isCount(c.floorTokens) || !isCount(c.passAtTokens) || !isCount(c.windowTokens))
42
+ return "floorTokens, passAtTokens and windowTokens must be whole numbers";
43
+ // DX-4993: the plugin refuses a control whose valve is not above its floor, and the dashboard never sends one; a frame that would become such a file is a fault line
44
+ if (c.passAtTokens <= c.floorTokens)
45
+ return "passAtTokens must be above floorTokens";
46
+ if (c.windowTokens <= c.passAtTokens)
47
+ return "windowTokens must be above passAtTokens";
48
+ if (c.ask !== null) {
49
+ if (!isRecord(c.ask) || !isCount(c.ask.seq) || typeof c.ask.at !== "string" || c.ask.at === "" || typeof c.ask.text !== "string" || c.ask.text.trim() === "") {
50
+ return "ask must be null or {seq, at, text}";
51
+ }
52
+ }
53
+ if ((c.releaseUntilMs === null) !== (c.releaseId === null))
54
+ return "releaseUntilMs and releaseId are both set or both null";
55
+ if (c.releaseUntilMs !== null && !(typeof c.releaseUntilMs === "number" && Number.isFinite(c.releaseUntilMs)))
56
+ return "releaseUntilMs must be a number or null";
57
+ if (c.releaseId !== null && (typeof c.releaseId !== "string" || c.releaseId === ""))
58
+ return "releaseId must be a string or null";
59
+ return null;
60
+ }
61
+ /** The control of a valid frame's data (`invalidCompactionReason` is `null`). */
62
+ export function controlOfFrame(value) {
63
+ const reason = invalidCompactionReason(value);
64
+ if (reason !== null)
65
+ throw new Error(`malformed compaction frame: ${reason}`);
66
+ return value.control;
67
+ }
68
+ /** The line the agent reads for a request: the dashboard's text on one line, prefixed like every stream line. */
69
+ export function formatCompactionAskLine(ask) {
70
+ return prefixedLine(ask.text, COMPACTION_ASK_MAX_CODE_POINTS);
71
+ }
72
+ /** The file of session `sessionId` for `control` (`null`: not under the handshake) after the request `askedAt` was shown last. */
73
+ export function fileOf(control, askedAt, sessionId) {
74
+ return {
75
+ v: 1,
76
+ sessionId,
77
+ control: control === null
78
+ ? null
79
+ : { floorTokens: control.floorTokens, passAtTokens: control.passAtTokens, windowTokens: control.windowTokens, releaseUntilMs: control.releaseUntilMs, releaseId: control.releaseId },
80
+ askedAt,
81
+ };
82
+ }
83
+ export function compactionDir(home = homedir()) {
84
+ return join(home, ".config", "danxbot", "compaction");
85
+ }
86
+ /** `~/.config/danxbot/compaction/<sessionId>.json`, next to the plugin's other per-session records. */
87
+ export function compactionFilePath(sessionId, home = homedir()) {
88
+ assertSessionIdSegment(sessionId);
89
+ return join(compactionDir(home), `${sessionId}.json`);
90
+ }
91
+ export function parseCompactionFile(text, where) {
92
+ const parsed = JSON.parse(text);
93
+ if (!isRecord(parsed) || parsed.v !== 1 || typeof parsed.sessionId !== "string" || !("control" in parsed) || (parsed.askedAt !== null && typeof parsed.askedAt !== "string")) {
94
+ throw new Error(`${where} is not a compaction file`);
95
+ }
96
+ const reason = parsed.control === null ? null : invalidCompactionReason({ control: { ...parsed.control, v: 1, ask: null } });
97
+ if (reason !== null)
98
+ throw new Error(`${where}: ${reason}`);
99
+ return parsed;
100
+ }
101
+ /**
102
+ * The real store, under `home`. Every write also removes the files nothing has touched for a week (`pruneStaleSessionFiles`, like the session's connection
103
+ * record and key file). A live session's file is never among them: its relay touches the file at every heartbeat (`touch`), so only the files of sessions whose
104
+ * relay has been gone for a week are removed.
105
+ */
106
+ export function compactionFilesIn(home = homedir(), now = Date.now) {
107
+ return {
108
+ read(sessionId) {
109
+ const path = compactionFilePath(sessionId, home);
110
+ let text;
111
+ try {
112
+ text = readFileSync(path, "utf-8");
113
+ }
114
+ catch (err) {
115
+ if (err.code === "ENOENT")
116
+ return null;
117
+ throw err;
118
+ }
119
+ const file = parseCompactionFile(text, path);
120
+ if (file.sessionId !== sessionId)
121
+ throw new Error(`${path} was written for session ${JSON.stringify(file.sessionId)}`);
122
+ return file;
123
+ },
124
+ write(sessionId, file) {
125
+ const path = compactionFilePath(sessionId, home);
126
+ mkdirSync(dirname(path), { recursive: true });
127
+ pruneStaleSessionFiles(dirname(path), now());
128
+ // A sibling then a rename: the plugin's gate reads this file on every compaction proposal and must never see half of one.
129
+ const temp = `${path}.${process.pid}.tmp`;
130
+ writeFileSync(temp, JSON.stringify(file), "utf-8");
131
+ renameSync(temp, path);
132
+ },
133
+ touch(sessionId) {
134
+ const at = new Date(now());
135
+ try {
136
+ utimesSync(compactionFilePath(sessionId, home), at, at);
137
+ }
138
+ catch (err) {
139
+ // a session with no file has nothing to keep; any other failure is the caller's to report
140
+ if (err.code !== "ENOENT")
141
+ throw err;
142
+ }
143
+ },
144
+ };
145
+ }
package/dist/index.js CHANGED
@@ -82,6 +82,7 @@
82
82
  * 5xx and network failures throw — never silently swallowed.
83
83
  */
84
84
  import { isEntrypointModule } from "./entrypoint.js";
85
+ import { compactionFilesIn } from "./compaction-control.js";
85
86
  import { PlanEventRelay, WAIT_MAX_MS, WAIT_MIN_MS } from "./plan-events-relay.js";
86
87
  import { READ_IDLE_TIMEOUT_MS } from "./listen.js";
87
88
  import { REQUEST_TIMEOUT_MS, realScheduleInterval, realSleep } from "./session-stream.js";
@@ -256,6 +257,7 @@ function boot() {
256
257
  readIdleTimeoutMs: READ_IDLE_TIMEOUT_MS,
257
258
  requestTimeoutMs: REQUEST_TIMEOUT_MS,
258
259
  scheduleInterval: realScheduleInterval,
260
+ compactionFiles: compactionFilesIn(),
259
261
  });
260
262
  permissionWatch = new PermissionWatch({
261
263
  dashboardUrl,
package/dist/listen.js CHANGED
@@ -41,6 +41,7 @@
41
41
  * (`src/issues/db/issue-activity.ts`); this published package cannot import
42
42
  * danxbot source.
43
43
  */
44
+ import { controlOfFrame, invalidCompactionReason } from "./compaction-control.js";
44
45
  import { LINE_PREFIX, oneLine } from "./one-line.js";
45
46
  import { pacingRelayLine } from "./pacing-relay.js";
46
47
  import { isUrgent } from "./urgent-records.js";
@@ -524,6 +525,22 @@ export async function runListener(options, deps) {
524
525
  remember(id);
525
526
  return null;
526
527
  };
528
+ const emitCompaction = (message) => {
529
+ let parsed;
530
+ let invalid;
531
+ try {
532
+ parsed = JSON.parse(message.data);
533
+ invalid = invalidCompactionReason(parsed);
534
+ }
535
+ catch (err) {
536
+ invalid = `not JSON: ${err.message}`;
537
+ }
538
+ if (invalid === null)
539
+ deps.write({ type: "compaction", control: controlOfFrame(parsed) });
540
+ else
541
+ deps.write({ type: "event", id: null, text: `${LINE_PREFIX} could not read compaction control (${invalid}): ${oneLine(message.data, 300)}`, origin: null, urgent: isUrgent({ source: "fault" }), scope: "session" });
542
+ return null;
543
+ };
527
544
  const handle = (message) => {
528
545
  if (message.event === "end") {
529
546
  // DX-2920 (round 3) — a JSON.parse failure here falls through to the
@@ -551,6 +568,9 @@ export async function runListener(options, deps) {
551
568
  // DX-4342 - the auto-resume's message once the limit that paused or stopped this session has reset: advisory like a pacing message.
552
569
  if (message.event === "resume")
553
570
  return emitAdvisory(message, "resume message", invalidResumeReason, (p) => formatResumeLine(p), () => ({ source: "resume" }), "session");
571
+ // DX-4993 - the compaction control: level-triggered, no id, no replay. A frame that cannot be read is a fault line (never silence), and the next frame still lands.
572
+ if (message.event === "compaction")
573
+ return emitCompaction(message);
554
574
  if (message.event !== "activity")
555
575
  return null;
556
576
  // `Number(null)` is 0, so a frame with no id must never be read as event 0: that id
@@ -53,11 +53,13 @@
53
53
  * mint / inventory requests are aborted by `close()`, and `close()` also clears a pending call's wait timer (the one ref'd timer,
54
54
  * held only until the call answers). The server calls `close()` when its transport closes and when its stdin ends (`shutdown.ts`).
55
55
  */
56
+ import { fileOf, formatCompactionAskLine } from "./compaction-control.js";
56
57
  import { DigestHold } from "./digest-hold.js";
57
58
  import { HeartbeatReporter } from "./heartbeat-reporter.js";
58
59
  import { capResumeIds, DELIVERED_ID_MEMORY, HEALTHY_CONNECTION_MS, INITIAL_BACKOFF_MS, MAX_BACKOFF_MS, runListener, } from "./listen.js";
59
60
  import { fetchPlanInventory, HEARTBEAT_INTERVAL_MS, heartbeatConnection, MAX_REFUSED_TICKETS_IN_A_ROW, mintTicket, } from "./session-stream.js";
60
61
  import { fixForStop } from "./stop-fixes.js";
62
+ import { LINE_PREFIX } from "./one-line.js";
61
63
  import { isUrgent } from "./urgent-records.js";
62
64
  /** The shortest and longest wait one call may ask for; a short ceiling keeps an orphaned waiter's life short. */
63
65
  export const WAIT_MIN_MS = 1_000;
@@ -295,10 +297,22 @@ export class PlanEventRelay {
295
297
  this.suspend();
296
298
  return;
297
299
  }
298
- if (this.deps.auth() === null)
300
+ const auth = this.deps.auth();
301
+ if (auth === null)
299
302
  return;
303
+ this.touchCompactionFile(auth.sessionId);
300
304
  this.reporter?.tick();
301
305
  }
306
+ /** DX-4993: a live relay keeps its session's compaction file out of the weekly pruning (`CompactionFiles.touch`); a failure is reported to stderr, never ends the heartbeat. */
307
+ touchCompactionFile(sessionId) {
308
+ try {
309
+ this.deps.compactionFiles.touch(sessionId);
310
+ }
311
+ catch (err) {
312
+ process.stderr.write(`plan_events_wait: the compaction control file could not be kept alive: ${err instanceof Error ? err.message : String(err)}
313
+ `);
314
+ }
315
+ }
302
316
  /**
303
317
  * Let go of everything the relay holds for a caller: the stream, the heartbeat, the digest, and the plan-scoped records.
304
318
  * What was held for a digest was never marked delivered, so the dashboard's replay (after the caller's cursor) returns it to
@@ -338,6 +352,38 @@ export class PlanEventRelay {
338
352
  this.terminal(run, "relay_failed", message);
339
353
  });
340
354
  }
355
+ /**
356
+ * DX-4993 - the session's compaction control arrived: show the agent a request it has not been shown, and write the file the plugin's gate reads.
357
+ * A control with a new request (`ask.at`) is shown once: the file remembers which, so a reconnect, a restart of this server or a repeat of the same
358
+ * frame shows nothing twice. A file that cannot be read or written is a line to the agent (a fault: the gate cannot work), never silence.
359
+ */
360
+ applyCompaction(control) {
361
+ const auth = this.deps.auth();
362
+ if (auth === null)
363
+ return;
364
+ // DX-4993: a file that cannot be read is no memory of what was shown, never a reason not to write: the write below replaces it (otherwise the same fault repeats
365
+ // at every frame and the plugin's gate never gets a readable control). The agent is told once per frame that it happened.
366
+ let askedAt = null;
367
+ try {
368
+ askedAt = this.deps.compactionFiles.read(auth.sessionId)?.askedAt ?? null;
369
+ }
370
+ catch (err) {
371
+ const detail = err instanceof Error ? err.message : String(err);
372
+ this.digest.route({ type: "event", id: null, text: `${LINE_PREFIX} the compaction control file of this session could not be read (${detail}); it is replaced`, origin: null, urgent: isUrgent({ source: "fault" }), scope: "session" });
373
+ }
374
+ try {
375
+ const ask = control?.ask ?? null;
376
+ const show = ask !== null && ask.at !== askedAt;
377
+ this.deps.compactionFiles.write(auth.sessionId, fileOf(control, ask === null ? null : show ? ask.at : askedAt, auth.sessionId));
378
+ if (show) {
379
+ this.digest.route({ type: "event", id: null, text: formatCompactionAskLine(ask), origin: null, urgent: isUrgent({ source: "compaction_ask" }), scope: "session" });
380
+ }
381
+ }
382
+ catch (err) {
383
+ const detail = err instanceof Error ? err.message : String(err);
384
+ this.digest.route({ type: "event", id: null, text: `${LINE_PREFIX} the compaction control could not be kept for this session (${detail}), so its compaction gate cannot work`, origin: null, urgent: isUrgent({ source: "fault" }), scope: "session" });
385
+ }
386
+ }
341
387
  current(run) {
342
388
  return this.run === run && !run.abort.signal.aborted;
343
389
  }
@@ -415,6 +461,10 @@ export class PlanEventRelay {
415
461
  return;
416
462
  }
417
463
  attempt.emitted = true;
464
+ if (output.type === "compaction") {
465
+ this.applyCompaction(output.control);
466
+ return;
467
+ }
418
468
  this.digest.route(output);
419
469
  },
420
470
  onState: (state) => {
@@ -67,7 +67,10 @@ export function sessionConnectionPath(sessionId, home = homedir()) {
67
67
  assertSessionIdSegment(sessionId);
68
68
  return join(sessionConnectionDir(home), `${sessionId}.json`);
69
69
  }
70
- /** Remove the per-session `.json` files in `dir` that nothing has touched for `STALE_RECORD_MS`. */
70
+ /**
71
+ * Remove the per-session `.json` files in `dir` that nothing has touched for `STALE_RECORD_MS`, and the `.tmp` files a failed
72
+ * write-then-rename left beside them (DX-4993).
73
+ */
71
74
  export function pruneStaleSessionFiles(dir, now) {
72
75
  let names;
73
76
  try {
@@ -77,7 +80,7 @@ export function pruneStaleSessionFiles(dir, now) {
77
80
  return; // nothing written yet
78
81
  }
79
82
  for (const name of names) {
80
- if (!name.endsWith(".json"))
83
+ if (!name.endsWith(".json") && !name.endsWith(".tmp"))
81
84
  continue;
82
85
  const file = join(dir, name);
83
86
  try {
@@ -7,8 +7,8 @@
7
7
  * creates a record names its source and calls `isUrgent`; none writes an `urgent` literal of its own. The flag is a required
8
8
  * boolean on `ListenEvent`, so a site that forgets it fails `make typecheck`, and no reader defaults a missing one.
9
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
10
+ * Urgent: a critical usage-pacing stop (the account is about to hit its limit and the session must wind down now), the
11
+ * notice that this session's key was revoked (it can no longer call the dashboard), and 50 Arms' request to prepare for a compaction. Everything else is not: activity events
12
12
  * (comments, answers, problems, blocks, design and pull-request outcomes), the idle nudge, the auto-resume message, a digest,
13
13
  * the relay's own failure notice, and any line that reports an unreadable event or frame (a fault line).
14
14
  *
@@ -23,6 +23,9 @@ export function isUrgent(record) {
23
23
  return record.level === "critical";
24
24
  case "key_revoked":
25
25
  return true;
26
+ // DX-4993: 50 Arms' request to prepare for a compaction must reach the agent inside a running turn, not after it: the compaction waits for the agent's ready.
27
+ case "compaction_ask":
28
+ return true;
26
29
  case "activity":
27
30
  case "nudge":
28
31
  case "resume":
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thehammer/danx-dashboard-mcp",
3
- "version": "0.1.304",
3
+ "version": "0.1.305",
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",