@thehammer/danx-dashboard-mcp 0.1.227 → 0.1.229

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
@@ -76,6 +76,7 @@ import { readSessionConnection, sessionConnectionPath } from "./session-connecti
76
76
  import { capResumeIds, DELIVERED_ID_MEMORY, HEALTHY_CONNECTION_MS, READ_IDLE_TIMEOUT_MS, runListener, } from "./listen.js";
77
77
  import { isClaimState } from "@thehammer/danx-session-telemetry";
78
78
  import { OperatorSessionStats, transcriptPathFromEnv, } from "./session-stats.js";
79
+ import { TranscriptTitleReader } from "./session-title.js";
79
80
  /**
80
81
  * DX-2730 D4 — WHICH EVENTS WAKE THE SESSION, NOW THAT EVERY ONE OF THEM CAN.
81
82
  *
@@ -586,7 +587,9 @@ export function degradePolicyFor(reason) {
586
587
  * heartbeat call itself was rejected". This gap is real and is recorded on
587
588
  * the card rather than silently papered over.
588
589
  */
589
- async function sendHeartbeat(options, deps, connection, stats) {
590
+ async function sendHeartbeat(options, deps, connection, stats,
591
+ // DX-4461 - the session's current Claude title, read from its transcript (`session-title.ts`).
592
+ title) {
590
593
  const body = { state: connection.state };
591
594
  if (connection.reason !== undefined)
592
595
  body.reason = connection.reason;
@@ -594,7 +597,7 @@ async function sendHeartbeat(options, deps, connection, stats) {
594
597
  body.inventory = connection.inventory;
595
598
  const outcome = await request(options, deps, "heartbeat", `${options.dashboardUrl}${HEARTBEAT_PATH}`, {
596
599
  method: "POST",
597
- body: JSON.stringify(stats === undefined ? { connection: body } : { connection: body, stats }),
600
+ body: JSON.stringify({ connection: body, ...(stats !== undefined ? { stats } : {}), ...(title !== undefined ? { title } : {}) }),
598
601
  });
599
602
  // DX-3099 — this is now the ONE place a heartbeat's own outcome is
600
603
  // inspected. `request()` still never throws, and a 401/403 is still an
@@ -604,7 +607,7 @@ async function sendHeartbeat(options, deps, connection, stats) {
604
607
  // request never landed at all, which is exactly the signal the silent-
605
608
  // failure counter in `runBridge` below needs. Still never throws.
606
609
  if (outcome.kind !== "answer")
607
- return { landed: false, status: null, stats: null, revocation: null };
610
+ return { landed: false, status: null, stats: null, revocation: null, title: null };
608
611
  // DX-2954 — the answer's `stats` (the operator dispatch's stored or
609
612
  // just-written totals and cursors) feed the session's stats collector.
610
613
  const ok = outcome.status >= 200 && outcome.status < 300;
@@ -623,7 +626,15 @@ async function sendHeartbeat(options, deps, connection, stats) {
623
626
  ? statsBody
624
627
  : null;
625
628
  // DX-4418 - the dashboard refuses the beat of a session whose key a person revoked, and says so.
626
- return { landed: true, status: outcome.status, stats: answered, revocation: keyRevocationOf({ ok: false, status: outcome.status, body: outcome.body }) };
629
+ // DX-4461 - the dashboard's stored title for this session; an answer without one is an older dashboard's.
630
+ const storedTitle = ok && isRecord(outcome.body) && typeof outcome.body.title === "string" ? outcome.body.title : null;
631
+ return {
632
+ landed: true,
633
+ status: outcome.status,
634
+ stats: answered,
635
+ revocation: keyRevocationOf({ ok: false, status: outcome.status, body: outcome.body }),
636
+ title: storedTitle,
637
+ };
627
638
  }
628
639
  function isRecord(value) {
629
640
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -669,11 +680,39 @@ export async function runBridge(options, deps) {
669
680
  : connectionState === "stopped"
670
681
  ? { state: "stopped", reason: stoppedReason ?? undefined }
671
682
  : { state: "reconnecting" };
683
+ // DX-4461 - a rename reaches the dashboard without the agent: the title in the session's transcript rides the heartbeat
684
+ // when it differs from the one the dashboard last answered with. Only the LATEST landed answer counts: one without a
685
+ // title (an older dashboard, including one rolled back to mid-session, or a refusal) stops the title until an answer
686
+ // carries one again, since an older dashboard refuses the `title` key and a refused heartbeat is a dead listener.
687
+ const titleReader = options.transcriptPath !== undefined ? new TranscriptTitleReader(options.transcriptPath) : null;
688
+ let dashboardTitle;
689
+ let lastTitleError = null;
690
+ const titleToReport = () => {
691
+ if (titleReader === null || dashboardTitle === undefined)
692
+ return undefined;
693
+ // The title is cosmetic and the heartbeat is liveness: a transcript the title cannot be read from costs the title
694
+ // (said once per distinct failure), never the beat.
695
+ try {
696
+ const title = titleReader.read();
697
+ lastTitleError = null;
698
+ return title !== null && title !== dashboardTitle ? title : undefined;
699
+ }
700
+ catch (err) {
701
+ const message = err instanceof Error ? err.message : String(err);
702
+ if (message !== lastTitleError)
703
+ process.stderr.write(`session title: ${message}\n`);
704
+ lastTitleError = message;
705
+ return undefined;
706
+ }
707
+ };
672
708
  const heartbeatTick = () => {
673
709
  if (heartbeatSuppressed)
674
710
  return;
711
+ const title = titleToReport();
675
712
  void sessionStats.tick(async (stats) => {
676
- const answer = await sendHeartbeat(options, deps, connectionNow(), stats);
713
+ const answer = await sendHeartbeat(options, deps, connectionNow(), stats, title);
714
+ if (answer.landed)
715
+ dashboardTitle = answer.title ?? undefined;
677
716
  // DX-4418 - a person revoked this session's key: tell the session to stop, at once and only once, as an ordinary event
678
717
  // line (the same delivery the silent-failure notice below uses). Its next dashboard call says the same.
679
718
  if (answer.revocation !== null && !revocationAnnounced) {
package/dist/handlers.js CHANGED
@@ -47,9 +47,11 @@ const PLAN_SESSIONS_BASE_PATH = "/api/plan-sessions";
47
47
  * would end the bridge's stream.
48
48
  *
49
49
  * `title` (DX-2816) rides here, not on a header, because the AGENT — not this
50
- * server — is the one thing able to read Claude's own session title (via a
50
+ * server — can read Claude's own session title at connect time (via a
51
51
  * Desktop-side `get_session` tool no MCP server subprocess can call). Sent
52
52
  * only when the caller supplied one; the server leaves an unset title alone.
53
+ * The event bridge keeps it current afterwards, from the transcript (DX-4461,
54
+ * `session-title.ts`).
53
55
  */
54
56
  export async function planConnect(client, args) {
55
57
  if (args.disconnect === true) {
package/dist/index.js CHANGED
@@ -221,10 +221,14 @@ function resolveInitialCredential(dashboardUrl, sessionId) {
221
221
  * MCP-server-reachable signal carries Claude's own session title — this
222
222
  * SERVER genuinely cannot read it. The AGENT can, though (a Desktop-side
223
223
  * `get_session` tool this server has no access to), and the agent is what
224
- * calls `plan_connect` — so the title now travels as that tool's own
225
- * `title` argument (see `handlers.ts#planConnect`), not as a header stamped
226
- * here. A session that never connects with a title keeps the dashboard's own
227
- * placeholder (`session <id prefix>`) — an honest placeholder beats a guess.
224
+ * calls `plan_connect` — so the title travels as that tool's own `title`
225
+ * argument (see `handlers.ts#planConnect`) at connect time, not as a header
226
+ * stamped here. DX-4461: the session's TRANSCRIPT carries every rename as a
227
+ * `custom-title` record, so the event bridge (not this server) reads it from
228
+ * there and reports it on its heartbeat (`session-title.ts`) and a later rename needs no
229
+ * agent call. A session that never connects with a title keeps the
230
+ * dashboard's own placeholder (`session <id prefix>`) until its bridge reports
231
+ * one — an honest placeholder beats a guess.
228
232
  */
229
233
  function readSessionConfig() {
230
234
  const id = readEnvOptional("CLAUDE_CODE_SESSION_ID");
@@ -0,0 +1,127 @@
1
+ /**
2
+ * DX-4461 - a session's live Claude title, read from its own transcript with no help from the agent.
3
+ *
4
+ * WHY THE TRANSCRIPT. The dashboard stores a plan session's title, and until now it arrived only as the `title`
5
+ * argument of `plan_connect`, so a session renamed afterwards showed a stale name until the agent happened to connect
6
+ * again. DX-2816 found no env var, hook input or MCP-reachable signal carrying the title, but Claude Code writes every
7
+ * title change into the session's transcript as a `{"type":"custom-title","customTitle":"..."}` line the moment the
8
+ * rename happens, even while the session is idle (observed on session 952f0739: 76 records of the old title, then the
9
+ * renamed one, no turn in between). The bridge already holds the transcript path (the plugin passes the hook input's
10
+ * `transcript_path` as `DANX_BRIDGE_TRANSCRIPT_PATH`, `session-stats.ts`), so it reads the last such record on each
11
+ * heartbeat and reports it when it differs from the title the dashboard answered with (`bridge.ts`); the dashboard
12
+ * stores it (`plan-sessions-routes.ts`).
13
+ *
14
+ * INCREMENTAL, NEVER A RE-READ. The reader remembers how far it has scanned (always a line boundary) and the title it
15
+ * found, so a heartbeat reads only the bytes appended since the last one. The first read of a long transcript is
16
+ * bounded to its last {@link TRANSCRIPT_TITLE_MAX_SCAN_BYTES}: Claude Code repeats the record on every turn, so the
17
+ * latest one is always near the end, and a transcript with none in that window has nothing to report.
18
+ *
19
+ * It reads synchronously on purpose: the bridge's heartbeat tick is synchronous up to its first request, and a read
20
+ * of a few appended kilobytes every 15 seconds costs less than the plumbing an async read would need.
21
+ */
22
+ import { closeSync, fstatSync, openSync, readSync } from "node:fs";
23
+ /**
24
+ * The longest title the dashboard accepts (`SESSION_TITLE_MAX_CHARS`, `src/issues/db/plan-sessions.ts`, counted in
25
+ * UTF-16 units). A longer one would 400 the whole heartbeat, which is liveness, so the reader cuts it first.
26
+ */
27
+ export const SESSION_TITLE_MAX_CHARS = 200;
28
+ /** How much of a transcript's end the first read looks at. */
29
+ export const TRANSCRIPT_TITLE_MAX_SCAN_BYTES = 2 * 1024 * 1024;
30
+ /** The marker of a title record. A message quoting it carries escaped quotes, so it never matches. */
31
+ const TITLE_RECORD_MARKER = '"type":"custom-title"';
32
+ export class TranscriptTitleReader {
33
+ path;
34
+ /** Offset just past the last complete line already scanned; always 0 or a line boundary. */
35
+ scannedTo = 0;
36
+ title = null;
37
+ constructor(path) {
38
+ this.path = path;
39
+ }
40
+ /**
41
+ * The session's current title, or `null` when its transcript has none (yet). Throws on a transcript this cannot
42
+ * read, or a complete title record that is not JSON: the caller decides what a failed read costs.
43
+ */
44
+ read() {
45
+ const fd = this.open();
46
+ if (fd === null)
47
+ return this.title;
48
+ try {
49
+ const size = fstatSync(fd).size;
50
+ if (size < this.scannedTo) {
51
+ // The file was rewritten shorter than what was already scanned; nothing remembered about it holds.
52
+ this.scannedTo = 0;
53
+ this.title = null;
54
+ }
55
+ if (size === this.scannedTo)
56
+ return this.title;
57
+ const start = Math.max(this.scannedTo, size - TRANSCRIPT_TITLE_MAX_SCAN_BYTES);
58
+ const buf = Buffer.alloc(size - start);
59
+ let filled = 0;
60
+ while (filled < buf.length) {
61
+ const n = readSync(fd, buf, filled, buf.length - filled, start + filled);
62
+ if (n === 0)
63
+ break;
64
+ filled += n;
65
+ }
66
+ const lastNewline = buf.subarray(0, filled).lastIndexOf(0x0a);
67
+ if (lastNewline === -1)
68
+ return this.title;
69
+ const lines = buf.subarray(0, lastNewline).toString("utf8").split("\n");
70
+ // A region that starts mid-file (the first read of a long transcript) begins inside a line.
71
+ if (start !== this.scannedTo)
72
+ lines.shift();
73
+ this.scannedTo = start + lastNewline + 1;
74
+ for (let i = lines.length - 1; i >= 0; i--) {
75
+ if (!lines[i].includes(TITLE_RECORD_MARKER))
76
+ continue;
77
+ this.title = parseTitleRecord(lines[i], this.path);
78
+ break;
79
+ }
80
+ return this.title;
81
+ }
82
+ finally {
83
+ closeSync(fd);
84
+ }
85
+ }
86
+ /** The transcript's descriptor, or `null` when it does not exist yet (Claude Code creates it on the first turn). */
87
+ open() {
88
+ try {
89
+ return openSync(this.path, "r");
90
+ }
91
+ catch (err) {
92
+ if (err.code === "ENOENT")
93
+ return null;
94
+ throw err;
95
+ }
96
+ }
97
+ }
98
+ function parseTitleRecord(line, path) {
99
+ let record;
100
+ try {
101
+ record = JSON.parse(line);
102
+ }
103
+ catch (err) {
104
+ throw new Error(`a custom-title line in ${path} is not JSON: ${err.message}`);
105
+ }
106
+ const customTitle = record?.customTitle;
107
+ if (typeof customTitle !== "string")
108
+ return null;
109
+ const trimmed = customTitle.trim();
110
+ return trimmed === "" ? null : boundTitle(trimmed);
111
+ }
112
+ /**
113
+ * At most {@link SESSION_TITLE_MAX_CHARS} UTF-16 units, cut on a code-point boundary. A cut that lands after a space is
114
+ * trimmed again: the dashboard stores the trimmed title, so an untrimmed one would never equal it and the bridge would
115
+ * resend it on every heartbeat.
116
+ */
117
+ function boundTitle(title) {
118
+ if (title.length <= SESSION_TITLE_MAX_CHARS)
119
+ return title;
120
+ let out = "";
121
+ for (const point of title) {
122
+ if (out.length + point.length > SESSION_TITLE_MAX_CHARS)
123
+ break;
124
+ out += point;
125
+ }
126
+ return out.trimEnd();
127
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thehammer/danx-dashboard-mcp",
3
- "version": "0.1.227",
3
+ "version": "0.1.229",
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, danxbot_complete, and other per-dispatch lifecycle callbacks) (DX-3465, PLN-11 R-25).",
5
5
  "license": "MIT",
6
6
  "type": "module",