@thehammer/danx-dashboard-mcp 0.1.276 → 0.1.277

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/index.js CHANGED
@@ -86,7 +86,7 @@ import { PlanEventRelay, WAIT_MAX_MS, WAIT_MIN_MS } from "./plan-events-relay.js
86
86
  import { READ_IDLE_TIMEOUT_MS } from "./listen.js";
87
87
  import { REQUEST_TIMEOUT_MS, realScheduleInterval, realSleep } from "./session-stream.js";
88
88
  import { servePlanEventsWait } from "./plan-events-tool.js";
89
- import { lookupRestartNotice } from "./restart-notice.js";
89
+ import { lookupRestartNotice, RESTART_NOTICE_ARGS } from "./restart-notice.js";
90
90
  import { stopWhenSessionEnds } from "./shutdown.js";
91
91
  import { BACKGROUND_WORK_SUBCOMMAND, runBackgroundWorkCommand } from "./background-work.js";
92
92
  import { ACTIVITY_SUBCOMMAND, runActivityCommand } from "./activity.js";
@@ -788,8 +788,10 @@ async (args, extra) => jsonResult(await servePlanEventsWait(planEventRelay, args
788
788
  strictTool("restart_notice",
789
789
  // DX-4632 - for the danxbot plugin's session-start hook, not for a model to call. General-audience text (DX-4550).
790
790
  "Which plan the session this one replaced was working, and what waits there. For the danxbot plugin's session start: " +
791
- "a model never calls it. Works before sign-in. Answers `{notice: text}` (tell the session), `{notice: null}` (nothing " +
792
- "to say) or `{stopped: {reason, detail, fix}}`.", {}, async () => jsonResult(await lookupRestartNotice({
791
+ "a model never calls it. Works before sign-in. Answers " +
792
+ "`{notice: text}` (tell the session), `{notice: null}` (nothing to say) or `{stopped: {reason, detail, fix}}`.", RESTART_NOTICE_ARGS, async (args) => jsonResult(await lookupRestartNotice({
793
+ predecessorId: args.predecessor_id,
794
+ transcriptPath: args.transcript_path,
793
795
  sessionId: config.session?.id,
794
796
  cwd: process.cwd(),
795
797
  env: process.env,
@@ -17,17 +17,35 @@
17
17
  * - `{notice: null}` - nothing to say: no earlier session of this project connected a plan, or the plan is archived or
18
18
  * deleted, or the record is stale, or every predecessor is still live. The plugin stays silent, as PLN-11 R-10 requires of
19
19
  * a project that never connected a plan.
20
- * - `{stopped: {reason, detail, fix}}` - the lookup could not answer: no session id, NO candidate answered (credential,
21
- * network, non-2xx, bad shape, for every one), or the one deadline passed. Never a silent null. (A failure that is not
20
+ * - `{stopped: {reason, detail, fix}}` - the lookup could not answer: no session id, an unreadable transcript, a named predecessor
21
+ * with no usable record, NO candidate answered (credential, network, non-2xx, bad shape, for every one), or the one deadline
22
+ * passed. Never a silent null. (A failure that is not
22
23
  * one of these, a bug in the credential resolver, is rethrown: fail loud, never dressed as a lookup failure.)
23
24
  *
24
- * ONE DEADLINE over the whole lookup (`RESTART_NOTICE_DEADLINE_MS`): every candidate's request shares it (and each keeps its own
25
- * shorter bound), so a hung dashboard cannot wedge a session start however many candidates there are. READ-ONLY: it advances
25
+ * WHO THE PREDECESSOR IS (DX-4234). The project scan is a GUESS (the newest non-self record of the project): another session
26
+ * of the same checkout can win it, so a forked session was told about a stranger's plan. The caller can name the predecessor
27
+ * instead, in this precedence (`choosePredecessors`):
28
+ * 1. `predecessor_id` (a Claude Code session id): ask ONLY that session's record. No usable record for it is a `stopped`
29
+ * (`no_predecessor_record`), never a fall back to the guess.
30
+ * 2. `transcript_path` (this session's own transcript, absolute `.jsonl`): the transcript says whether this session was forked
31
+ * and from which session (`readTranscriptLineage`: the nearest ancestor, which for a fork of a fork is not the first row's
32
+ * session). A fork's parent is asked as in (1), except that a parent with no record (it never connected a plan) is
33
+ * `{notice: null}`. A transcript that proves this is NOT a fork (a plain resume) is `{notice: null}` too. One that cannot be
34
+ * read (a bad path, a missing or malformed file, an over-cap row) is a `stopped` (`transcript_unreadable`): never the guess.
35
+ * 3. neither argument: the project scan (a bare startup).
36
+ * A predecessor the caller NAMED is asked with `?named=1`, which tells the dashboard to skip its "that session's stream is still
37
+ * open, so it is a live sibling" check: a fork's parent is usually still live, and the fork still needs to hear its plan.
38
+ *
39
+ * ONE DEADLINE over the whole lookup (`RESTART_NOTICE_DEADLINE_MS`): the transcript read and every candidate's request share it
40
+ * (each request keeps its own shorter bound), so a hung dashboard or a stalled disk cannot wedge a session start. READ-ONLY: it advances
26
41
  * no cursor on the server.
27
42
  */
43
+ import { z } from "zod";
28
44
  import { sendSessionRequest, tryParseJsonBody } from "./one-shot-session-request.js";
29
45
  import { resolveDashboardAuth, SessionStartError } from "./session-options.js";
30
- import { findPredecessorCandidates, projectKeyOf } from "./session-connection.js";
46
+ import { findPredecessorCandidates, projectKeyOf, readSessionConnection } from "./session-connection.js";
47
+ import { checkedTranscriptPath } from "./session-stats.js";
48
+ import { readTranscriptLineage, TranscriptReadAborted, TranscriptUnreadable } from "./transcript-lineage.js";
31
49
  import { fixForStop } from "./stop-fixes.js";
32
50
  export const RESTART_NOTICE_REQUEST_TIMEOUT_MS = 5_000;
33
51
  /** DX-4632: the whole lookup, every candidate included (the old hook's `timeout 8s`). */
@@ -44,7 +62,7 @@ export const RESTART_NOTICE_REMINDER_KEY = "plan_restart.waiting_events";
44
62
  export async function fetchRestartNotice(target, deps) {
45
63
  // The bearer is the predecessor's; the session id header names the ASKING session.
46
64
  const auth = { dashboardUrl: target.dashboardUrl, token: target.token, sessionId: target.askingSessionId };
47
- const sent = await sendSessionRequest(auth, `/api/plan-sessions/${encodeURIComponent(target.predecessorId)}/restart-notice`, { method: "GET" }, deps);
65
+ const sent = await sendSessionRequest(auth, `/api/plan-sessions/${encodeURIComponent(target.predecessorId)}/restart-notice${target.named === true ? "?named=1" : ""}`, { method: "GET" }, deps);
48
66
  if ("ok" in sent)
49
67
  return { ok: false, reason: sent.reason };
50
68
  const { status, text } = sent;
@@ -68,20 +86,94 @@ export async function fetchRestartNotice(target, deps) {
68
86
  return { ok: false, reason: "bad_response" };
69
87
  return { ok: true, text: notice.text };
70
88
  }
89
+ /** The tool's arguments (`index.ts` registers exactly this shape). */
90
+ export const RESTART_NOTICE_ARGS = {
91
+ predecessor_id: z
92
+ .string()
93
+ .min(1)
94
+ .optional()
95
+ .describe("Session id of the session this one replaced, when known. Asked alone, and wins over transcript_path. With neither argument the newest earlier session of the project is guessed."),
96
+ transcript_path: z
97
+ .string()
98
+ .min(1)
99
+ .optional()
100
+ .describe("Absolute path of this session's transcript (.jsonl file): a forked session's copied rows name the session it was forked from."),
101
+ };
71
102
  const stop = (reason, detail) => ({ stopped: { reason, detail, fix: fixForStop(reason) } });
103
+ /**
104
+ * DX-4234 - the module docblock's precedence: a named id, else the transcript's lineage, else the project scan. Throws
105
+ * `TranscriptReadAborted` when the deadline fires mid-read; every other failure is an answer.
106
+ */
107
+ async function choosePredecessors(lookup, ownId, signal) {
108
+ let namedId = lookup.predecessorId;
109
+ let namedBy = "predecessor_id";
110
+ if (namedId === undefined && lookup.transcriptPath !== undefined) {
111
+ let path;
112
+ try {
113
+ path = checkedTranscriptPath(lookup.transcriptPath, "restart_notice transcript_path");
114
+ }
115
+ catch (err) {
116
+ return { answer: stop("transcript_unreadable", err.message) };
117
+ }
118
+ let lineage;
119
+ try {
120
+ lineage = await readTranscriptLineage(path, ownId, { signal, open: lookup.openTranscript });
121
+ }
122
+ catch (err) {
123
+ if (err instanceof TranscriptUnreadable)
124
+ return { answer: stop("transcript_unreadable", err.message) };
125
+ throw err;
126
+ }
127
+ if (lineage.kind === "not_fork")
128
+ return { answer: { notice: null } };
129
+ namedId = lineage.parent;
130
+ namedBy = "transcript_path";
131
+ }
132
+ if (namedId === undefined) {
133
+ const found = findPredecessorCandidates(projectKeyOf(lookup.cwd), ownId, { home: lookup.home, now: lookup.now });
134
+ return { candidates: found.slice(0, RESTART_NOTICE_MAX_CANDIDATES), named: false };
135
+ }
136
+ let record;
137
+ try {
138
+ record = readSessionConnection(namedId, { home: lookup.home });
139
+ }
140
+ catch (err) {
141
+ return { answer: stop("no_predecessor_record", `session ${namedId} (${namedBy}) has no usable dashboard connection record: ${err.message}`) };
142
+ }
143
+ if (record === null) {
144
+ // A parent found through the transcript that never connected a plan has nothing to tell; an id the caller NAMED that has
145
+ // no record is something the caller must hear about.
146
+ if (namedBy === "transcript_path")
147
+ return { answer: { notice: null } };
148
+ return { answer: stop("no_predecessor_record", `session ${namedId} (${namedBy}) has no dashboard connection record on this machine`) };
149
+ }
150
+ return { candidates: [record], named: true };
151
+ }
72
152
  /** The tool's whole body: see the module docblock for the contract. Throws only on a bug, never on a lookup failure. */
73
153
  export async function lookupRestartNotice(lookup) {
74
- if (lookup.sessionId === undefined || lookup.sessionId === "") {
154
+ const ownId = lookup.sessionId;
155
+ if (ownId === undefined || ownId === "") {
75
156
  return stop("no_session_id", "this server has no CLAUDE_CODE_SESSION_ID, so it cannot tell which session it replaced");
76
157
  }
77
158
  const resolveFrom = { home: lookup.home, now: lookup.now };
78
- const candidates = findPredecessorCandidates(projectKeyOf(lookup.cwd), lookup.sessionId, resolveFrom).slice(0, RESTART_NOTICE_MAX_CANDIDATES);
79
- // DX-4632: ONE deadline for every candidate read. It is not unref'd: the lookup is awaited, and a pending request holds the
80
- // process anyway.
159
+ // DX-4632: ONE deadline for the transcript read and every candidate read. It is not unref'd: the lookup is awaited, and a
160
+ // pending request holds the process anyway.
81
161
  const deadline = new AbortController();
82
162
  const deadlineMs = lookup.deadlineMs ?? RESTART_NOTICE_DEADLINE_MS;
83
163
  const timer = setTimeout(() => deadline.abort(), deadlineMs);
84
164
  try {
165
+ let choice;
166
+ try {
167
+ choice = await choosePredecessors(lookup, ownId, deadline.signal);
168
+ }
169
+ catch (err) {
170
+ if (!(err instanceof TranscriptReadAborted))
171
+ throw err;
172
+ return stop("lookup_timeout", `the lookup did not finish within ${deadlineMs} ms (it was still reading the transcript)`);
173
+ }
174
+ if ("answer" in choice)
175
+ return choice.answer;
176
+ const { candidates, named } = choice;
85
177
  // One candidate failing (its credential moved, its dashboard is down) must not hide a LATER candidate that
86
178
  // would answer: keep going, and report failure only when no candidate answered at all.
87
179
  const failures = [];
@@ -101,7 +193,8 @@ export async function lookupRestartNotice(lookup) {
101
193
  dashboardUrl: options.dashboardUrl,
102
194
  token: options.token,
103
195
  predecessorId: predecessor.sessionId,
104
- askingSessionId: lookup.sessionId,
196
+ askingSessionId: ownId,
197
+ named,
105
198
  }, { fetch: lookup.fetch, requestTimeoutMs: lookup.requestTimeoutMs ?? RESTART_NOTICE_REQUEST_TIMEOUT_MS, signal: deadline.signal });
106
199
  // A notice the dashboard fully answered is kept even when the deadline fired right after it; every other outcome of a
107
200
  // deadline that passed is the stop below (this is the lookup's one deadline check).
@@ -37,6 +37,8 @@ export const STOP_FIXES = {
37
37
  // DX-4632: `restart_notice` only tells a restarted session which plan its predecessor was on; none of its stops blocks anything.
38
38
  no_session_id: `${RESTART_NOTICE_STOP_LEAD}: this server was started without a session id, so it cannot tell which session it replaced; ${RESTART_NOTICE_STOP_TAIL}`,
39
39
  lookup_timeout: `${RESTART_NOTICE_STOP_LEAD}: the dashboard did not answer in time; ${RESTART_NOTICE_STOP_TAIL}`,
40
+ no_predecessor_record: `${RESTART_NOTICE_STOP_LEAD}: this machine holds no usable record of the named earlier session, so there is nothing to carry over; ${RESTART_NOTICE_STOP_TAIL}`,
41
+ transcript_unreadable: `${RESTART_NOTICE_STOP_LEAD}: this session's transcript could not be read to find the session it replaced (the detail says why); ${RESTART_NOTICE_STOP_TAIL}`,
40
42
  lookup_failed: `${RESTART_NOTICE_STOP_LEAD}: no earlier session's record could be checked (the detail says why); ${RESTART_NOTICE_STOP_TAIL}`,
41
43
  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",
42
44
  };
@@ -0,0 +1,105 @@
1
+ /**
2
+ * DX-4234 - who a session was FORKED from, read from its own transcript. For the `restart_notice` tool.
3
+ *
4
+ * A desktop fork copies its parent's rows into the new transcript, then appends its own. The copied rows carry the session
5
+ * ids of the ancestors that wrote them, so a transcript runs `root ... parent ... own`: for a fork of a fork the FIRST row
6
+ * names the ROOT ancestor, not the parent. The parent is the LAST session id before the first row carrying this session's own
7
+ * id, so the read goes forward and stops at that row (it reads the copied prefix, never the rest of a file that can be tens
8
+ * of MB).
9
+ *
10
+ * ANSWERS:
11
+ * - `{kind: "fork", parent}` - the nearest earlier session id.
12
+ * - `{kind: "not_fork"}` - the first row naming a session is this session's own (a plain resume or a fresh start): it was
13
+ * not forked from anything.
14
+ * - throws `TranscriptUnreadable` - the file cannot say (missing, unreadable, a row that is not JSON, a row or prefix over
15
+ * the caps below, no row names a session). The caller must be told; it is never a "not a fork".
16
+ * - throws `TranscriptReadAborted` - the caller's deadline signal fired mid-read.
17
+ *
18
+ * BOUNDED: one row may be at most `TRANSCRIPT_MAX_LINE_BYTES` (a first row can be 428 KB or more) and the prefix read at most
19
+ * `TRANSCRIPT_MAX_PREFIX_BYTES`; over either is `TranscriptUnreadable`, never a quiet truncation.
20
+ */
21
+ import { createReadStream } from "node:fs";
22
+ export const TRANSCRIPT_MAX_LINE_BYTES = 4 * 1024 * 1024;
23
+ export const TRANSCRIPT_MAX_PREFIX_BYTES = 64 * 1024 * 1024;
24
+ export class TranscriptUnreadable extends Error {
25
+ }
26
+ export class TranscriptReadAborted extends Error {
27
+ }
28
+ const NEWLINE = 0x0a;
29
+ export async function readTranscriptLineage(path, ownSessionId, options = {}) {
30
+ const source = options.open?.(path) ?? createReadStream(path);
31
+ const stop = () => {
32
+ source.destroy?.();
33
+ };
34
+ options.signal?.addEventListener("abort", stop, { once: true });
35
+ let carry = Buffer.alloc(0);
36
+ let total = 0;
37
+ let row = 0;
38
+ const seen = { nearest: null };
39
+ /** One complete line: the lineage when it settles the answer, else `undefined` to keep reading. */
40
+ const settle = (line) => {
41
+ if (line.toString("utf-8").trim() === "")
42
+ return undefined;
43
+ row += 1;
44
+ let parsed;
45
+ try {
46
+ parsed = JSON.parse(line.toString("utf-8"));
47
+ }
48
+ catch (err) {
49
+ throw new TranscriptUnreadable(`row ${row} of ${path} is not JSON: ${err.message}`);
50
+ }
51
+ const id = parsed?.sessionId;
52
+ if (typeof id !== "string" || id === "")
53
+ return undefined;
54
+ if (id !== ownSessionId) {
55
+ seen.nearest = id;
56
+ return undefined;
57
+ }
58
+ return seen.nearest === null ? { kind: "not_fork" } : { kind: "fork", parent: seen.nearest };
59
+ };
60
+ try {
61
+ for await (const chunk of source) {
62
+ if (options.signal?.aborted === true)
63
+ throw new TranscriptReadAborted("the deadline passed");
64
+ total += chunk.length;
65
+ if (total > TRANSCRIPT_MAX_PREFIX_BYTES) {
66
+ throw new TranscriptUnreadable(`${path} has no row of this session within its first ${TRANSCRIPT_MAX_PREFIX_BYTES} bytes`);
67
+ }
68
+ let start = 0;
69
+ for (let end = chunk.indexOf(NEWLINE, start); end !== -1; end = chunk.indexOf(NEWLINE, start)) {
70
+ const piece = chunk.subarray(start, end);
71
+ if (carry.length + piece.length > TRANSCRIPT_MAX_LINE_BYTES) {
72
+ throw new TranscriptUnreadable(`row ${row + 1} of ${path} is over ${TRANSCRIPT_MAX_LINE_BYTES} bytes`);
73
+ }
74
+ const settled = settle(Buffer.concat([carry, piece]));
75
+ carry = Buffer.alloc(0);
76
+ if (settled !== undefined)
77
+ return settled;
78
+ start = end + 1;
79
+ }
80
+ const rest = chunk.subarray(start);
81
+ if (carry.length + rest.length > TRANSCRIPT_MAX_LINE_BYTES) {
82
+ throw new TranscriptUnreadable(`row ${row + 1} of ${path} is over ${TRANSCRIPT_MAX_LINE_BYTES} bytes`);
83
+ }
84
+ carry = Buffer.concat([carry, rest]);
85
+ }
86
+ }
87
+ catch (err) {
88
+ if (options.signal?.aborted === true)
89
+ throw new TranscriptReadAborted("the deadline passed");
90
+ if (err instanceof TranscriptUnreadable || err instanceof TranscriptReadAborted)
91
+ throw err;
92
+ throw new TranscriptUnreadable(`cannot read ${path}: ${err.message}`);
93
+ }
94
+ finally {
95
+ options.signal?.removeEventListener("abort", stop);
96
+ stop();
97
+ }
98
+ const settled = settle(carry);
99
+ if (settled !== undefined)
100
+ return settled;
101
+ if (seen.nearest === null)
102
+ throw new TranscriptUnreadable(`no row of ${path} names a session`);
103
+ // The file ends before any row of this session: everything in it was copied from an earlier one.
104
+ return { kind: "fork", parent: seen.nearest };
105
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thehammer/danx-dashboard-mcp",
3
- "version": "0.1.276",
3
+ "version": "0.1.277",
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",