@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 +44 -5
- package/dist/handlers.js +3 -1
- package/dist/index.js +8 -4
- package/dist/session-title.js +127 -0
- package/package.json +1 -1
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
|
|
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
|
-
|
|
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 —
|
|
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
|
|
225
|
-
*
|
|
226
|
-
* here.
|
|
227
|
-
*
|
|
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.
|
|
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",
|