@thehammer/danx-dashboard-mcp 0.1.180 → 0.1.181
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 +36 -9
- package/dist/session-stats.js +158 -0
- package/package.json +2 -1
package/dist/bridge.js
CHANGED
|
@@ -71,6 +71,7 @@ import { oneLine } from "./one-line.js";
|
|
|
71
71
|
import { credentialFingerprint, describeCredentialSource, readCredential, } from "./credential.js";
|
|
72
72
|
import { readSessionConnection, sessionConnectionPath } from "./session-connection.js";
|
|
73
73
|
import { capResumeIds, DELIVERED_ID_MEMORY, HEALTHY_CONNECTION_MS, READ_IDLE_TIMEOUT_MS, runListener, } from "./listen.js";
|
|
74
|
+
import { OperatorSessionStats, transcriptPathFromEnv, } from "./session-stats.js";
|
|
74
75
|
/**
|
|
75
76
|
* DX-2730 D4 — WHICH EVENTS WAKE THE SESSION, NOW THAT EVERY ONE OF THEM CAN.
|
|
76
77
|
*
|
|
@@ -241,7 +242,11 @@ export function parseBridgeArgs(argv, env) {
|
|
|
241
242
|
const resumeIds = withResume ? argv[1].split(",").map(positiveInteger) : [];
|
|
242
243
|
if (resumeIds.some((id) => id === null))
|
|
243
244
|
throw new Error(USAGE);
|
|
244
|
-
|
|
245
|
+
const parsed = { sessionId, resumeIds: resumeIds };
|
|
246
|
+
const transcriptPath = transcriptPathFromEnv(env);
|
|
247
|
+
if (transcriptPath !== null)
|
|
248
|
+
parsed.transcriptPath = transcriptPath;
|
|
249
|
+
return parsed;
|
|
245
250
|
}
|
|
246
251
|
/** A start this process cannot make, named the way the session will be told about it. */
|
|
247
252
|
export class BridgeStartError extends Error {
|
|
@@ -334,6 +339,7 @@ export function resolveBridgeOptions(args, env, options = {}) {
|
|
|
334
339
|
resumeIds: args.resumeIds,
|
|
335
340
|
instanceId,
|
|
336
341
|
watchedPaths,
|
|
342
|
+
...(args.transcriptPath !== undefined ? { transcriptPath: args.transcriptPath } : {}),
|
|
337
343
|
};
|
|
338
344
|
}
|
|
339
345
|
/**
|
|
@@ -542,7 +548,7 @@ export function degradePolicyFor(reason) {
|
|
|
542
548
|
* heartbeat call itself was rejected". This gap is real and is recorded on
|
|
543
549
|
* the card rather than silently papered over.
|
|
544
550
|
*/
|
|
545
|
-
async function sendHeartbeat(options, deps, connection) {
|
|
551
|
+
async function sendHeartbeat(options, deps, connection, stats) {
|
|
546
552
|
const body = { state: connection.state };
|
|
547
553
|
if (connection.reason !== undefined)
|
|
548
554
|
body.reason = connection.reason;
|
|
@@ -550,7 +556,7 @@ async function sendHeartbeat(options, deps, connection) {
|
|
|
550
556
|
body.inventory = connection.inventory;
|
|
551
557
|
const outcome = await request(options, deps, "heartbeat", `${options.dashboardUrl}${HEARTBEAT_PATH}`, {
|
|
552
558
|
method: "POST",
|
|
553
|
-
body: JSON.stringify({ connection: body }),
|
|
559
|
+
body: JSON.stringify(stats === undefined ? { connection: body } : { connection: body, stats }),
|
|
554
560
|
});
|
|
555
561
|
// DX-3099 — this is now the ONE place a heartbeat's own outcome is
|
|
556
562
|
// inspected. `request()` still never throws, and a 401/403 is still an
|
|
@@ -559,7 +565,23 @@ async function sendHeartbeat(options, deps, connection) {
|
|
|
559
565
|
// "transient" outcome (network error, timeout, 5xx, 408/429) means the
|
|
560
566
|
// request never landed at all, which is exactly the signal the silent-
|
|
561
567
|
// failure counter in `runBridge` below needs. Still never throws.
|
|
562
|
-
|
|
568
|
+
if (outcome.kind !== "answer")
|
|
569
|
+
return { landed: false, status: null, stats: null };
|
|
570
|
+
// DX-2954 — the answer's `stats` (the operator dispatch's stored or
|
|
571
|
+
// just-written totals and cursors) feed the session's stats collector.
|
|
572
|
+
const ok = outcome.status >= 200 && outcome.status < 300;
|
|
573
|
+
// A heartbeat whose carried stats were NOT applied (`statsError`) still
|
|
574
|
+
// landed; its `stats` are the stored values, so the collector must re-queue
|
|
575
|
+
// rather than treat them as the answer to its write.
|
|
576
|
+
const applied = ok && isRecord(outcome.body) && outcome.body.statsError === undefined;
|
|
577
|
+
const statsBody = applied && isRecord(outcome.body) && isRecord(outcome.body.stats) ? outcome.body.stats : null;
|
|
578
|
+
const answered = statsBody !== null && isRecord(statsBody.sourceCursors) && isRecord(statsBody.totals)
|
|
579
|
+
? statsBody
|
|
580
|
+
: null;
|
|
581
|
+
return { landed: true, status: outcome.status, stats: answered };
|
|
582
|
+
}
|
|
583
|
+
function isRecord(value) {
|
|
584
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
563
585
|
}
|
|
564
586
|
/**
|
|
565
587
|
* Mint, stream, re-mint — until a terminal outcome, which it writes as the one
|
|
@@ -589,14 +611,18 @@ export async function runBridge(options, deps) {
|
|
|
589
611
|
// ended is a harmless no-op.
|
|
590
612
|
let heartbeatFailureStreak = 0;
|
|
591
613
|
let forceReconnect = null;
|
|
614
|
+
// DX-2954 — the session's stats ride this same heartbeat. Created ONCE per
|
|
615
|
+
// process, so an in-process re-mint never touches its state.
|
|
616
|
+
const sessionStats = new OperatorSessionStats(options.transcriptPath ?? null, deps.makeStatsWatcher);
|
|
617
|
+
const connectionNow = () => connectionState === "streaming"
|
|
618
|
+
? { state: "streaming", inventory: lastInventoryOk ? "ok" : "unavailable" }
|
|
619
|
+
: connectionState === "stopped"
|
|
620
|
+
? { state: "stopped", reason: stoppedReason ?? undefined }
|
|
621
|
+
: { state: "reconnecting" };
|
|
592
622
|
const heartbeatTick = () => {
|
|
593
623
|
if (heartbeatSuppressed)
|
|
594
624
|
return;
|
|
595
|
-
void sendHeartbeat(options, deps,
|
|
596
|
-
? { state: "streaming", inventory: lastInventoryOk ? "ok" : "unavailable" }
|
|
597
|
-
: connectionState === "stopped"
|
|
598
|
-
? { state: "stopped", reason: stoppedReason ?? undefined }
|
|
599
|
-
: { state: "reconnecting" }).then((landed) => {
|
|
625
|
+
void sessionStats.tick((stats) => sendHeartbeat(options, deps, connectionNow(), stats)).then((landed) => {
|
|
600
626
|
// DX-3099 — only counts while THIS process still believes it is
|
|
601
627
|
// streaming: that is the exact desync the card found (the client
|
|
602
628
|
// believes it is fine; the dashboard has nothing recent for it). A
|
|
@@ -661,6 +687,7 @@ export async function runBridge(options, deps) {
|
|
|
661
687
|
deps.write({ type: "stopped", reason, detail, refusal: extra?.refusal, fix, instanceId: options.instanceId, paths: options.watchedPaths, degraded });
|
|
662
688
|
if (policy === "exit") {
|
|
663
689
|
heartbeat.stop();
|
|
690
|
+
sessionStats.stop();
|
|
664
691
|
return { kind: "exit", code };
|
|
665
692
|
}
|
|
666
693
|
stoppedReason = reason;
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DX-2954 — an operator session's live stats, collected by the bridge and sent
|
|
3
|
+
* in its DX-2952 heartbeat.
|
|
4
|
+
*
|
|
5
|
+
* Workers and operator sessions report the same stats the same way (epic
|
|
6
|
+
* DX-2958), so this module owns NO counting or cursor logic of its own: it runs
|
|
7
|
+
* the ONE collector from the shared package (`DispatchUsage`, the shared ledger
|
|
8
|
+
* counter + transcript cursors + running-set queue), fed by the ONE transcript
|
|
9
|
+
* tailer (`SessionLogWatcher`) and the ONE transcript-side running-set producer
|
|
10
|
+
* (`createActivityObserver`) — the exact trio the worker runs in
|
|
11
|
+
* `attach-monitoring-stack.ts`. The server side is the ONE writer too:
|
|
12
|
+
* `POST /api/plan-sessions/me/heartbeat` applies the `stats` field through the
|
|
13
|
+
* same `applyStatsInTx` the worker's `/stats` route uses.
|
|
14
|
+
*
|
|
15
|
+
* ## Process start: the first heartbeat's answer, never the mint (round 22)
|
|
16
|
+
*
|
|
17
|
+
* A restarted bridge (watchdog restart, compact, resume) may fail its mint and
|
|
18
|
+
* still heartbeat (DX-2952), so the heartbeat is the one call that always
|
|
19
|
+
* answers. Until this process holds that answer it sends ONLY its connection
|
|
20
|
+
* state: no ledger entries, no cursors — the transcript is not even opened. The
|
|
21
|
+
* answer's `sourceCursors` are where each source resumes (start-order rule 1: an
|
|
22
|
+
* operator dispatch is one per session id, so its own stored `acked` is the
|
|
23
|
+
* whole answer; a source with none starts at 0 — rule 3, so a session connected
|
|
24
|
+
* mid-way, or a fork, has its full transcript counted). There is no predecessor
|
|
25
|
+
* and never an end-of-file skip for an operator session.
|
|
26
|
+
*
|
|
27
|
+
* An in-process re-mint (lease expiry, a refused ticket, transient backoff)
|
|
28
|
+
* never reaches this module: it lives for the whole process, so its in-memory
|
|
29
|
+
* state is untouched by anything the mint loop does.
|
|
30
|
+
*
|
|
31
|
+
* ## The one path that leaves the machine
|
|
32
|
+
*
|
|
33
|
+
* `transcriptPath` comes from the Claude Code hook input `transcript_path` (the
|
|
34
|
+
* plugin hands it to this process as {@link TRANSCRIPT_PATH_ENV}). It is sent
|
|
35
|
+
* once, in the first stats-bearing heartbeat, and recorded on the dispatch's
|
|
36
|
+
* `jsonl_path`; the strict stats parser refuses every other path field.
|
|
37
|
+
*/
|
|
38
|
+
import { basename, dirname, isAbsolute } from "node:path";
|
|
39
|
+
import { DispatchUsage, SessionLogWatcher, createActivityObserver, } from "@thehammer/danx-session-telemetry";
|
|
40
|
+
/** The environment variable the plugin passes the hook input's `transcript_path` in. */
|
|
41
|
+
export const TRANSCRIPT_PATH_ENV = "DANX_BRIDGE_TRANSCRIPT_PATH";
|
|
42
|
+
/**
|
|
43
|
+
* The session's transcript path from the environment, or `null` when the
|
|
44
|
+
* plugin supplied none (an older plugin, a hand run): the bridge then runs
|
|
45
|
+
* exactly as before, heartbeating its connection state only. A value that is
|
|
46
|
+
* present but is not an absolute `.jsonl` path is refused loudly — silently
|
|
47
|
+
* ignoring it would leave the session's usage uncounted with nothing to say why.
|
|
48
|
+
*/
|
|
49
|
+
export function transcriptPathFromEnv(env) {
|
|
50
|
+
const raw = env[TRANSCRIPT_PATH_ENV];
|
|
51
|
+
if (raw === undefined || raw.trim() === "")
|
|
52
|
+
return null;
|
|
53
|
+
const path = raw.trim();
|
|
54
|
+
if (!isAbsolute(path) || !path.endsWith(".jsonl")) {
|
|
55
|
+
throw new Error(`${TRANSCRIPT_PATH_ENV} must be an absolute path to a session .jsonl transcript (got "${path}")`);
|
|
56
|
+
}
|
|
57
|
+
return path;
|
|
58
|
+
}
|
|
59
|
+
const realWatcher = (options) => new SessionLogWatcher(options);
|
|
60
|
+
/** The collector's dispatch-id argument; the heartbeat is addressed by session, not dispatch. */
|
|
61
|
+
const OPERATOR_DISPATCH_REF = "operator-session";
|
|
62
|
+
export class OperatorSessionStats {
|
|
63
|
+
transcriptPath;
|
|
64
|
+
makeWatcher;
|
|
65
|
+
usage = new DispatchUsage();
|
|
66
|
+
watcher = null;
|
|
67
|
+
stopped = false;
|
|
68
|
+
constructor(transcriptPath, makeWatcher = realWatcher) {
|
|
69
|
+
this.transcriptPath = transcriptPath;
|
|
70
|
+
this.makeWatcher = makeWatcher;
|
|
71
|
+
}
|
|
72
|
+
/** Whether this process holds the stored stats (and is therefore collecting). */
|
|
73
|
+
collecting() {
|
|
74
|
+
return this.watcher !== null;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* One heartbeat tick. Before the process holds the stored stats: a plain
|
|
78
|
+
* connection-state heartbeat, whose answer starts collection. After: the
|
|
79
|
+
* collector's flush, carried by the heartbeat (a flush with nothing new
|
|
80
|
+
* sends one plain heartbeat instead, so an idle session still heartbeats).
|
|
81
|
+
* Returns whether a heartbeat landed, for the bridge's silent-failure count.
|
|
82
|
+
*/
|
|
83
|
+
async tick(send) {
|
|
84
|
+
if (this.watcher === null) {
|
|
85
|
+
const answer = await send(undefined);
|
|
86
|
+
if (answer.stats !== null)
|
|
87
|
+
this.begin(answer.stats);
|
|
88
|
+
return answer.landed;
|
|
89
|
+
}
|
|
90
|
+
let carried = false;
|
|
91
|
+
let landed = true;
|
|
92
|
+
await this.usage.flush({
|
|
93
|
+
applyStats: async (_dispatchId, payload) => {
|
|
94
|
+
carried = true;
|
|
95
|
+
const answer = await send(payload);
|
|
96
|
+
if (!answer.landed) {
|
|
97
|
+
landed = false;
|
|
98
|
+
throw new Error("stats heartbeat did not reach the dashboard");
|
|
99
|
+
}
|
|
100
|
+
if (answer.stats === null) {
|
|
101
|
+
throw new Error(`stats heartbeat answered HTTP ${answer.status} without stats`);
|
|
102
|
+
}
|
|
103
|
+
return answer.stats;
|
|
104
|
+
},
|
|
105
|
+
}, OPERATOR_DISPATCH_REF);
|
|
106
|
+
if (carried)
|
|
107
|
+
return landed;
|
|
108
|
+
return (await send(undefined)).landed;
|
|
109
|
+
}
|
|
110
|
+
/** Stop tailing. Idempotent; a stopped collector never starts again. */
|
|
111
|
+
stop() {
|
|
112
|
+
this.stopped = true;
|
|
113
|
+
this.watcher?.stop();
|
|
114
|
+
}
|
|
115
|
+
begin(stored) {
|
|
116
|
+
if (this.transcriptPath === null || this.stopped)
|
|
117
|
+
return;
|
|
118
|
+
const path = this.transcriptPath;
|
|
119
|
+
const activities = createActivityObserver({
|
|
120
|
+
recordActivities: (changes) => this.usage.recordActivities(changes),
|
|
121
|
+
});
|
|
122
|
+
const watcher = this.makeWatcher({
|
|
123
|
+
cwd: dirname(path),
|
|
124
|
+
sessionDir: dirname(path),
|
|
125
|
+
sessionId: basename(path, ".jsonl"),
|
|
126
|
+
startCursors: stored.sourceCursors,
|
|
127
|
+
onCursorPastEof: (info) => this.usage.recordPastEof(info),
|
|
128
|
+
onSourceDiscovered: (info) => activities.onSourceDiscovered(info),
|
|
129
|
+
});
|
|
130
|
+
this.usage.attachCursors(() => watcher.cursorState(), stored.sourceCursors);
|
|
131
|
+
this.usage.setTranscriptPath(path);
|
|
132
|
+
// Replays are credited (the ledger is idempotent) and marked so the
|
|
133
|
+
// collector's own estimate never counts them twice; the bridge has no
|
|
134
|
+
// side-effecting transcript subscriber to skip them for.
|
|
135
|
+
watcher.onEntry((entry, meta) => this.usage.observeEntry(entry, meta.replay));
|
|
136
|
+
watcher.onEntry(activities.onEntry);
|
|
137
|
+
this.watcher = watcher;
|
|
138
|
+
void watcher
|
|
139
|
+
.start()
|
|
140
|
+
.then(async () => {
|
|
141
|
+
// The watcher's discovery gives up after a bounded wait when the
|
|
142
|
+
// transcript file does not exist yet (Claude Code may create it lazily,
|
|
143
|
+
// on the session's first turn). Collecting "forever" from a watcher that
|
|
144
|
+
// adopted no file would read nothing, so drop it: the next heartbeat's
|
|
145
|
+
// answer starts collection again from the stored cursors.
|
|
146
|
+
if (this.watcher !== watcher || this.stopped)
|
|
147
|
+
return;
|
|
148
|
+
if ((await watcher.resolveSessionFileNow()) !== null)
|
|
149
|
+
return;
|
|
150
|
+
watcher.stop();
|
|
151
|
+
this.watcher = null;
|
|
152
|
+
this.usage = new DispatchUsage();
|
|
153
|
+
})
|
|
154
|
+
.catch((err) => {
|
|
155
|
+
process.stderr.write(`operator session stats: transcript watcher failed: ${err.message}\n`);
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
}
|
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.181",
|
|
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",
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
33
|
"@modelcontextprotocol/sdk": "1.29.0",
|
|
34
|
+
"@thehammer/danx-session-telemetry": "0.1.15",
|
|
34
35
|
"zod": "^3.25.76"
|
|
35
36
|
},
|
|
36
37
|
"devDependencies": {
|