@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 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
- return { sessionId, resumeIds: resumeIds };
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
- return outcome.kind === "answer";
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, connectionState === "streaming"
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.180",
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": {