@thehammer/danx-dashboard-mcp 0.1.271 → 0.1.274
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/README.md +25 -18
- package/dist/activity.js +1 -1
- package/dist/background-work.js +4 -4
- package/dist/credential.js +7 -8
- package/dist/digest-hold.js +65 -0
- package/dist/event-text.js +5 -5
- package/dist/handlers.js +4 -4
- package/dist/heartbeat-reporter.js +121 -0
- package/dist/index.js +95 -39
- package/dist/json-body.js +3 -3
- package/dist/listen.js +19 -13
- package/dist/one-shot-session-request.js +7 -7
- package/dist/plan-events-relay.js +425 -0
- package/dist/plan-events-tool.js +9 -0
- package/dist/restart-notice.js +3 -3
- package/dist/session-access.js +3 -3
- package/dist/session-connection.js +16 -17
- package/dist/session-key.js +2 -2
- package/dist/session-options.js +58 -0
- package/dist/session-stats.js +10 -19
- package/dist/session-stream.js +358 -0
- package/dist/session-title.js +5 -5
- package/dist/shutdown.js +12 -0
- package/dist/stop-fixes.js +40 -0
- package/dist/subagents-live.js +2 -2
- package/dist/tool-access.js +7 -1
- package/dist/tool-surface.js +5 -7
- package/package.json +3 -2
- package/dist/bridge.js +0 -1125
package/dist/session-stats.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* DX-2954 — an operator session's live stats, collected by the
|
|
2
|
+
* DX-2954 — an operator session's live stats, collected by the relay and sent
|
|
3
3
|
* in its DX-2952 heartbeat.
|
|
4
4
|
*
|
|
5
5
|
* Workers and operator sessions report the same stats the same way (epic
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
*
|
|
15
15
|
* ## Process start: the first heartbeat's answer, never the mint (round 22)
|
|
16
16
|
*
|
|
17
|
-
* A restarted
|
|
17
|
+
* A restarted session's relay (a resume or a restarted MCP server) may fail its mint and
|
|
18
18
|
* still heartbeat (DX-2952), so the heartbeat is the one call that always
|
|
19
19
|
* answers. Until this process holds that answer it sends ONLY its connection
|
|
20
20
|
* state: no ledger entries, no cursors — the transcript is not even opened. The
|
|
@@ -37,22 +37,13 @@
|
|
|
37
37
|
*/
|
|
38
38
|
import { basename, dirname, isAbsolute } from "node:path";
|
|
39
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
40
|
/**
|
|
43
|
-
*
|
|
44
|
-
*
|
|
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.
|
|
41
|
+
* DX-4233 - the one rule for a transcript path, wherever it comes from (the `transcript_path` argument of
|
|
42
|
+
* `plan_events_wait`): an absolute `.jsonl` path, or a loud refusal naming `source`.
|
|
48
43
|
*/
|
|
49
|
-
export function
|
|
50
|
-
const raw = env[TRANSCRIPT_PATH_ENV];
|
|
51
|
-
if (raw === undefined || raw.trim() === "")
|
|
52
|
-
return null;
|
|
53
|
-
const path = raw.trim();
|
|
44
|
+
export function checkedTranscriptPath(path, source) {
|
|
54
45
|
if (!isAbsolute(path) || !path.endsWith(".jsonl")) {
|
|
55
|
-
throw new Error(`${
|
|
46
|
+
throw new Error(`${source} must be an absolute path to a session .jsonl transcript (got "${path}")`);
|
|
56
47
|
}
|
|
57
48
|
return path;
|
|
58
49
|
}
|
|
@@ -60,7 +51,7 @@ const realWatcher = (options) => new SessionLogWatcher(options);
|
|
|
60
51
|
/** The collector's dispatch-id argument; the heartbeat is addressed by session, not dispatch. */
|
|
61
52
|
const OPERATOR_DISPATCH_REF = "operator-session";
|
|
62
53
|
/**
|
|
63
|
-
* DX-3962 - the
|
|
54
|
+
* DX-3962 - the relay's collector. `"operator-bridge"` (the telemetry package's published mode value, kept under its old name) is the ONE mode that credits a sub-agent's replies to the card
|
|
64
55
|
* it claimed (from its own transcript's pickup); a worker builds its collector with attribution off.
|
|
65
56
|
*/
|
|
66
57
|
function newCollector() {
|
|
@@ -85,7 +76,7 @@ export class OperatorSessionStats {
|
|
|
85
76
|
* connection-state heartbeat, whose answer starts collection. After: the
|
|
86
77
|
* collector's flush, carried by the heartbeat (a flush with nothing new
|
|
87
78
|
* sends one plain heartbeat instead, so an idle session still heartbeats).
|
|
88
|
-
* Returns whether a heartbeat landed, for the
|
|
79
|
+
* Returns whether a heartbeat landed, for the relay's silent-failure count.
|
|
89
80
|
*/
|
|
90
81
|
async tick(send) {
|
|
91
82
|
if (this.watcher === null) {
|
|
@@ -120,7 +111,7 @@ export class OperatorSessionStats {
|
|
|
120
111
|
this.watcher?.stop();
|
|
121
112
|
}
|
|
122
113
|
begin(stored) {
|
|
123
|
-
// `this.watcher !== null`: the
|
|
114
|
+
// `this.watcher !== null`: the relay fires `tick` without awaiting it, so
|
|
124
115
|
// two ticks can both be waiting on a pre-collection heartbeat. The first
|
|
125
116
|
// answer starts collection; a second must not start (and leak) another
|
|
126
117
|
// tailer over the same transcript.
|
|
@@ -141,7 +132,7 @@ export class OperatorSessionStats {
|
|
|
141
132
|
this.usage.attachCursors(() => watcher.cursorState(), stored.sourceCursors);
|
|
142
133
|
this.usage.setTranscriptPath(path);
|
|
143
134
|
// Replays are credited (the ledger is idempotent) and marked so the
|
|
144
|
-
// collector's own estimate never counts them twice; the
|
|
135
|
+
// collector's own estimate never counts them twice; the relay has no
|
|
145
136
|
// side-effecting transcript subscriber to skip them for.
|
|
146
137
|
watcher.onUsageLine((raw, meta) => this.usage.observeRawLine(raw, meta));
|
|
147
138
|
// DX-4498 — the raw line feed: the brief and Claude Code's task notifications are not entries.
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DX-4233 - the dashboard HTTP contract of a session's event stream, spoken by the in-process relay behind
|
|
3
|
+
* `plan_events_wait` (`plan-events-relay.ts`).
|
|
4
|
+
*
|
|
5
|
+
* One authenticated request with a timeout, the listener-ticket mint, the best-effort plan inventory read and the 15 s
|
|
6
|
+
* heartbeat. Everything here is about talking to the dashboard; WHEN to talk (the mint/stream loop, the heartbeat schedule,
|
|
7
|
+
* what a stop means for the session) belongs to the relay.
|
|
8
|
+
*/
|
|
9
|
+
import { errorCodeOf } from "./json-body.js";
|
|
10
|
+
import { keyRevocationOf, keyRevokedHalt } from "./key-revoked-halt.js";
|
|
11
|
+
import { oneLine } from "./one-line.js";
|
|
12
|
+
import { SESSION_ID_HEADER } from "./session-options.js";
|
|
13
|
+
import { isClaimState } from "@thehammer/danx-session-telemetry";
|
|
14
|
+
/**
|
|
15
|
+
* DX-3912 — the longest heartbeat `reason` the relay sends: one under the dashboard's own
|
|
16
|
+
* limit (`HEARTBEAT_REASON_MAX_CHARS` = 200, `src/issues/db/plan-session-heartbeat.ts`),
|
|
17
|
+
* because `oneLine` appends a "…" to a truncated string and a longer reason refuses the whole
|
|
18
|
+
* heartbeat. `plan-events-wait.test.ts` asserts the cap.
|
|
19
|
+
*/
|
|
20
|
+
const HEARTBEAT_REASON_MAX_CODE_POINTS = 199;
|
|
21
|
+
export const STREAM_TICKET_PATH = "/api/plan-sessions/me/stream-ticket";
|
|
22
|
+
// DX-2970 — the bare (no `fields=`) cheap-scalars read, used ONLY to report the
|
|
23
|
+
// connected plan's real `boards`/`cardCount` on the `ready` record (see
|
|
24
|
+
// `fetchPlanInventory`). This is NOT a revival of DX-2920's deleted
|
|
25
|
+
// `verifyPlanBoardsReadable` gate — it never refuses a mint, never blocks
|
|
26
|
+
// streaming, and is best-effort (one attempt, no retry/backoff).
|
|
27
|
+
export const PLAN_MINE_PATH = "/api/plans/mine";
|
|
28
|
+
// DX-3028 (AC1) — `POST /api/plan-sessions/me/heartbeat` (DX-2952's server
|
|
29
|
+
// route, `plan-sessions-routes.ts#handleHeartbeat`). Every 15s this process
|
|
30
|
+
// reports its truthful streaming state; see `sendHeartbeat` below.
|
|
31
|
+
export const HEARTBEAT_PATH = "/api/plan-sessions/me/heartbeat";
|
|
32
|
+
/** How long one dashboard request may take before it counts as a transient failure. */
|
|
33
|
+
export const REQUEST_TIMEOUT_MS = 10_000;
|
|
34
|
+
/** A freshly minted ticket refused this many times in a row is a terminal outcome, not a loop. */
|
|
35
|
+
export const MAX_REFUSED_TICKETS_IN_A_ROW = 2;
|
|
36
|
+
/** DX-3028 (AC1) — the dashboard's own heartbeat cadence; see the card's AC1. */
|
|
37
|
+
export const HEARTBEAT_INTERVAL_MS = 15_000;
|
|
38
|
+
/**
|
|
39
|
+
* DX-3099 — how many consecutive 15s heartbeat POSTs may fail to LAND
|
|
40
|
+
* (network error, timeout, 5xx, 408/429 — never an ordinary 401/403 answer,
|
|
41
|
+
* which proves the pipe itself still works) while this process still
|
|
42
|
+
* believes it is `"streaming"`, before that counts as evidence the current
|
|
43
|
+
* stream connection is dead without having errored — the exact "alive but
|
|
44
|
+
* not reporting" signature the investigation found: the SSE connection can
|
|
45
|
+
* read as established at the OS level and never trip `listen.ts`'s own
|
|
46
|
+
* read-idle timeout, while every outbound heartbeat silently never lands
|
|
47
|
+
* (the send call's own result used to be discarded entirely — see the
|
|
48
|
+
* card). `3 * HEARTBEAT_INTERVAL_MS` = 45s, deliberately UNDER
|
|
49
|
+
* `SESSION_LIVENESS_WINDOW_MS` (60s, `plan-session-heartbeat.ts`) so this
|
|
50
|
+
* process self-heals before, or right around, the moment the dashboard
|
|
51
|
+
* would itself start reporting the session `unattached` — never a shorter
|
|
52
|
+
* freshness window (that constant is untouched) and never a blind
|
|
53
|
+
* "restart more eagerly" (no external watchdog, no timer-driven kill): this
|
|
54
|
+
* fires only on repeated, OBSERVED delivery failure, self-diagnosed by the
|
|
55
|
+
* SAME process that is already streaming. Card: DX-3099 (AC3 forbids both
|
|
56
|
+
* of the obvious wrong fixes this constant is not).
|
|
57
|
+
*/
|
|
58
|
+
export const HEARTBEAT_SILENT_FAILURE_THRESHOLD = 3;
|
|
59
|
+
const TRANSIENT_CLIENT_STATUSES = new Set([408, 429]);
|
|
60
|
+
/**
|
|
61
|
+
* One authenticated request, with a timeout, classifying the failures that are
|
|
62
|
+
* about THIS MOMENT (network, timeout, 408/429, 5xx) as transient. Shared by
|
|
63
|
+
* every request this module makes so they can never disagree about which
|
|
64
|
+
* failures are worth retrying.
|
|
65
|
+
*/
|
|
66
|
+
async function request(options, deps, what, url, init = { method: "GET" }) {
|
|
67
|
+
const controller = new AbortController();
|
|
68
|
+
const timer = setTimeout(() => controller.abort(), deps.requestTimeoutMs);
|
|
69
|
+
const abort = () => controller.abort();
|
|
70
|
+
if (deps.signal?.aborted === true)
|
|
71
|
+
abort();
|
|
72
|
+
deps.signal?.addEventListener("abort", abort, { once: true });
|
|
73
|
+
try {
|
|
74
|
+
let status;
|
|
75
|
+
let text;
|
|
76
|
+
try {
|
|
77
|
+
const headers = {
|
|
78
|
+
Authorization: `Bearer ${options.token}`,
|
|
79
|
+
Accept: "application/json",
|
|
80
|
+
[SESSION_ID_HEADER]: options.sessionId,
|
|
81
|
+
};
|
|
82
|
+
if (init.body !== undefined)
|
|
83
|
+
headers["Content-Type"] = "application/json";
|
|
84
|
+
const response = await deps.fetch(url, {
|
|
85
|
+
method: init.method,
|
|
86
|
+
headers,
|
|
87
|
+
body: init.body,
|
|
88
|
+
signal: controller.signal,
|
|
89
|
+
});
|
|
90
|
+
status = response.status;
|
|
91
|
+
text = await response.text();
|
|
92
|
+
}
|
|
93
|
+
catch (err) {
|
|
94
|
+
return {
|
|
95
|
+
kind: "transient",
|
|
96
|
+
detail: controller.signal.aborted
|
|
97
|
+
? `${what} timed out after ${deps.requestTimeoutMs} ms`
|
|
98
|
+
: `${what} failed: ${err.message}`,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
if (status >= 500 || TRANSIENT_CLIENT_STATUSES.has(status)) {
|
|
102
|
+
return { kind: "transient", detail: `${what} HTTP ${status}` };
|
|
103
|
+
}
|
|
104
|
+
let body = null;
|
|
105
|
+
try {
|
|
106
|
+
body = text === "" ? null : JSON.parse(text);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
body = null;
|
|
110
|
+
}
|
|
111
|
+
return { kind: "answer", status, text, body };
|
|
112
|
+
}
|
|
113
|
+
finally {
|
|
114
|
+
clearTimeout(timer);
|
|
115
|
+
deps.signal?.removeEventListener("abort", abort);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/** What is wrong with a 2xx mint body, or `null` when it is a ticket. Never echoes the body. */
|
|
119
|
+
function badTicketReason(body) {
|
|
120
|
+
if (typeof body !== "object" || body === null)
|
|
121
|
+
return "the body is not a JSON object";
|
|
122
|
+
const b = body;
|
|
123
|
+
if (typeof b.ticket !== "string" || b.ticket === "")
|
|
124
|
+
return "ticket is not a non-empty string";
|
|
125
|
+
if (typeof b.streamPath !== "string" || !b.streamPath.startsWith("/"))
|
|
126
|
+
return "streamPath is not a path";
|
|
127
|
+
if (typeof b.leaseMs !== "number" || !Number.isSafeInteger(b.leaseMs) || b.leaseMs <= 0) {
|
|
128
|
+
return "leaseMs is not a positive integer";
|
|
129
|
+
}
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
/** Mint this session's listener ticket, classifying every way that can end. */
|
|
133
|
+
export async function mintTicket(options, deps) {
|
|
134
|
+
const outcome = await request(options, deps, "ticket mint", `${options.dashboardUrl}${STREAM_TICKET_PATH}`, {
|
|
135
|
+
method: "POST",
|
|
136
|
+
body: "{}",
|
|
137
|
+
});
|
|
138
|
+
if (outcome.kind === "transient")
|
|
139
|
+
return outcome;
|
|
140
|
+
const { status, text, body } = outcome;
|
|
141
|
+
if (status === 409 && errorCodeOf(body) === "session_not_connected") {
|
|
142
|
+
return { kind: "terminal", reason: "not_connected", detail: "the session is not connected to a plan" };
|
|
143
|
+
}
|
|
144
|
+
// DX-3028 (AC2) — matched BEFORE the generic mint_refused fallback below,
|
|
145
|
+
// which every other 409 falls into: this session id belongs to a worker
|
|
146
|
+
// dispatch (`SessionIsWorkerError`, the same shape the heartbeat route
|
|
147
|
+
// already gives it, DX-2952).
|
|
148
|
+
if (status === 409 && errorCodeOf(body) === "session_is_worker") {
|
|
149
|
+
return { kind: "terminal", reason: "session_is_worker", detail: "this session id belongs to a worker dispatch, not an operator plan session" };
|
|
150
|
+
}
|
|
151
|
+
// DX-4418 - a key a person revoked is not a lapsed one: the detail IS the stop halt (who revoked it, what to do), so the record
|
|
152
|
+
// the plugin relays to the session says it in full.
|
|
153
|
+
const revocation = keyRevocationOf({ ok: false, status, body });
|
|
154
|
+
if (revocation !== null) {
|
|
155
|
+
return { kind: "terminal", reason: "key_revoked", detail: keyRevokedHalt(revocation, false) };
|
|
156
|
+
}
|
|
157
|
+
if (status === 401 || status === 403) {
|
|
158
|
+
return { kind: "terminal", reason: "unauthorized", detail: `ticket mint HTTP ${status} ${oneLine(text, 300)}` };
|
|
159
|
+
}
|
|
160
|
+
if (status < 200 || status >= 300) {
|
|
161
|
+
return { kind: "terminal", reason: "mint_refused", detail: `ticket mint HTTP ${status} ${oneLine(text, 300)}` };
|
|
162
|
+
}
|
|
163
|
+
const bad = badTicketReason(body);
|
|
164
|
+
if (bad !== null) {
|
|
165
|
+
return { kind: "terminal", reason: "mint_bad_response", detail: `ticket mint HTTP ${status}: ${bad}` };
|
|
166
|
+
}
|
|
167
|
+
const ticket = body;
|
|
168
|
+
return {
|
|
169
|
+
kind: "ticket",
|
|
170
|
+
ticket: ticket.ticket,
|
|
171
|
+
streamUrl: `${options.dashboardUrl}${ticket.streamPath}`,
|
|
172
|
+
leaseMs: ticket.leaseMs,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* What is wrong with a 2xx bare `/api/plans/mine` body, or `null` when it carries a usable inventory.
|
|
177
|
+
*
|
|
178
|
+
* DX-3912 — the field names here are the plan resource's WIRE names (`boards`, `card_count`,
|
|
179
|
+
* snake_case since DX-3428), the same ones `src/resources/plan/plan-resource.ts` emits; the
|
|
180
|
+
* contract test `src/issues/__tests__/plan-inventory-relay-contract.test.ts` runs this parser
|
|
181
|
+
* on the server's own response, so a rename there fails that test instead of silently
|
|
182
|
+
* degrading every listener. A missing field lists the keys the body DID carry, so a rename is
|
|
183
|
+
* named in the first message anyone reads.
|
|
184
|
+
*/
|
|
185
|
+
function badInventoryReason(body) {
|
|
186
|
+
if (!isRecord(body))
|
|
187
|
+
return "the body is not a JSON object";
|
|
188
|
+
if (!("boards" in body))
|
|
189
|
+
return `boards is missing (response keys: ${Object.keys(body).join(", ")})`;
|
|
190
|
+
if (!Array.isArray(body.boards) || body.boards.some((x) => typeof x !== "string")) {
|
|
191
|
+
return "boards is not an array of strings";
|
|
192
|
+
}
|
|
193
|
+
if (!("card_count" in body))
|
|
194
|
+
return `card_count is missing (response keys: ${Object.keys(body).join(", ")})`;
|
|
195
|
+
const count = body.card_count;
|
|
196
|
+
if (typeof count !== "number" || !Number.isSafeInteger(count) || count < 0) {
|
|
197
|
+
return `card_count is not a non-negative integer (got ${typeof count === "number" ? count : typeof count})`;
|
|
198
|
+
}
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* DX-2970 — a single BEST-EFFORT read of the connected plan's real
|
|
203
|
+
* `boards`/`cardCount`, purely for accurate `ready`-record reporting. NEVER a
|
|
204
|
+
* gate: unlike DX-2920's deleted `verifyPlanBoardsReadable`, a failure here
|
|
205
|
+
* (network, timeout, non-2xx, unusable body) is NOT retried and NEVER blocks
|
|
206
|
+
* or delays streaming — the relay reports `degraded: true` with `boards`/
|
|
207
|
+
* `cardCount` both `null` and proceeds exactly as it would have before this
|
|
208
|
+
* read existed. The bare (no `fields=`) call is deliberately the cheapest
|
|
209
|
+
* read the route offers — the plan's own "cheap scalars," always present on
|
|
210
|
+
* every `GET /api/plans/mine` response regardless of `fields`.
|
|
211
|
+
*/
|
|
212
|
+
export async function fetchPlanInventory(options, deps) {
|
|
213
|
+
const outcome = await request(options, deps, "plan inventory read", `${options.dashboardUrl}${PLAN_MINE_PATH}`);
|
|
214
|
+
if (outcome.kind === "transient")
|
|
215
|
+
return { kind: "unavailable", detail: outcome.detail };
|
|
216
|
+
const { status, text, body } = outcome;
|
|
217
|
+
if (status < 200 || status >= 300) {
|
|
218
|
+
return { kind: "unavailable", detail: `plan inventory read HTTP ${status} ${oneLine(text, 300)}` };
|
|
219
|
+
}
|
|
220
|
+
const bad = badInventoryReason(body);
|
|
221
|
+
if (bad !== null) {
|
|
222
|
+
return { kind: "unavailable", detail: `plan inventory read HTTP ${status}: ${bad}` };
|
|
223
|
+
}
|
|
224
|
+
const b = body;
|
|
225
|
+
return { kind: "ok", boards: b.boards, cardCount: b.card_count };
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* DX-2920 — a stream admission refusal (`listen.ts`'s STRUCTURED `refused`
|
|
229
|
+
* outcome — `{status, errorCode, body}`, parsed once in `connectOnce`) whose
|
|
230
|
+
* shape matches one the mint route already gives its OWN name to: `409
|
|
231
|
+
* session_not_connected`, the SAME shape the mint route uses for "no plan
|
|
232
|
+
* connected" (`plan-session-stream.ts`, DX-2863 review finding M2). A session
|
|
233
|
+
* with no plan connected is the identical terminal state whether the
|
|
234
|
+
* dashboard says so at mint or at the stream's own admission check, and the
|
|
235
|
+
* session should see ONE remedy either way.
|
|
236
|
+
*
|
|
237
|
+
* Classifies by STATUS + ERROR CODE, never by re-deriving them from the
|
|
238
|
+
* flattened `detail` string — the second-source-of-truth shape this card's fix
|
|
239
|
+
* round closed (the regex-based `isSessionNotConnectedRefusal` it replaced).
|
|
240
|
+
*/
|
|
241
|
+
export function classifyAdmissionRefusal(refusal) {
|
|
242
|
+
if (refusal === undefined)
|
|
243
|
+
return null;
|
|
244
|
+
if (refusal.status === 409 && refusal.errorCode === "session_not_connected") {
|
|
245
|
+
return { reason: "not_connected", detail: "the session is not connected to a plan" };
|
|
246
|
+
}
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* The one real `scheduleInterval`: a `setInterval` and its canceler, for the relay's heartbeat. Unref'd: a heartbeat never keeps
|
|
251
|
+
* the MCP server alive after its session ended.
|
|
252
|
+
*/
|
|
253
|
+
export function realScheduleInterval(tick, ms) {
|
|
254
|
+
const timer = setInterval(tick, ms);
|
|
255
|
+
timer.unref();
|
|
256
|
+
return { stop: () => clearInterval(timer) };
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* The one real sleep of the relay's mint / stream loop: unref'd, so a backoff never keeps the MCP server alive after its session
|
|
260
|
+
* ended, and cut short by `signal` (the run was ended), so a suspended relay does not leave a timer behind.
|
|
261
|
+
*/
|
|
262
|
+
export function realSleep(ms, signal) {
|
|
263
|
+
return new Promise((resolve) => {
|
|
264
|
+
if (signal?.aborted)
|
|
265
|
+
return resolve();
|
|
266
|
+
const done = () => {
|
|
267
|
+
clearTimeout(timer);
|
|
268
|
+
signal?.removeEventListener("abort", done);
|
|
269
|
+
resolve();
|
|
270
|
+
};
|
|
271
|
+
const timer = setTimeout(done, ms);
|
|
272
|
+
timer.unref();
|
|
273
|
+
signal?.addEventListener("abort", done, { once: true });
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* The truthful `connection` for a heartbeat: `streaming` only while the SSE connection is open (with whether the plan
|
|
278
|
+
* inventory read worked), `stopped` with the reason the stream ended, otherwise `reconnecting`.
|
|
279
|
+
*/
|
|
280
|
+
export function heartbeatConnection(state, stoppedReason, inventoryError) {
|
|
281
|
+
if (state === "streaming") {
|
|
282
|
+
return inventoryError === null
|
|
283
|
+
? { state, inventory: "ok" }
|
|
284
|
+
: { state, inventory: "unavailable", reason: oneLine(inventoryError, HEARTBEAT_REASON_MAX_CODE_POINTS) };
|
|
285
|
+
}
|
|
286
|
+
return state === "stopped" ? { state, reason: stoppedReason ?? undefined } : { state };
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* One heartbeat POST. Never throws on a failure of the request itself and NEVER stops the relay on one
|
|
290
|
+
* (the one throw is a contract breach: a `key_revoked` answer that does not say who revoked the key, DX-4418; `heartbeatTick`'s
|
|
291
|
+
* `.catch` keeps it from taking the relay down) (card AC1 — "a failed heartbeat never stops the relay"): `request()`
|
|
292
|
+
* itself already classifies every network/timeout/5xx failure as
|
|
293
|
+
* `{kind:"transient"}` rather than throwing, and a 401/403 (a revoked
|
|
294
|
+
* credential) simply comes back as an ordinary non-2xx answer this function
|
|
295
|
+
* discards — there is nothing meaningful to retry a heartbeat POST for, and
|
|
296
|
+
* the NEXT tick will just try again. A heartbeat refused with 401/403 cannot
|
|
297
|
+
* report itself, so `computeListenerHealth`'s staleness fallback
|
|
298
|
+
* (`heartbeatIsFresh`) is what eventually reflects it: the row's
|
|
299
|
+
* `last_heartbeat_at` stops advancing, and once it falls outside
|
|
300
|
+
* `SESSION_LIVENESS_WINDOW_MS` the badge reads `unattached`, NOT
|
|
301
|
+
* `credential_mismatch` — `credential_mismatch` is computed only from a
|
|
302
|
+
* heartbeat that DID land with `state:"streaming"` and a live ticket whose
|
|
303
|
+
* issuer hash differs from the caller's; it has no way to represent "the
|
|
304
|
+
* heartbeat call itself was rejected". This gap is real and is recorded on
|
|
305
|
+
* the card rather than silently papered over.
|
|
306
|
+
*/
|
|
307
|
+
export async function sendHeartbeat(options, deps, connection, stats,
|
|
308
|
+
// DX-4461 - the session's current Claude title, read from its transcript (`session-title.ts`).
|
|
309
|
+
title) {
|
|
310
|
+
const body = { state: connection.state };
|
|
311
|
+
if (connection.reason !== undefined)
|
|
312
|
+
body.reason = connection.reason;
|
|
313
|
+
if (connection.inventory !== undefined)
|
|
314
|
+
body.inventory = connection.inventory;
|
|
315
|
+
const outcome = await request(options, deps, "heartbeat", `${options.dashboardUrl}${HEARTBEAT_PATH}`, {
|
|
316
|
+
method: "POST",
|
|
317
|
+
body: JSON.stringify({ connection: body, ...(stats !== undefined ? { stats } : {}), ...(title !== undefined ? { title } : {}) }),
|
|
318
|
+
});
|
|
319
|
+
// DX-3099 — this is now the ONE place a heartbeat's own outcome is
|
|
320
|
+
// inspected. `request()` still never throws, and a 401/403 is still an
|
|
321
|
+
// ordinary answer this function does not retry for (the credential is
|
|
322
|
+
// whatever it is; there is nothing to redo about THIS request) — only a
|
|
323
|
+
// "transient" outcome (network error, timeout, 5xx, 408/429) means the
|
|
324
|
+
// request never landed at all, which is exactly the signal the silent-
|
|
325
|
+
// failure counter in the relay below needs. Still never throws.
|
|
326
|
+
if (outcome.kind !== "answer")
|
|
327
|
+
return { landed: false, status: null, stats: null, revocation: null, title: null };
|
|
328
|
+
// DX-2954 — the answer's `stats` (the operator dispatch's stored or
|
|
329
|
+
// just-written totals and cursors) feed the session's stats collector.
|
|
330
|
+
const ok = outcome.status >= 200 && outcome.status < 300;
|
|
331
|
+
// A heartbeat whose carried stats were NOT applied (`statsError`) still
|
|
332
|
+
// landed; its `stats` are the stored values, so the collector must re-queue
|
|
333
|
+
// rather than treat them as the answer to its write.
|
|
334
|
+
const applied = ok && isRecord(outcome.body) && outcome.body.statsError === undefined;
|
|
335
|
+
const statsBody = applied && isRecord(outcome.body) && isRecord(outcome.body.stats) ? outcome.body.stats : null;
|
|
336
|
+
// DX-3962: an answer from a server that predates attribution stores cursors with no `claims`. Treated exactly like
|
|
337
|
+
// an answer with no stats (the older-server path): the collector never starts, the heartbeat still lands, and
|
|
338
|
+
// nothing is recorded until the server is deployed. Handing it on would crash the collector at attach.
|
|
339
|
+
const answered = statsBody !== null &&
|
|
340
|
+
isRecord(statsBody.sourceCursors) &&
|
|
341
|
+
isRecord(statsBody.totals) &&
|
|
342
|
+
Object.values(statsBody.sourceCursors).every((cursor) => isRecord(cursor) && isClaimState(cursor.claims))
|
|
343
|
+
? statsBody
|
|
344
|
+
: null;
|
|
345
|
+
// DX-4418 - the dashboard refuses the beat of a session whose key a person revoked, and says so.
|
|
346
|
+
// DX-4461 - the dashboard's stored title for this session; an answer without one is an older dashboard's.
|
|
347
|
+
const storedTitle = ok && isRecord(outcome.body) && typeof outcome.body.title === "string" ? outcome.body.title : null;
|
|
348
|
+
return {
|
|
349
|
+
landed: true,
|
|
350
|
+
status: outcome.status,
|
|
351
|
+
stats: answered,
|
|
352
|
+
revocation: keyRevocationOf({ ok: false, status: outcome.status, body: outcome.body }),
|
|
353
|
+
title: storedTitle,
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
function isRecord(value) {
|
|
357
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
358
|
+
}
|
package/dist/session-title.js
CHANGED
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
* again. DX-2816 found no env var, hook input or MCP-reachable signal carrying the title, but Claude Code writes every
|
|
7
7
|
* title change into the session's transcript as a `{"type":"custom-title","customTitle":"..."}` line the moment the
|
|
8
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).
|
|
10
|
-
* `transcript_path` as `
|
|
11
|
-
* heartbeat and reports it when it differs from the title the dashboard answered with (
|
|
9
|
+
* renamed one, no turn in between). the relay already holds the transcript path (the plugin passes the hook input's
|
|
10
|
+
* `transcript_path` as the `transcript_path` argument, `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 (the relay); the dashboard
|
|
12
12
|
* stores it (`plan-sessions-routes.ts`).
|
|
13
13
|
*
|
|
14
14
|
* INCREMENTAL, NEVER A RE-READ. The reader remembers how far it has scanned (always a line boundary) and the title it
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* bounded to its last {@link TRANSCRIPT_TITLE_MAX_SCAN_BYTES}: Claude Code repeats the record on every turn, so the
|
|
17
17
|
* latest one is always near the end, and a transcript with none in that window has nothing to report.
|
|
18
18
|
*
|
|
19
|
-
* It reads synchronously on purpose: the
|
|
19
|
+
* It reads synchronously on purpose: the relay's heartbeat tick is synchronous up to its first request, and a read
|
|
20
20
|
* of a few appended kilobytes every 15 seconds costs less than the plumbing an async read would need.
|
|
21
21
|
*/
|
|
22
22
|
import { closeSync, fstatSync, openSync, readSync } from "node:fs";
|
|
@@ -111,7 +111,7 @@ function parseTitleRecord(line, path) {
|
|
|
111
111
|
}
|
|
112
112
|
/**
|
|
113
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
|
|
114
|
+
* trimmed again: the dashboard stores the trimmed title, so an untrimmed one would never equal it and the relay would
|
|
115
115
|
* resend it on every heartbeat.
|
|
116
116
|
*/
|
|
117
117
|
function boundTitle(title) {
|
package/dist/shutdown.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DX-4523 / DX-4233 - what the server lets go of when its session ends: the permission watch's open stream and the plan event
|
|
3
|
+
* relay (its pending call, stream, heartbeat and tailer), so nothing keeps the process alive after the client is gone.
|
|
4
|
+
*
|
|
5
|
+
* Two triggers, both needed: the transport's `onclose` (an orderly close of the connection) and the end of stdin. The SDK's stdio
|
|
6
|
+
* transport does not watch stdin for its end (only a transport `close()` reaches `onclose`), so a client that simply exits
|
|
7
|
+
* would leave the process running without the second one.
|
|
8
|
+
*/
|
|
9
|
+
export function stopWhenSessionEnds(wiring) {
|
|
10
|
+
wiring.server.onclose = wiring.stop;
|
|
11
|
+
wiring.stdin.once("end", wiring.stop);
|
|
12
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DX-4233 - the remedy a session is shown for each way its dashboard event stream can stop, in ONE place.
|
|
3
|
+
*
|
|
4
|
+
* The client that ends in these texts is the in-process relay behind `plan_events_wait` (`plan-events-relay.ts`); the one-shot
|
|
5
|
+
* subcommands use `fixForStop` for a start they cannot make. Pure data and one function with no imports.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* The remedy per terminal reason. ONE map, so the message a session is shown and the message a log carries can never drift.
|
|
9
|
+
* `KnownStopReason` is only a SUBSET of `ListenStopReason` (which also has `unknown`, for a newer dashboard's wire reason this
|
|
10
|
+
* build predates), so `fixForStop` accepts a plain string and falls back to `DEFAULT_STOP_FIX`.
|
|
11
|
+
*
|
|
12
|
+
* DX-4233 - the texts name the session's listener, never a process: with the relay inside the session's own MCP server there
|
|
13
|
+
* is no separate process to restart.
|
|
14
|
+
*/
|
|
15
|
+
export const STOP_FIXES = {
|
|
16
|
+
no_connection_record: "call plan_connect again in this session — its danx-dashboard MCP server records the connection a hook command needs " +
|
|
17
|
+
"(a server older than the one that introduced the record writes none)",
|
|
18
|
+
credential_unavailable: "call plan_connect again to request access: this session's key file is missing or unreadable",
|
|
19
|
+
credential_mismatch: "restart this Claude Code session so its MCP server and the plugin's hook commands pick up the same credential, then call plan_connect again",
|
|
20
|
+
not_connected: "call plan_connect to connect this session to a plan",
|
|
21
|
+
plan_archived: "the plan was archived or deleted, so this session was disconnected; ask the operator to restore it, then call plan_connect again",
|
|
22
|
+
unauthorized: "the dashboard no longer accepts this session's key (it lapsed after a day unused): call plan_connect again to request access",
|
|
23
|
+
key_revoked: "stop all work now, commit your work in progress and end this session: the person who owns this session's access revoked it; " +
|
|
24
|
+
"do not call plan_connect or request access again",
|
|
25
|
+
mint_refused: "check that this session is connected to a plan on the dashboard its MCP server points at, then call plan_connect again",
|
|
26
|
+
mint_bad_response: "check that DANXBOT_DASHBOARD_URL for this session's MCP server points at a danxbot dashboard",
|
|
27
|
+
// A newer ticket exists (superseded) or another connection is already using this one (replaced): THIS listener is ending
|
|
28
|
+
// because the session is already served elsewhere, not because anything is broken.
|
|
29
|
+
superseded: "no action needed — a newer ticket was minted for this session, and a fresh listener is already using it",
|
|
30
|
+
replaced: "no action needed — another connection is already using this session's ticket; call plan_connect again only if that was unexpected",
|
|
31
|
+
revoked: "call plan_connect again to mint a new listener ticket for this session",
|
|
32
|
+
refused: "call plan_connect again to mint a new listener ticket for this session",
|
|
33
|
+
// DX-3028 - this session id belongs to a worker dispatch; there is no listener ticket a worker session can ever use.
|
|
34
|
+
session_is_worker: "this session id belongs to a worker dispatch; start a new session",
|
|
35
|
+
relay_failed: "the event relay inside this session's danx-dashboard MCP server failed (the detail says why); the next call tries again, and if it keeps failing restart this Claude Code session",
|
|
36
|
+
};
|
|
37
|
+
export const DEFAULT_STOP_FIX = "call plan_connect again in this session to restart the listener";
|
|
38
|
+
export function fixForStop(reason) {
|
|
39
|
+
return STOP_FIXES[reason] ?? DEFAULT_STOP_FIX;
|
|
40
|
+
}
|
package/dist/subagents-live.js
CHANGED
|
@@ -3,12 +3,12 @@
|
|
|
3
3
|
* sub-agents for the danxbot plugin's plan pane (DX-4508).
|
|
4
4
|
*
|
|
5
5
|
* WHY IT EXISTS. The pane's dashboard read (`GET /api/plan-sessions/:id/subagents`) lags the transcript by about 80s
|
|
6
|
-
* (the
|
|
6
|
+
* (the relay's 5s transcript poll, its 15s heartbeat flush, the pane's 60s read). A hooks module cannot tail the files
|
|
7
7
|
* itself (`$.fs.read` has no offset and refuses files over 4 MiB), but it can spawn a long-lived child and read its
|
|
8
8
|
* stdout. This is that child: it tails the session's transcript tree on this machine and prints each change within one
|
|
9
9
|
* 1s poll of Claude Code writing it.
|
|
10
10
|
*
|
|
11
|
-
* ONE PLACE COMPUTES EACH NUMBER. It runs the same `SessionLogWatcher` the
|
|
11
|
+
* ONE PLACE COMPUTES EACH NUMBER. It runs the same `SessionLogWatcher` the relay runs (`session-stats.ts`), over the
|
|
12
12
|
* same session layout (the main transcript, and `<sessionDir>/<sessionId>/subagents/agent-<id>.jsonl` beside it), into
|
|
13
13
|
* the shared `createSubagentLiveView`, which feeds the same activity observer, usage ledger counter and pricing rule
|
|
14
14
|
* the dashboard's numbers come from. Nothing is counted or priced here.
|
package/dist/tool-access.js
CHANGED
|
@@ -27,6 +27,7 @@ export const CALLBACK_ENV = {
|
|
|
27
27
|
};
|
|
28
28
|
const BOOTSTRAP = { permission: null, callback: null };
|
|
29
29
|
const needs = (permission) => ({ permission, callback: null });
|
|
30
|
+
const operatorSessionNeeds = (permission) => ({ permission, callback: null, operatorSessionOnly: true });
|
|
30
31
|
const needsAndPostsTo = (permission, callback) => ({ permission, callback });
|
|
31
32
|
/**
|
|
32
33
|
* Every tool this server registers, with what makes it usable. `index.ts` refuses to register a tool that is not
|
|
@@ -40,6 +41,9 @@ export const TOOL_ACCESS = {
|
|
|
40
41
|
danxbot_api_spec: BOOTSTRAP,
|
|
41
42
|
// `POST /api/issues/:id/attachments`
|
|
42
43
|
attach_file: needs("cards.edit"),
|
|
44
|
+
// DX-4233 - the plugin's relay waits on the session's plan events here: it opens the listener ticket
|
|
45
|
+
// (`POST /api/plan-sessions/me/stream-ticket`) and heartbeats (`.../me/heartbeat`), both `plans.view`.
|
|
46
|
+
plan_events_wait: operatorSessionNeeds("plans.view"),
|
|
43
47
|
// The one dispatch callback that stays a tool (DX-4525): it reads local files. Its dashboard route's own policy names the
|
|
44
48
|
// permission (`route-policies/index.ts`); every other callback is a `danxbot_api` call to a route under `callback-routes.ts`.
|
|
45
49
|
danxbot_slack_reply: needsAndPostsTo("slack.threads.post", "slackReply"),
|
|
@@ -50,6 +54,8 @@ export function isUsable(access, grant) {
|
|
|
50
54
|
return false;
|
|
51
55
|
if (access.callback !== null && !grant.callbacks.has(access.callback))
|
|
52
56
|
return false;
|
|
57
|
+
if (access.operatorSessionOnly === true && !grant.operatorSession)
|
|
58
|
+
return false;
|
|
53
59
|
return true;
|
|
54
60
|
}
|
|
55
61
|
/** The ONE mapping from what a session holds to the tool names it advertises, sorted. */
|
|
@@ -58,5 +64,5 @@ export function toolsFor(grant) {
|
|
|
58
64
|
}
|
|
59
65
|
/** The bootstrap set: usable with no key and no callback. */
|
|
60
66
|
export function bootstrapTools() {
|
|
61
|
-
return toolsFor({ permissions: new Set(), callbacks: new Set() });
|
|
67
|
+
return toolsFor({ permissions: new Set(), callbacks: new Set(), operatorSession: false });
|
|
62
68
|
}
|
package/dist/tool-surface.js
CHANGED
|
@@ -11,17 +11,15 @@ import { toolsFor } from "./tool-access.js";
|
|
|
11
11
|
export class ToolSurface {
|
|
12
12
|
tools;
|
|
13
13
|
callbacks;
|
|
14
|
-
|
|
15
|
-
constructor(tools, callbacks,
|
|
16
|
-
/** Announces that the tool list changed: the server's `sendToolListChanged` (a no-op until a client is connected). */
|
|
17
|
-
onChanged) {
|
|
14
|
+
options;
|
|
15
|
+
constructor(tools, callbacks, options) {
|
|
18
16
|
this.tools = tools;
|
|
19
17
|
this.callbacks = callbacks;
|
|
20
|
-
this.
|
|
18
|
+
this.options = options;
|
|
21
19
|
}
|
|
22
20
|
/** Show exactly the tools `permissions` and this dispatch's callbacks allow. Returns the names whose visibility changed. */
|
|
23
21
|
apply(permissions) {
|
|
24
|
-
const wanted = new Set(toolsFor({ permissions, callbacks: this.callbacks }));
|
|
22
|
+
const wanted = new Set(toolsFor({ permissions, callbacks: this.callbacks, operatorSession: this.options.operatorSession }));
|
|
25
23
|
const changed = [];
|
|
26
24
|
for (const [name, tool] of this.tools) {
|
|
27
25
|
const want = wanted.has(name);
|
|
@@ -31,7 +29,7 @@ export class ToolSurface {
|
|
|
31
29
|
changed.push(name);
|
|
32
30
|
}
|
|
33
31
|
if (changed.length > 0)
|
|
34
|
-
this.onChanged();
|
|
32
|
+
this.options.onChanged();
|
|
35
33
|
return changed.sort();
|
|
36
34
|
}
|
|
37
35
|
/** The tool names currently shown. */
|
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.274",
|
|
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",
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
"node": ">=20"
|
|
22
22
|
},
|
|
23
23
|
"scripts": {
|
|
24
|
+
"prepublishOnly": "bash ../../scripts/assert-tree-contains-origin-main.sh @thehammer/danx-dashboard-mcp",
|
|
24
25
|
"build": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.json && node -e \"require('fs').chmodSync('dist/index.js', 0o755)\"",
|
|
25
26
|
"verify:boot": "bash scripts/verify-boot.sh",
|
|
26
27
|
"start": "node dist/index.js",
|
|
@@ -31,7 +32,7 @@
|
|
|
31
32
|
},
|
|
32
33
|
"dependencies": {
|
|
33
34
|
"@modelcontextprotocol/sdk": "1.29.0",
|
|
34
|
-
"@thehammer/danx-session-telemetry": "0.1.
|
|
35
|
+
"@thehammer/danx-session-telemetry": "0.1.44",
|
|
35
36
|
"zod": "^3.25.76"
|
|
36
37
|
},
|
|
37
38
|
"devDependencies": {
|