@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
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DX-4233 - the IN-PROCESS relay of a session's plan events: what the `plan_events_wait` tool serves.
|
|
3
|
+
*
|
|
4
|
+
* WHO CALLS IT. The danxbot plugin's relay loop, inside the Claude Code session, calls `plan_events_wait` over and over on
|
|
5
|
+
* this session's own danx-dashboard MCP server and hands every record it gets to the session. This class is everything on the
|
|
6
|
+
* server side of that call: it opens the session's dashboard event stream (the mint / stream / heartbeat contract of
|
|
7
|
+
* `session-stream.ts`), buffers records for the caller and reports the session's listener health. There is no process to start,
|
|
8
|
+
* watch or kill: the stream lives exactly as long as the MCP server, which lives exactly as long as its session.
|
|
9
|
+
*
|
|
10
|
+
* THE WAIT CONTRACT (`wait`). One rule, one reset, and a cursor that is opaque to the caller.
|
|
11
|
+
* 1. ONE RULE, BY ARRIVAL. Every record (a dashboard event, a digest, a notice, a pacing or resume message) is given the next
|
|
12
|
+
* number of a sequence that only ever increases: its `seq`. Each answered record carries a CURSOR, an opaque string the
|
|
13
|
+
* caller stores once it has DELIVERED that record and sends back on its next call. A call answers every buffered record
|
|
14
|
+
* whose seq is above the cursor's (at once when there is one, else when one arrives, else empty at the timeout) and
|
|
15
|
+
* DROPS every record at or below it, at the call (`acknowledged`, the one predicate). An answer is never a drain: a record
|
|
16
|
+
* stays buffered until a LATER call's cursor passes it, so a call whose caller went away (a plugin reload) loses nothing.
|
|
17
|
+
* The dashboard numbers its events when it INSERTS them but publishes them when it COMMITS, so an event can arrive after
|
|
18
|
+
* a higher-numbered one; arrival order is the only order the relay can promise, which is why the dashboard's id is never
|
|
19
|
+
* what is compared.
|
|
20
|
+
* 2. THE CURSOR. `<seq>:<id>,<id>,...`: the record's seq, then the dashboard event ids delivered up to and including it, in
|
|
21
|
+
* arrival order and bounded to the last `CURSOR_IDS_MAX` (`formatCursor`, `parseCursor`: the seq and every id are base-10
|
|
22
|
+
* whole numbers with no leading zero, the ids are distinct and positive; anything else is refused loudly). The ids are what
|
|
23
|
+
* makes a restart exact. After a restart of the MCP server (a resumed session) a fresh relay is presented its caller's
|
|
24
|
+
* cursor: its sequence continues from that seq, and the stream resumes after the highest of those ids with the listener's
|
|
25
|
+
* duplicate guard seeded with all of them. The dashboard replays every event created within its 30 s commit-order overlap
|
|
26
|
+
* (`REPLAY_COMMIT_ORDER_OVERLAP_MS`) before the last one seen and relies on that guard, so an already delivered event in
|
|
27
|
+
* the overlap is dropped and one never delivered is delivered.
|
|
28
|
+
* THE ACCEPTED GAP: an insert that commits more than that 30 s overlap after the event it follows is missed by a RESTART
|
|
29
|
+
* resume (the dashboard's own limit); a running relay never misses one. And a burst of more than `CURSOR_IDS_MAX` events
|
|
30
|
+
* inside the overlap can replay the oldest of them once (a duplicate, never a loss).
|
|
31
|
+
* 3. ONE PLAN. A call names the plan the session is on (`planId`). A plan different from the last call's drops the
|
|
32
|
+
* PLAN-scoped records buffered or held for the plan it left, so a move starts clean. SESSION-scoped records (pacing, resume
|
|
33
|
+
* and the relay's own failure and revoked-key notices: they say nothing about a plan, and the dashboard never replays
|
|
34
|
+
* them) are kept, here and across the 40 s suspend below. The ids of a dropped record leave the cursors of the records that
|
|
35
|
+
* stay: delivering a kept record must not claim an event that was dropped undelivered. The stream stays on a move: it
|
|
36
|
+
* belongs to the SESSION, and the dashboard delivers an event only while the session is on the event's plan.
|
|
37
|
+
*
|
|
38
|
+
* One wait is pending at a time: a newer call supersedes the older, which answers empty at once. A call whose request is
|
|
39
|
+
* aborted drops its waiter. A terminal outcome answers `{stopped}` with the remedy (`stop-fixes.ts`); the call after it tries
|
|
40
|
+
* again from scratch.
|
|
41
|
+
*
|
|
42
|
+
* WHO IS HEALTHY. The relay heartbeats every `HEARTBEAT_INTERVAL_MS` while a call is pending or one was made within
|
|
43
|
+
* `HEARTBEAT_ACTIVITY_WINDOW_MS`. After that window the stream is closed and the heartbeat stops, so the dashboard reads the
|
|
44
|
+
* session not healthy when nothing is reading its events (a dead or unloaded plugin module). The window is 40 s: a pending
|
|
45
|
+
* call counts, so it only has to outlast the caller's turnaround and failure backoff (the plugin waits 20 s and backs off at
|
|
46
|
+
* most 10 s), and it must stay under the dashboard's liveness window (`SESSION_LIVENESS_WINDOW_MS`, 60 s) minus one beat. The
|
|
47
|
+
* sequence and the highest event id survive the gap, so a cursor stays valid across it.
|
|
48
|
+
*
|
|
49
|
+
* NOTHING OUTLIVES THE SESSION. The heartbeat interval, the backoff sleep and the digest hold are unref'd, the stream and the
|
|
50
|
+
* mint / inventory requests are aborted by `close()`, and `close()` also clears a pending call's wait timer (the one ref'd timer,
|
|
51
|
+
* held only until the call answers). The server calls `close()` when its transport closes and when its stdin ends (`shutdown.ts`).
|
|
52
|
+
*/
|
|
53
|
+
import { DigestHold } from "./digest-hold.js";
|
|
54
|
+
import { HeartbeatReporter } from "./heartbeat-reporter.js";
|
|
55
|
+
import { capResumeIds, DELIVERED_ID_MEMORY, HEALTHY_CONNECTION_MS, INITIAL_BACKOFF_MS, MAX_BACKOFF_MS, runListener, } from "./listen.js";
|
|
56
|
+
import { classifyAdmissionRefusal, fetchPlanInventory, HEARTBEAT_INTERVAL_MS, heartbeatConnection, MAX_REFUSED_TICKETS_IN_A_ROW, mintTicket, } from "./session-stream.js";
|
|
57
|
+
import { fixForStop } from "./stop-fixes.js";
|
|
58
|
+
/** The shortest and longest wait one call may ask for; a short ceiling keeps an orphaned waiter's life short. */
|
|
59
|
+
export const WAIT_MIN_MS = 1_000;
|
|
60
|
+
export const WAIT_MAX_MS = 30_000;
|
|
61
|
+
/** How long after a call (or the end of one) the relay keeps heartbeating and streaming; see the module docblock. */
|
|
62
|
+
export const HEARTBEAT_ACTIVITY_WINDOW_MS = 40_000;
|
|
63
|
+
/**
|
|
64
|
+
* How many delivered dashboard event ids a cursor carries. The dashboard replays the events created within its 30 s overlap
|
|
65
|
+
* before the last one seen: 64 covers a busy plan's burst in that window many times over while keeping every record's cursor
|
|
66
|
+
* a few hundred characters. (The listener's own memory, `DELIVERED_ID_MEMORY`, is far larger and is not what a cursor carries.)
|
|
67
|
+
*/
|
|
68
|
+
export const CURSOR_IDS_MAX = 64;
|
|
69
|
+
const WHOLE = "(?:0|[1-9][0-9]*)";
|
|
70
|
+
const CURSOR_PATTERN = new RegExp(`^(${WHOLE}):((?:[1-9][0-9]*)(?:,[1-9][0-9]*)*)?$`);
|
|
71
|
+
export const formatCursor = (cursor) => `${cursor.seq}:${cursor.ids.join(",")}`;
|
|
72
|
+
/** Reads a cursor this relay issued. Anything else is refused loudly: a guess would hide a lost or repeated record. */
|
|
73
|
+
export function parseCursor(raw) {
|
|
74
|
+
const match = CURSOR_PATTERN.exec(raw);
|
|
75
|
+
const seq = match === null ? NaN : Number(match[1]);
|
|
76
|
+
const ids = match === null || match[2] === undefined ? [] : match[2].split(",").map(Number);
|
|
77
|
+
const valid = Number.isSafeInteger(seq) && ids.length <= CURSOR_IDS_MAX && ids.every(Number.isSafeInteger) && new Set(ids).size === ids.length;
|
|
78
|
+
if (!valid) {
|
|
79
|
+
throw new Error(`plan_events_wait: cursor ${JSON.stringify(raw)} is not one this server issued (expected "<seq>:<id>,<id>,...": a whole number, a colon, then up to ${CURSOR_IDS_MAX} distinct positive event ids)`);
|
|
80
|
+
}
|
|
81
|
+
return { seq, ids };
|
|
82
|
+
}
|
|
83
|
+
const boundedIds = (ids) => [...new Set(ids)].slice(-CURSOR_IDS_MAX);
|
|
84
|
+
/** DX-4233: the one rule of the cursor: a record at or below its seq has been delivered. */
|
|
85
|
+
export const acknowledged = (seq, cursor) => cursor !== null && seq <= cursor.seq;
|
|
86
|
+
export class PlanEventRelay {
|
|
87
|
+
deps;
|
|
88
|
+
buffer = [];
|
|
89
|
+
waiter = null;
|
|
90
|
+
run = null;
|
|
91
|
+
lastPlanId = null;
|
|
92
|
+
/** The last seq given: it continues from a presented cursor and survives a suspend, so a cursor stays valid across the gap. */
|
|
93
|
+
seq = 0;
|
|
94
|
+
/** The dashboard event ids that arrived (or were presented in a cursor), oldest first, bounded: the next record's `recent`. */
|
|
95
|
+
recentIds = [];
|
|
96
|
+
/** A terminal outcome nobody was waiting for: answered by the next call. */
|
|
97
|
+
pendingStop = null;
|
|
98
|
+
/** Dashboard ids already given to the buffer (and the cursor the run started from): handed to a re-mint so a replay is dropped. */
|
|
99
|
+
seenIds = [];
|
|
100
|
+
connectionState = "reconnecting";
|
|
101
|
+
stoppedReason = null;
|
|
102
|
+
lastInventoryError = null;
|
|
103
|
+
lastActivityAt = 0;
|
|
104
|
+
heartbeat = null;
|
|
105
|
+
reporter = null;
|
|
106
|
+
digest = new DigestHold((event) => this.accept(event));
|
|
107
|
+
constructor(deps) {
|
|
108
|
+
this.deps = deps;
|
|
109
|
+
}
|
|
110
|
+
/** The one entry point: see the module docblock for the contract. */
|
|
111
|
+
async wait(args, signal) {
|
|
112
|
+
// Parsed first: a cursor this server did not issue is refused before it changes anything.
|
|
113
|
+
const cursor = args.cursor === null ? null : parseCursor(args.cursor);
|
|
114
|
+
// DX-4233: a request already cancelled (a plugin reload) touches nothing and starts nothing.
|
|
115
|
+
if (signal?.aborted)
|
|
116
|
+
return { events: [] };
|
|
117
|
+
// DX-4233: one wait at a time; the older call answers empty so its caller is never left hanging.
|
|
118
|
+
this.supersedeWaiter();
|
|
119
|
+
// DX-4233: a plan move starts clean (the very first call has no last plan; after a suspend the last plan is still known, and `dropPlanScoped` already ran).
|
|
120
|
+
if (this.lastPlanId !== null && args.planId !== this.lastPlanId)
|
|
121
|
+
this.startClean();
|
|
122
|
+
this.lastPlanId = args.planId;
|
|
123
|
+
if (cursor !== null) {
|
|
124
|
+
// DX-4233: a fresh relay (a restarted server) continues the caller's sequence and remembers the ids it was handed; a
|
|
125
|
+
// running one is already past both.
|
|
126
|
+
this.seq = Math.max(this.seq, cursor.seq); // DX-4233: max, never `=`: a stale cursor (a plugin reload) must not reuse a buffered seq
|
|
127
|
+
this.recentIds = boundedIds([...cursor.ids, ...this.recentIds]);
|
|
128
|
+
// What the caller delivered precedes every record still buffered, so each of their cursors carries it (a dropped
|
|
129
|
+
// plan-scoped record's ids were taken out of them, and the caller's own delivered ids go back in).
|
|
130
|
+
this.buffer = this.buffer.map((r) => ({ ...r, recent: boundedIds([...cursor.ids, ...r.recent]) }));
|
|
131
|
+
}
|
|
132
|
+
this.touch();
|
|
133
|
+
this.ensureReporter(args.transcriptPath);
|
|
134
|
+
this.buffer = this.buffer.filter((r) => !acknowledged(r.seq, cursor));
|
|
135
|
+
if (this.pendingStop !== null) {
|
|
136
|
+
const stopped = this.pendingStop;
|
|
137
|
+
this.pendingStop = null;
|
|
138
|
+
return { stopped };
|
|
139
|
+
}
|
|
140
|
+
// DX-4233: a server holding no key answers a stop without calling the dashboard (a dispatch never reaches here).
|
|
141
|
+
if (this.deps.auth() === null) {
|
|
142
|
+
return { stopped: this.stopRecord("credential_unavailable", "this session holds no dashboard key") };
|
|
143
|
+
}
|
|
144
|
+
if (this.run === null)
|
|
145
|
+
this.start(cursor);
|
|
146
|
+
if (this.buffer.length > 0)
|
|
147
|
+
return this.answer();
|
|
148
|
+
return new Promise((resolve) => {
|
|
149
|
+
const onAbort = () => this.supersedeWaiter();
|
|
150
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
151
|
+
const waiter = {
|
|
152
|
+
settle: resolve,
|
|
153
|
+
timer: setTimeout(() => {
|
|
154
|
+
this.waiter = null;
|
|
155
|
+
this.finishCall(waiter, { events: [] });
|
|
156
|
+
}, args.timeoutMs),
|
|
157
|
+
detach: () => signal?.removeEventListener("abort", onAbort),
|
|
158
|
+
};
|
|
159
|
+
this.waiter = waiter;
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
/** Stop everything: the pending call, the stream, the heartbeat, the transcript tailer. For the server's shutdown. */
|
|
163
|
+
close() {
|
|
164
|
+
this.supersedeWaiter();
|
|
165
|
+
this.suspend();
|
|
166
|
+
}
|
|
167
|
+
// ---------------------------------------------------------------------------------------------------------------- calls
|
|
168
|
+
finishCall(waiter, answer) {
|
|
169
|
+
clearTimeout(waiter.timer);
|
|
170
|
+
waiter.detach();
|
|
171
|
+
this.lastActivityAt = this.deps.now();
|
|
172
|
+
waiter.settle(answer);
|
|
173
|
+
}
|
|
174
|
+
/** The pending call, if any, answers empty at once (a newer call replaced it, or its request went away). */
|
|
175
|
+
supersedeWaiter() {
|
|
176
|
+
const waiter = this.waiter;
|
|
177
|
+
if (waiter === null)
|
|
178
|
+
return;
|
|
179
|
+
this.waiter = null;
|
|
180
|
+
this.finishCall(waiter, { events: [] });
|
|
181
|
+
}
|
|
182
|
+
/** Everything buffered, each record with the cursor to store once it is delivered. */
|
|
183
|
+
answer() {
|
|
184
|
+
return { events: this.buffer.map(({ seq, recent, text }) => ({ cursor: formatCursor({ seq, ids: recent }), text })) };
|
|
185
|
+
}
|
|
186
|
+
// ------------------------------------------------------------------------------------------------------------ the buffer
|
|
187
|
+
/**
|
|
188
|
+
* A record the session should now be told: given the next seq, buffered for the caller, and the pending call answers with it.
|
|
189
|
+
* A replayed dashboard event never reaches here twice: the listener's duplicate guard (seeded from the cursor) drops it first.
|
|
190
|
+
*/
|
|
191
|
+
accept(event) {
|
|
192
|
+
// DX-4233: the seq is given at arrival and never reused, so a record the dashboard publishes late is just the next one.
|
|
193
|
+
const own = event.ids ?? (event.id === null ? [] : [event.id]);
|
|
194
|
+
this.recentIds = boundedIds([...this.recentIds, ...own]);
|
|
195
|
+
for (const id of own) {
|
|
196
|
+
this.seenIds.push(id);
|
|
197
|
+
if (this.seenIds.length > DELIVERED_ID_MEMORY)
|
|
198
|
+
this.seenIds.shift();
|
|
199
|
+
}
|
|
200
|
+
this.seq += 1;
|
|
201
|
+
this.buffer.push({ seq: this.seq, own, recent: [...this.recentIds], text: event.text, scope: event.scope ?? "plan" });
|
|
202
|
+
const waiter = this.waiter;
|
|
203
|
+
if (waiter === null)
|
|
204
|
+
return;
|
|
205
|
+
this.waiter = null;
|
|
206
|
+
this.finishCall(waiter, this.answer());
|
|
207
|
+
}
|
|
208
|
+
// ---------------------------------------------------------------------------------------------------- lifecycle and health
|
|
209
|
+
/**
|
|
210
|
+
* DX-4233: drop the plan-scoped records, keeping the session-scoped ones. The ids of what is dropped leave the cursors of what
|
|
211
|
+
* stays (and the remembered ids): delivering a kept record must not claim an event that was dropped undelivered, or a
|
|
212
|
+
* restart would resume past it and the dashboard would never replay it.
|
|
213
|
+
*/
|
|
214
|
+
dropPlanScoped() {
|
|
215
|
+
const dropped = new Set(this.buffer.filter((r) => r.scope === "plan").flatMap((r) => r.own));
|
|
216
|
+
this.buffer = this.buffer
|
|
217
|
+
.filter((r) => r.scope === "session")
|
|
218
|
+
.map((r) => ({ ...r, recent: r.recent.filter((id) => !dropped.has(id)) }));
|
|
219
|
+
this.recentIds = this.recentIds.filter((id) => !dropped.has(id));
|
|
220
|
+
// DX-4233: `seenIds` (the listener's duplicate guard for a re-mint on the SAME run) deliberately keeps them. A plan move's
|
|
221
|
+
// dropped events belong to a plan the session left, and the dashboard replays only the session's current plan, so nothing
|
|
222
|
+
// legitimate is replayed into the guard; and the gap, which does need them replayed, ends the run, so `start` builds a fresh `seenIds` from the next call's cursor.
|
|
223
|
+
}
|
|
224
|
+
/** The session moved to another plan: drop what was buffered or held for the plan it left; session-scoped records stay. */
|
|
225
|
+
startClean() {
|
|
226
|
+
this.dropPlanScoped();
|
|
227
|
+
this.digest.reset();
|
|
228
|
+
}
|
|
229
|
+
/** Creates the heartbeat reporter on the first call; a later call's transcript path (a resumed session) is handed to it. */
|
|
230
|
+
ensureReporter(transcriptPath) {
|
|
231
|
+
if (this.reporter !== null) {
|
|
232
|
+
this.reporter.useTranscript(transcriptPath);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
this.reporter = new HeartbeatReporter({
|
|
236
|
+
auth: () => {
|
|
237
|
+
const auth = this.deps.auth();
|
|
238
|
+
if (auth === null)
|
|
239
|
+
throw new Error("plan_events_wait: this server holds no dashboard key to heartbeat with");
|
|
240
|
+
return auth;
|
|
241
|
+
},
|
|
242
|
+
http: this.deps,
|
|
243
|
+
makeStatsWatcher: this.deps.makeStatsWatcher,
|
|
244
|
+
connection: () => heartbeatConnection(this.connectionState, this.stoppedReason, this.lastInventoryError),
|
|
245
|
+
streaming: () => this.connectionState === "streaming",
|
|
246
|
+
// `origin: null` skips the agent/machine hold: the notice reaches the session at once. It is about the session, not a plan.
|
|
247
|
+
announce: (text) => this.digest.route({ type: "event", id: null, text, origin: null, scope: "session" }),
|
|
248
|
+
forceReconnect: () => this.run?.forceReconnect?.(),
|
|
249
|
+
}, transcriptPath ?? null);
|
|
250
|
+
}
|
|
251
|
+
/** Mark the relay in use and make sure the heartbeat is scheduled. */
|
|
252
|
+
touch() {
|
|
253
|
+
this.lastActivityAt = this.deps.now();
|
|
254
|
+
this.heartbeat ??= this.deps.scheduleInterval(() => this.heartbeatTick(), HEARTBEAT_INTERVAL_MS);
|
|
255
|
+
}
|
|
256
|
+
heartbeatTick() {
|
|
257
|
+
// DX-4233: a pending call keeps the relay alive however long it waits; only a gap with no call at all suspends it.
|
|
258
|
+
if (this.waiter === null && this.deps.now() - this.lastActivityAt > HEARTBEAT_ACTIVITY_WINDOW_MS) {
|
|
259
|
+
// Nobody is reading this session's events any more: stop heartbeating (the dashboard then reads the listener not
|
|
260
|
+
// healthy) and let go of the stream. The next call starts it again from its cursor; the dashboard replays what it missed.
|
|
261
|
+
this.suspend();
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
if (this.deps.auth() === null)
|
|
265
|
+
return;
|
|
266
|
+
this.reporter?.tick();
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Let go of everything the relay holds for a caller: the stream, the heartbeat, the digest, and the plan-scoped records.
|
|
270
|
+
* What was held for a digest was never marked delivered, so the dashboard's replay (after the caller's cursor) returns it to
|
|
271
|
+
* whoever calls next. Session-scoped records stay (the dashboard never replays them), and so does the sequence, so a cursor
|
|
272
|
+
* stays valid across the gap; the delivered ids come back with the next call's cursor.
|
|
273
|
+
*/
|
|
274
|
+
suspend() {
|
|
275
|
+
this.heartbeat?.stop();
|
|
276
|
+
this.heartbeat = null;
|
|
277
|
+
this.run?.abort.abort();
|
|
278
|
+
this.run = null;
|
|
279
|
+
this.reporter?.stop();
|
|
280
|
+
this.reporter = null;
|
|
281
|
+
this.digest.reset();
|
|
282
|
+
this.dropPlanScoped();
|
|
283
|
+
// DX-4233: `dropPlanScoped` already took the undelivered records' ids out of `recentIds`, so the dashboard replays them; what
|
|
284
|
+
// stays there was delivered. `seenIds` is reassigned by `start`, and a plan move after the gap is dropped by `dropPlanScoped`
|
|
285
|
+
// above, so neither is reset here.
|
|
286
|
+
this.pendingStop = null;
|
|
287
|
+
this.connectionState = "reconnecting";
|
|
288
|
+
this.stoppedReason = null;
|
|
289
|
+
this.lastInventoryError = null;
|
|
290
|
+
}
|
|
291
|
+
// ------------------------------------------------------------------------------------------------- the mint / stream loop
|
|
292
|
+
start(cursor) {
|
|
293
|
+
// The listener's duplicate guard starts with every event the caller was handed and everything still buffered: the dashboard
|
|
294
|
+
// replays its 30 s overlap before the highest of them, so a replayed one is dropped and a never-delivered one is delivered.
|
|
295
|
+
this.seenIds = capResumeIds([...(cursor?.ids ?? []), ...this.buffer.flatMap((r) => r.own)]);
|
|
296
|
+
this.stoppedReason = null;
|
|
297
|
+
const run = { abort: new AbortController(), forceReconnect: null };
|
|
298
|
+
this.run = run;
|
|
299
|
+
void this.loop(run).catch((err) => {
|
|
300
|
+
// A bug, not a stream outcome: say so out loud, and answer it like any terminal outcome rather than idle silently.
|
|
301
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
302
|
+
process.stderr.write(`plan_events_wait: the event relay failed: ${message}\n`);
|
|
303
|
+
this.terminal(run, "relay_failed", message);
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
current(run) {
|
|
307
|
+
return this.run === run && !run.abort.signal.aborted;
|
|
308
|
+
}
|
|
309
|
+
stopRecord(reason, detail) {
|
|
310
|
+
return { reason, detail, fix: fixForStop(reason) };
|
|
311
|
+
}
|
|
312
|
+
/** A terminal outcome: the pending call (or else the next) answers `{stopped}`; the loop ends. */
|
|
313
|
+
terminal(run, reason, detail) {
|
|
314
|
+
// DX-4233: only the run that is still current may end the relay; a superseded or closed run ending late is ignored.
|
|
315
|
+
if (!this.current(run))
|
|
316
|
+
return;
|
|
317
|
+
this.digest.cancel();
|
|
318
|
+
this.run = null;
|
|
319
|
+
this.connectionState = "stopped";
|
|
320
|
+
this.stoppedReason = reason;
|
|
321
|
+
const stopped = this.stopRecord(reason, detail);
|
|
322
|
+
const waiter = this.waiter;
|
|
323
|
+
if (waiter === null) {
|
|
324
|
+
this.pendingStop = stopped;
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
this.waiter = null;
|
|
328
|
+
this.finishCall(waiter, { stopped });
|
|
329
|
+
}
|
|
330
|
+
async loop(run) {
|
|
331
|
+
const sleep = (ms) => this.deps.sleep(ms, run.abort.signal);
|
|
332
|
+
// The mint and the inventory read end with the run, so a closed relay leaves no request in flight.
|
|
333
|
+
const http = { ...this.deps, signal: run.abort.signal };
|
|
334
|
+
let backoff = INITIAL_BACKOFF_MS;
|
|
335
|
+
let refusedInRow = 0;
|
|
336
|
+
let inventoryRead = false;
|
|
337
|
+
for (;;) {
|
|
338
|
+
if (!this.current(run))
|
|
339
|
+
return;
|
|
340
|
+
this.connectionState = "reconnecting";
|
|
341
|
+
this.reporter?.resetFailureStreak();
|
|
342
|
+
run.forceReconnect = null;
|
|
343
|
+
const auth = this.deps.auth();
|
|
344
|
+
if (auth === null)
|
|
345
|
+
return this.terminal(run, "credential_unavailable", "this session holds no dashboard key");
|
|
346
|
+
const minted = await mintTicket(auth, http);
|
|
347
|
+
if (!this.current(run))
|
|
348
|
+
return;
|
|
349
|
+
if (minted.kind === "terminal")
|
|
350
|
+
return this.terminal(run, minted.reason, minted.detail);
|
|
351
|
+
if (minted.kind === "transient") {
|
|
352
|
+
await sleep(backoff);
|
|
353
|
+
backoff = Math.min(MAX_BACKOFF_MS, backoff * 2);
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
// The inventory is a property of the credential and the plan: read best effort, never a gate. A failed read is reported
|
|
357
|
+
// on the heartbeat (`inventory: unavailable`), never silently healthy, and is tried again at the next re-mint.
|
|
358
|
+
if (!inventoryRead) {
|
|
359
|
+
const inventory = await fetchPlanInventory(auth, http);
|
|
360
|
+
if (!this.current(run))
|
|
361
|
+
return;
|
|
362
|
+
inventoryRead = inventory.kind === "ok";
|
|
363
|
+
this.lastInventoryError = inventory.kind === "ok" ? null : inventory.detail;
|
|
364
|
+
}
|
|
365
|
+
backoff = INITIAL_BACKOFF_MS;
|
|
366
|
+
const attempt = { stopped: null, emitted: false };
|
|
367
|
+
const startedAt = this.deps.now();
|
|
368
|
+
await runListener({
|
|
369
|
+
streamUrl: minted.streamUrl,
|
|
370
|
+
ticket: minted.ticket,
|
|
371
|
+
leaseMs: minted.leaseMs,
|
|
372
|
+
resumeIds: capResumeIds([...this.seenIds, ...this.digest.heldIds()]),
|
|
373
|
+
}, {
|
|
374
|
+
fetch: this.deps.fetch,
|
|
375
|
+
sleep,
|
|
376
|
+
now: this.deps.now,
|
|
377
|
+
readIdleTimeoutMs: this.deps.readIdleTimeoutMs,
|
|
378
|
+
signal: run.abort.signal,
|
|
379
|
+
write: (output) => {
|
|
380
|
+
if (!this.current(run))
|
|
381
|
+
return;
|
|
382
|
+
if (output.type === "stopped") {
|
|
383
|
+
attempt.stopped = output;
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
attempt.emitted = true;
|
|
387
|
+
this.digest.route(output);
|
|
388
|
+
},
|
|
389
|
+
onState: (state) => {
|
|
390
|
+
if (!this.current(run))
|
|
391
|
+
return;
|
|
392
|
+
this.connectionState = state;
|
|
393
|
+
// Report `streaming` now, not up to a beat later: the session reads healthy as soon as it is.
|
|
394
|
+
if (state === "streaming" && this.deps.auth() !== null)
|
|
395
|
+
this.reporter?.tick();
|
|
396
|
+
},
|
|
397
|
+
onConnectionAttempt: (abort) => {
|
|
398
|
+
run.forceReconnect = abort;
|
|
399
|
+
},
|
|
400
|
+
});
|
|
401
|
+
if (!this.current(run))
|
|
402
|
+
return;
|
|
403
|
+
const stopped = attempt.stopped;
|
|
404
|
+
// DX-4233: the listener always ends with a stop record; its absence is a bug, answered as `relay_failed` by `start`'s catch.
|
|
405
|
+
if (stopped === null)
|
|
406
|
+
throw new Error("the stream reader ended without a stop record");
|
|
407
|
+
if (stopped.reason === "lease_expired") {
|
|
408
|
+
refusedInRow = 0;
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
if (stopped.reason === "refused") {
|
|
412
|
+
const classified = classifyAdmissionRefusal(stopped.refusal);
|
|
413
|
+
if (classified !== null)
|
|
414
|
+
return this.terminal(run, classified.reason, classified.detail);
|
|
415
|
+
const provedHealthy = attempt.emitted || this.deps.now() - startedAt >= HEALTHY_CONNECTION_MS;
|
|
416
|
+
refusedInRow = provedHealthy ? 1 : refusedInRow + 1;
|
|
417
|
+
if (refusedInRow >= MAX_REFUSED_TICKETS_IN_A_ROW) {
|
|
418
|
+
return this.terminal(run, "refused", `${stopped.detail} (a freshly minted ticket was refused ${refusedInRow} times in a row)`);
|
|
419
|
+
}
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
return this.terminal(run, stopped.reason, stopped.detail);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { checkedTranscriptPath } from "./session-stats.js";
|
|
2
|
+
/**
|
|
3
|
+
* DX-4233 - the body of the `plan_events_wait` tool: the transcript path is checked (a refusal names it), then the relay waits,
|
|
4
|
+
* and a cancelled request (`signal`, the SDK's `extra.signal`) drops the pending wait. A malformed cursor is refused by the relay.
|
|
5
|
+
*/
|
|
6
|
+
export function servePlanEventsWait(relay, args, signal) {
|
|
7
|
+
const transcriptPath = args.transcript_path === undefined ? undefined : checkedTranscriptPath(args.transcript_path, "plan_events_wait transcript_path");
|
|
8
|
+
return relay.wait({ planId: args.plan_id, cursor: args.cursor, timeoutMs: args.timeout_ms, transcriptPath }, signal);
|
|
9
|
+
}
|
package/dist/restart-notice.js
CHANGED
|
@@ -22,10 +22,10 @@
|
|
|
22
22
|
* non-2xx, bad shape, for every one). Never silent: the hook prints a could-not-load notice.
|
|
23
23
|
*
|
|
24
24
|
* The credential is the PREDECESSOR's own record's source, resolved through the
|
|
25
|
-
* same resolver the
|
|
25
|
+
* same resolver the relay uses, so the lookup reaches the dashboard that
|
|
26
26
|
* connected it. READ-ONLY: it advances no cursor on the server.
|
|
27
27
|
*/
|
|
28
|
-
import { SESSION_ID_HEADER,
|
|
28
|
+
import { SESSION_ID_HEADER, resolveDashboardAuth } from "./session-options.js";
|
|
29
29
|
import { findPredecessorCandidates, projectKeyOf } from "./session-connection.js";
|
|
30
30
|
export const RESTART_NOTICE_SUBCOMMAND = "restart-notice";
|
|
31
31
|
export const RESTART_NOTICE_REQUEST_TIMEOUT_MS = 5_000;
|
|
@@ -119,7 +119,7 @@ resolveFrom = {}) {
|
|
|
119
119
|
for (const predecessor of candidates) {
|
|
120
120
|
let options;
|
|
121
121
|
try {
|
|
122
|
-
options =
|
|
122
|
+
options = resolveDashboardAuth(predecessor.sessionId, env, resolveFrom);
|
|
123
123
|
}
|
|
124
124
|
catch (err) {
|
|
125
125
|
const start = err;
|
package/dist/session-access.js
CHANGED
|
@@ -49,7 +49,7 @@ const DENIED_INSTRUCTION = "The user denied the request. Do not retry unless the
|
|
|
49
49
|
const DASHBOARD_OUTDATED_ERROR = "this dashboard does not support request-and-approve yet (POST /api/access-requests answered 404): " +
|
|
50
50
|
"it must be updated before this session can sign in";
|
|
51
51
|
const RATE_LIMITED_ERROR = "too many access requests from this address; wait, then call plan_connect again";
|
|
52
|
-
const unknownTeamError = (team) => `the dashboard has no team "${team}" (DANXBOT_TEAM); fix DANXBOT_TEAM in this server's env (
|
|
52
|
+
const unknownTeamError = (team) => `the dashboard has no team "${team}" (DANXBOT_TEAM); fix DANXBOT_TEAM in this server's env (your shell, or a repo's committed .claude/settings.json env block) and restart the session`;
|
|
53
53
|
const NO_SESSION_ID_ERROR = "this server has no CLAUDE_CODE_SESSION_ID, so it cannot request a session key";
|
|
54
54
|
const CLAIMED_LOST_ERROR = "the dashboard says this request was already claimed, but this process never received the key; " +
|
|
55
55
|
"start a new request";
|
|
@@ -109,7 +109,7 @@ export class SessionAccess {
|
|
|
109
109
|
this.dropKey();
|
|
110
110
|
return this.revokedHalt();
|
|
111
111
|
}
|
|
112
|
-
/** Where the current credential comes from and its fingerprint, for the
|
|
112
|
+
/** Where the current credential comes from and its fingerprint, for the relay's connection record. */
|
|
113
113
|
connection() {
|
|
114
114
|
if (this.credential === null || this.revoked !== null)
|
|
115
115
|
return null;
|
|
@@ -152,7 +152,7 @@ export class SessionAccess {
|
|
|
152
152
|
return { result };
|
|
153
153
|
}
|
|
154
154
|
/**
|
|
155
|
-
* DX-4390: the stored file is the
|
|
155
|
+
* DX-4390: the stored file is the relay's only way to the key, and the 7-day prune ages out
|
|
156
156
|
* a file by when it was last written. A server in use rewrites it (atomically) at most hourly,
|
|
157
157
|
* which also restores a file someone deleted while this process still holds the key.
|
|
158
158
|
*/
|
|
@@ -1,22 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* DX-2862 — the record that hands ONE Claude Code session's dashboard
|
|
3
|
-
* connection from its MCP server to
|
|
3
|
+
* connection from its MCP server to the plugin's hook subcommands.
|
|
4
4
|
*
|
|
5
|
-
* THE PROBLEM THIS SOLVES.
|
|
6
|
-
* in the SESSION's environment
|
|
7
|
-
* place the server's `.mcp.json` env block exists. So the
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* the server's
|
|
5
|
+
* THE PROBLEM THIS SOLVES. A hook's subcommand (`activity`, `background-work`, `event-text`,
|
|
6
|
+
* `ready-cards`, `restart-notice`) runs in the SESSION's environment, not in the MCP server's, which is the only
|
|
7
|
+
* place the server's `.mcp.json` env block exists. So it cannot see what the server was configured with, and before this it
|
|
8
|
+
* simply used its own ambient env credential. When that was a different credential from the server's, its report was
|
|
9
|
+
* dropped silently (the card's incident). The plan event relay (`plan_events_wait`) needs none of this: it runs inside the
|
|
10
|
+
* server, with the server's own credential. The listener reminder reads this record too.
|
|
11
11
|
*
|
|
12
12
|
* WHAT IS WRITTEN, AND WHAT IS NOT. On a successful `plan_connect` the server
|
|
13
13
|
* writes the dashboard URL, the credential SOURCE (a session key file path, or the name of
|
|
14
|
-
* an env var — never a credential) and the credential's FINGERPRINT.
|
|
14
|
+
* an env var — never a credential) and the credential's FINGERPRINT. A subcommand
|
|
15
15
|
* reads that record, resolves the SAME source through the same resolver
|
|
16
16
|
* (`credential.ts`), and refuses to run unless what it resolved fingerprints
|
|
17
17
|
* identically. A rotated file or a session environment that disagrees with the
|
|
18
|
-
* server's therefore fails loud instead of
|
|
19
|
-
* or, as in DX-2862, none at all.
|
|
18
|
+
* server's therefore fails loud instead of reporting as somebody else.
|
|
20
19
|
*
|
|
21
20
|
* WHY `plan_connect` AND NOTHING ELSE WRITES IT. A session can have several
|
|
22
21
|
* dashboard MCP servers configured (gpt-manager has a deployed one and a local
|
|
@@ -92,7 +91,7 @@ export function pruneStaleSessionFiles(dir, now) {
|
|
|
92
91
|
}
|
|
93
92
|
}
|
|
94
93
|
/**
|
|
95
|
-
* Record this session's connection. Write-then-rename, so a
|
|
94
|
+
* Record this session's connection. Write-then-rename, so a subcommand reading
|
|
96
95
|
* concurrently sees the old record or the new one, never a torn one.
|
|
97
96
|
*/
|
|
98
97
|
export function writeSessionConnection(record, options = {}) {
|
|
@@ -147,8 +146,8 @@ export function readSessionConnection(sessionId, options = {}) {
|
|
|
147
146
|
* that tool's own answer when it could not be recorded.
|
|
148
147
|
*
|
|
149
148
|
* FAIL LOUD, IN THE ONE PLACE THE AGENT IS LOOKING. A connect whose record is
|
|
150
|
-
* missing produces a session that is connected
|
|
151
|
-
*
|
|
149
|
+
* missing produces a session that is connected but whose hook reports (activity, background work, ready cards, the
|
|
150
|
+
* restart notice) refuse to run — so the failure belongs in the connect's answer, not only in a file
|
|
152
151
|
* nobody reads. It does NOT fail the connect itself: the session IS connected,
|
|
153
152
|
* and reporting otherwise would be a second lie on top of the first.
|
|
154
153
|
*/
|
|
@@ -157,8 +156,8 @@ export function recordSessionConnectionAfterConnect(result, inputs, write = writ
|
|
|
157
156
|
return {};
|
|
158
157
|
if (inputs.sessionId === undefined || inputs.sessionId === "") {
|
|
159
158
|
return {
|
|
160
|
-
|
|
161
|
-
"
|
|
159
|
+
connection_record_error: "connected, but this server has no CLAUDE_CODE_SESSION_ID, so no connection record can be written — " +
|
|
160
|
+
"the plugin's activity, background-work, ready-cards and restart-notice reports will not reach the dashboard",
|
|
162
161
|
};
|
|
163
162
|
}
|
|
164
163
|
try {
|
|
@@ -173,8 +172,8 @@ export function recordSessionConnectionAfterConnect(result, inputs, write = writ
|
|
|
173
172
|
}
|
|
174
173
|
catch (err) {
|
|
175
174
|
return {
|
|
176
|
-
|
|
177
|
-
"
|
|
175
|
+
connection_record_error: `connected, but recording this session's connection failed (${err.message}) — ` +
|
|
176
|
+
"the plugin's activity, background-work, ready-cards and restart-notice reports will not reach the dashboard until it succeeds",
|
|
178
177
|
};
|
|
179
178
|
}
|
|
180
179
|
}
|
package/dist/session-key.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* DX-4390 (PLAN-17 section 96) - the dashboard key a user approved for ONE agent session, kept on
|
|
3
|
-
* this machine so the session's MCP server and its event
|
|
3
|
+
* this machine so the session's MCP server and its event relay both authenticate with it.
|
|
4
4
|
*
|
|
5
5
|
* One file per dashboard and per session:
|
|
6
6
|
* `~/.config/danxbot/session-keys/<sha12(dashboard url)>/<session id>.json`, mode 0600. The key
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* rewrites its file at least hourly (`SessionAccess.guard`), so only an unused session's file
|
|
12
12
|
* ages out. The session id picks the file but never selects a key on the dashboard.
|
|
13
13
|
*
|
|
14
|
-
* Written write-then-rename, like the session connection record, so a
|
|
14
|
+
* Written write-then-rename, like the session connection record, so a relay reading
|
|
15
15
|
* concurrently sees the old file or the new one, never a torn one.
|
|
16
16
|
*/
|
|
17
17
|
import { createHash } from "node:crypto";
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DX-2862 / DX-4233 - how a one-shot client of the dashboard (`activity`, `background-work`, `event-text`, `ready-cards`,
|
|
3
|
+
* `restart-notice`) learns WHICH dashboard to call and AS WHOM: the connection record this session's own MCP server wrote when
|
|
4
|
+
* it connected a plan (`session-connection.ts`), resolved through the same credential resolver the server used, and refused
|
|
5
|
+
* unless it fingerprints identically. (The in-process relay behind `plan_events_wait` needs none of this: it lives inside the
|
|
6
|
+
* server that holds the credential.)
|
|
7
|
+
*/
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { credentialFingerprint, describeCredentialSource, readCredential } from "./credential.js";
|
|
10
|
+
import { readSessionConnection } from "./session-connection.js";
|
|
11
|
+
import { fixForStop } from "./stop-fixes.js";
|
|
12
|
+
export const SESSION_ID_HEADER = "x-danx-session-id";
|
|
13
|
+
/** A start this process cannot make, named the way the session will be told about it. */
|
|
14
|
+
export class SessionStartError extends Error {
|
|
15
|
+
// `ListenStopReason`, not `string`: a start-time failure is always one of `no_connection_record` / `credential_unavailable`
|
|
16
|
+
// / `credential_mismatch` (there is no stream yet).
|
|
17
|
+
reason;
|
|
18
|
+
fix;
|
|
19
|
+
constructor(reason, detail, fix) {
|
|
20
|
+
super(detail);
|
|
21
|
+
this.name = "SessionStartError";
|
|
22
|
+
this.reason = reason;
|
|
23
|
+
this.fix = fix;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* The session's connection, as its own MCP server recorded it, and the proof that this process holds the SAME credential that
|
|
28
|
+
* server does. Never falls back to an ambient env credential: that fallback is precisely DX-2862.
|
|
29
|
+
*/
|
|
30
|
+
export function resolveDashboardAuth(sessionId, env, options = {}) {
|
|
31
|
+
const home = options.home ?? homedir();
|
|
32
|
+
let record;
|
|
33
|
+
try {
|
|
34
|
+
record = readSessionConnection(sessionId, { home });
|
|
35
|
+
}
|
|
36
|
+
catch (err) {
|
|
37
|
+
throw new SessionStartError("no_connection_record", err.message, fixForStop("no_connection_record"));
|
|
38
|
+
}
|
|
39
|
+
if (record === null) {
|
|
40
|
+
throw new SessionStartError("no_connection_record", `session ${sessionId} has no dashboard connection record — no danx-dashboard MCP server has connected it to a plan`, fixForStop("no_connection_record"));
|
|
41
|
+
}
|
|
42
|
+
let token;
|
|
43
|
+
try {
|
|
44
|
+
token = readCredential(record.source, env);
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
const credentialError = err;
|
|
48
|
+
throw new SessionStartError("credential_unavailable", `the credential this session's danx-dashboard MCP server declared cannot be resolved here: ${credentialError.message}`,
|
|
49
|
+
// CredentialError.fix is a required string, never absent — no fallback needed.
|
|
50
|
+
credentialError.fix);
|
|
51
|
+
}
|
|
52
|
+
const fingerprint = credentialFingerprint(token);
|
|
53
|
+
if (fingerprint !== record.fingerprint) {
|
|
54
|
+
throw new SessionStartError("credential_mismatch", `${describeCredentialSource(record.source)} now yields credential ${fingerprint}, but this session's ` +
|
|
55
|
+
`danx-dashboard MCP server is using ${record.fingerprint} — a one-shot call would authenticate as somebody else`, fixForStop("credential_mismatch"));
|
|
56
|
+
}
|
|
57
|
+
return { dashboardUrl: record.dashboardUrl.replace(/\/+$/, ""), token, sessionId };
|
|
58
|
+
}
|