@thehammer/danx-dashboard-mcp 0.1.274 → 0.1.276

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 CHANGED
@@ -78,11 +78,17 @@ The contract the plugin builds against:
78
78
  - **Nothing outlives the session.** The heartbeat interval, the backoff sleep and the digest hold are unref'd; `close()` (run when the transport closes and when stdin ends) aborts the stream and the mint and inventory requests and clears a pending call's wait timer, the one ref'd timer, which otherwise holds the process only until the call answers.
79
79
  - **Credential and audience.** The server's own, from its boot-time `CLAUDE_CODE_SESSION_ID`: no connection record, fingerprint check or environment variable is involved. The tool is advertised only to a person's session holding `plans.view`, never to a dispatch. The stream's advisory frames (the idle nudge, DX-4340's pacing message, DX-4342's auto-resume message) arrive as records like any event, at once and never held for a digest. The digest (agent and machine events held up to 10 minutes) is one record, `[plan events] digest`, and a held id is never acknowledged before it is returned.
80
80
 
81
- The one-shot subcommands below (`activity`, `background-work`, `event-text`, `ready-cards`, `restart-notice`) still read the connection record this server wrote on `plan_connect` (`src/session-connection.ts`) and refuse to run unless the credential it names fingerprints identically (`credential_mismatch`, DX-2862).
81
+ The one-shot subcommands below (`activity`, `background-work`, `ready-cards`) still read the connection record this server wrote on `plan_connect` (`src/session-connection.ts`) and refuse to run unless the credential it names fingerprints identically (`credential_mismatch`, DX-2862).
82
82
 
83
- ## `restart-notice` — what a restarted session was working on (DX-3928)
83
+ ## `restart_notice` — what a restarted session was working on (DX-3928, DX-4632)
84
84
 
85
- Run by the danxbot plugin's SessionStart hook for a session that is **not** plan-connected. A restarted Claude Code session gets a new session id and no plan binding, so nothing told it that the plan it had been working was unattended. `plan_connect` writes the session's working directory into its connection record (`projectKey`, record schema v2); this subcommand finds the newest OTHER session's record for the same project within `STALE_RECORD_MS`, resolves that record's own credential, and asks `GET /api/plan-sessions/<predecessor>/restart-notice` which plan it was on and how many operator events wait there (the connect digest's own query, read-only). Exit 0 with text on stdout = say it (the registry's `plan_restart.waiting_events` text — this package carries no wording); exit 0 with nothing = nothing to say (never connected from this project, plan archived or deleted, record stale); exit 1 = the lookup failed. A record from an older server (schema v1) is skipped here and refused, with the fix, everywhere a session reads its OWN record.
85
+ A tool for the danxbot plugin's session-start hook, not for a model, advertised only on a person's session (never a dispatch) and **usable before sign-in**: a restarted Claude Code session gets a new session id, no plan binding and, until a person approves its first call, no dashboard key of its own, so nothing else could tell it that the plan it had been working was unattended. `plan_connect` writes the session's working directory into its connection record (`projectKey`, record schema v2); `restart_notice` finds the newest OTHER sessions' records for the same project (at most ten, each within `STALE_RECORD_MS`), resolves each record's own credential, and asks `GET /api/plan-sessions/<predecessor>/restart-notice` which plan it was on and how many operator events wait there (the connect digest's own query, read-only). No arguments. Answers one of:
86
+
87
+ - `{notice: "<text>"}`: say it (the registry's `plan_restart.waiting_events` text; this package carries no wording).
88
+ - `{notice: null}`: nothing to say (no earlier session of this project connected a plan, the plan is archived or deleted, the record is stale, or every predecessor is still live).
89
+ - `{stopped: {reason, detail, fix}}`: the lookup could not answer: `no_session_id`, `lookup_failed` (no candidate answered; the detail names each reason) or `lookup_timeout`. Never a silent null.
90
+
91
+ ONE deadline of 8 s bounds the whole lookup, every candidate included (each request also keeps its own 5 s bound), so a hung dashboard answers `lookup_timeout` and never wedges a session start. A record from an older server (schema v1) is skipped here and refused, with the fix, everywhere a session reads its OWN record.
86
92
 
87
93
  ## `ready-cards` — the plan's cards anyone could pick up now (DX-4534)
88
94
 
package/dist/index.js CHANGED
@@ -86,11 +86,10 @@ 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
90
  import { stopWhenSessionEnds } from "./shutdown.js";
90
91
  import { BACKGROUND_WORK_SUBCOMMAND, runBackgroundWorkCommand } from "./background-work.js";
91
92
  import { ACTIVITY_SUBCOMMAND, runActivityCommand } from "./activity.js";
92
- import { EVENT_TEXT_SUBCOMMAND, runEventTextCommand } from "./event-text.js";
93
- import { RESTART_NOTICE_SUBCOMMAND, runRestartNoticeCommand } from "./restart-notice.js";
94
93
  import { READY_CARDS_SUBCOMMAND, runReadyCardsCommand } from "./ready-cards.js";
95
94
  import { SUBAGENTS_LIVE_SUBCOMMAND, runSubagentsLiveCommand } from "./subagents-live.js";
96
95
  import { resolveBootCredential } from "./credential.js";
@@ -785,6 +784,17 @@ strictTool("plan_events_wait",
785
784
  // The relay is bound to the session the server booted for (the id every other tool on this server also sends); a server
786
785
  // with none holds no session key, so the surface never shows this tool and the relay would answer `credential_unavailable`.
787
786
  async (args, extra) => jsonResult(await servePlanEventsWait(planEventRelay, args, extra.signal)));
787
+ // ---------------- restart_notice (DX-4632) ----------------
788
+ strictTool("restart_notice",
789
+ // DX-4632 - for the danxbot plugin's session-start hook, not for a model to call. General-audience text (DX-4550).
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({
793
+ sessionId: config.session?.id,
794
+ cwd: process.cwd(),
795
+ env: process.env,
796
+ fetch: (input, init) => fetch(input, init),
797
+ })));
788
798
  // ---------------- main ----------------
789
799
  async function main() {
790
800
  boot();
@@ -808,7 +818,7 @@ async function main() {
808
818
  // symlink-aware (DX-1647) so it holds under the symlinked `npx` bin the worker
809
819
  // spawns, not just a direct `node dist/index.js`.
810
820
  //
811
- // The subcommands are one-shot clients (activity, background-work, event-text, ready-cards, restart-notice, subagents-live)
821
+ // The subcommands are one-shot clients (activity, background-work, ready-cards, subagents-live)
812
822
  // the danxbot plugin runs. None boots the MCP server; the dashboard URL, credential source and credential come from the
813
823
  // session-connection record this server's own `plan_connect` wrote (`session-connection.ts`), never from the subcommand's own
814
824
  // environment (DX-2862: trusting an ambient env credential is the exact bug this repo fixed). There is no event-stream
@@ -834,26 +844,8 @@ if (isEntrypointModule(import.meta.url, process.argv[1])) {
834
844
  process.exit(0);
835
845
  });
836
846
  }
837
- else if (subcommand === EVENT_TEXT_SUBCOMMAND) {
838
- // DX-3421 (was DX-3366's `mantra` subcommand) — unlike background-work, a
839
- // rejection here is a genuine fatal (never expected: runEventTextCommand
840
- // itself already classifies every network/shape failure into its own
841
- // {ok:false} exit 1), so it exits 1 too rather than the "always 0, stay
842
- // silent" contract background-work's hook-only subcommand uses.
843
- runEventTextCommand(rest).then((code) => process.exit(code), (err) => {
844
- console.error(`[danx-dashboard-mcp] event-text fatal: ${err.message}`);
845
- process.exit(1);
846
- });
847
- }
848
- else if (subcommand === RESTART_NOTICE_SUBCOMMAND) {
849
- // DX-3928 — same exit-code contract as event-text: 0 (text, or nothing to say) / 1 (lookup failed).
850
- runRestartNoticeCommand(rest).then((code) => process.exit(code), (err) => {
851
- console.error(`[danx-dashboard-mcp] restart-notice fatal: ${err.message}`);
852
- process.exit(1);
853
- });
854
- }
855
847
  else if (subcommand === READY_CARDS_SUBCOMMAND) {
856
- // DX-4534 — the plugin Stop hook's read; the event-text exit-code contract: 0 (one JSON line) / 1 (read failed).
848
+ // DX-4534 — the plugin Stop hook's read; exit 0 (one JSON line) / 1 (read failed).
857
849
  runReadyCardsCommand(rest).then((code) => process.exit(code), (err) => {
858
850
  console.error(`[danx-dashboard-mcp] ${READY_CARDS_SUBCOMMAND} fatal: ${err.message}`);
859
851
  process.exit(1);
@@ -869,8 +861,8 @@ if (isEntrypointModule(import.meta.url, process.argv[1])) {
869
861
  }
870
862
  else if (subcommand !== undefined) {
871
863
  console.error(`[danx-dashboard-mcp] unknown subcommand "${subcommand}" (the only ones are ` +
872
- `"${BACKGROUND_WORK_SUBCOMMAND}", "${ACTIVITY_SUBCOMMAND}", "${EVENT_TEXT_SUBCOMMAND}", ` +
873
- `"${RESTART_NOTICE_SUBCOMMAND}", "${READY_CARDS_SUBCOMMAND}", and "${SUBAGENTS_LIVE_SUBCOMMAND}")`);
864
+ `"${BACKGROUND_WORK_SUBCOMMAND}", "${ACTIVITY_SUBCOMMAND}", ` +
865
+ `"${READY_CARDS_SUBCOMMAND}", and "${SUBAGENTS_LIVE_SUBCOMMAND}")`);
874
866
  process.exit(2);
875
867
  }
876
868
  else {
package/dist/listen.js CHANGED
@@ -181,6 +181,13 @@ export function invalidEventReason(value) {
181
181
  }
182
182
  case "blocked":
183
183
  return isCappedText(d.reason) ? null : "detail.reason is not capped text";
184
+ // DX-4622: a reviewer asked for changes on the card's pull request (GitHub review webhook).
185
+ case "pull_request_changes_requested":
186
+ if (typeof d.reviewer !== "string")
187
+ return "detail.reviewer is not a string";
188
+ if (typeof d.review_url !== "string")
189
+ return "detail.review_url is not a string";
190
+ return isCappedText(d.excerpt) ? null : "detail.excerpt is not capped text";
184
191
  default:
185
192
  return null;
186
193
  }
@@ -256,6 +263,9 @@ function describe(event) {
256
263
  return `${who} blocked the card: ${quoted(d.reason)}`;
257
264
  case "unblocked":
258
265
  return `${who} unblocked the card`;
266
+ // DX-4622: the agent must act on the review, so the line names who, where, and what to do next.
267
+ case "pull_request_changes_requested":
268
+ return `${who}: ${d.reviewer} requested changes on the pull request (${d.review_url})${d.excerpt.text === "" ? "" : `: ${quoted(d.excerpt)}`}. Address the review, push, and reply on the pull request`;
259
269
  default:
260
270
  // A kind newer than this listener still produces a line: an event the
261
271
  // agent is never told about is exactly the failure this reader prevents.
@@ -24,6 +24,7 @@
24
24
  * non-Error) still comes back as a reported reason instead of a value
25
25
  * whose `.reason`/`.message`/`.fix` fields are silently `undefined`.
26
26
  */
27
+ import { boundRequest } from "./request-bound.js";
27
28
  import { SESSION_ID_HEADER, resolveDashboardAuth, SessionStartError } from "./session-options.js";
28
29
  /**
29
30
  * One authenticated HTTP call to this session's dashboard, with a hard
@@ -32,8 +33,7 @@ import { SESSION_ID_HEADER, resolveDashboardAuth, SessionStartError } from "./se
32
33
  * might produce from the status/body it gets back on success.
33
34
  */
34
35
  export async function sendSessionRequest(options, path, init, deps) {
35
- const controller = new AbortController();
36
- const timer = setTimeout(() => controller.abort(), deps.requestTimeoutMs);
36
+ const bound = boundRequest(deps.requestTimeoutMs, deps.signal);
37
37
  try {
38
38
  try {
39
39
  const response = await deps.fetch(`${options.dashboardUrl}${path}`, {
@@ -45,7 +45,7 @@ export async function sendSessionRequest(options, path, init, deps) {
45
45
  [SESSION_ID_HEADER]: options.sessionId,
46
46
  },
47
47
  body: init.body,
48
- signal: controller.signal,
48
+ signal: bound.signal,
49
49
  });
50
50
  const text = await response.text();
51
51
  return { status: response.status, text };
@@ -54,11 +54,11 @@ export async function sendSessionRequest(options, path, init, deps) {
54
54
  // DX-3367 fix-up — the caught value is never inspected (an aborted
55
55
  // fetch's own rejection shape varies by runtime), so binding it to a
56
56
  // name the code never reads was dead weight the linter never caught.
57
- return { ok: false, reason: controller.signal.aborted ? "timeout" : "request_failed" };
57
+ return { ok: false, reason: bound.aborted() ? "timeout" : "request_failed" };
58
58
  }
59
59
  }
60
60
  finally {
61
- clearTimeout(timer);
61
+ bound.release();
62
62
  }
63
63
  }
64
64
  /** `JSON.parse`, classified rather than thrown — the ONE shared parse point
@@ -0,0 +1,22 @@
1
+ /**
2
+ * DX-4632 - the ONE bound every dashboard request of this package shares: a timeout of its own, plus an OUTER signal that ends
3
+ * the request early (a lookup's one deadline, a closed relay). Used by `sendSessionRequest` (`one-shot-session-request.ts`: the
4
+ * one-shot subcommands and `restart_notice`) and by `request` in `session-stream.ts` (the relay's mint, inventory and
5
+ * heartbeat), so the controller, the timer, the link to the outer signal and the cleanup exist once.
6
+ */
7
+ export function boundRequest(timeoutMs, outer) {
8
+ const controller = new AbortController();
9
+ const abort = () => controller.abort();
10
+ const timer = setTimeout(abort, timeoutMs);
11
+ if (outer?.aborted === true)
12
+ abort();
13
+ outer?.addEventListener("abort", abort, { once: true });
14
+ return {
15
+ signal: controller.signal,
16
+ aborted: () => controller.signal.aborted,
17
+ release: () => {
18
+ clearTimeout(timer);
19
+ outer?.removeEventListener("abort", abort);
20
+ },
21
+ };
22
+ }
@@ -1,83 +1,61 @@
1
1
  /**
2
- * `danx-dashboard-mcp restart-notice` — DX-3928. The ONE-SHOT client the
3
- * claude-plugins `danxbot` plugin's SessionStart hook runs for a session that is
4
- * NOT plan-connected, to tell it when the previous session of the same project
5
- * was working a plan.
2
+ * The `restart_notice` tool - DX-3928, DX-4632. How a RESTARTED Claude Code session learns which plan the session it replaced was
3
+ * working, so it can say so to its agent and reconnect.
6
4
  *
7
- * WHY IT EXISTS. A restarted Claude Code session gets a NEW session id and no
8
- * plan binding, and nothing told it (or the operator) that the plan it had been
9
- * working was unattended: six operator answers sat unread for an hour on
10
- * 2026-10-01. The session's own connection record cannot help (it is the new
11
- * session's, and does not exist yet); the PREVIOUS session's record can, found by
12
- * the project key `plan_connect` wrote into it (`findPredecessorCandidates`).
5
+ * WHY IT EXISTS. A restarted session gets a NEW session id, no plan binding and, until a person approves its first call, no
6
+ * dashboard key of its own: its MCP server boots signed out. Nothing told it (or the operator) that the plan it had been
7
+ * working was unattended: six operator answers sat unread for an hour on 2026-10-01. The session's own connection record cannot
8
+ * help (it does not exist yet); the PREVIOUS session's record can, found by the project key `plan_connect` wrote into it
9
+ * (`findPredecessorCandidates`). So this asks the dashboard about each predecessor as the predecessor, with the credential
10
+ * that predecessor's record names (`resolveDashboardAuth`, the resolver the one-shot subcommands use), which a signed-out
11
+ * session needs no key of its own to do. The tool is therefore usable signed out: it is declared like `plan_connect` (no
12
+ * permission) and only on a person's session (`tool-access.ts`).
13
13
  *
14
- * THE CONTRACT, which `event-hook.sh` reads by exit code:
15
- * - exit 0, text on stdout: say this to the agent (the registry's
16
- * `plan_restart.waiting_events` text — this module carries no wording).
17
- * - exit 0, nothing on stdout: nothing to say — no earlier session of this
18
- * project connected a plan, or the plan is archived/deleted, or the record is
19
- * stale. The plugin stays silent, as PLN-11 R-10 requires of a project that
20
- * never connected a plan.
21
- * - exit 1, reasons on stderr: NO candidate answered (credential, network,
22
- * non-2xx, bad shape, for every one). Never silent: the hook prints a could-not-load notice.
14
+ * THE ANSWER (the plugin's hooks module reads it through `$.mcp.call`):
15
+ * - `{notice: "<text>"}` - say this to the agent (the registry's `plan_restart.waiting_events` text; this module carries no
16
+ * wording of its own).
17
+ * - `{notice: null}` - nothing to say: no earlier session of this project connected a plan, or the plan is archived or
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
+ * 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
22
+ * one of these, a bug in the credential resolver, is rethrown: fail loud, never dressed as a lookup failure.)
23
23
  *
24
- * The credential is the PREDECESSOR's own record's source, resolved through the
25
- * same resolver the relay uses, so the lookup reaches the dashboard that
26
- * connected it. READ-ONLY: it advances no cursor on the server.
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
26
+ * no cursor on the server.
27
27
  */
28
- import { SESSION_ID_HEADER, resolveDashboardAuth } from "./session-options.js";
28
+ import { sendSessionRequest, tryParseJsonBody } from "./one-shot-session-request.js";
29
+ import { resolveDashboardAuth, SessionStartError } from "./session-options.js";
29
30
  import { findPredecessorCandidates, projectKeyOf } from "./session-connection.js";
30
- export const RESTART_NOTICE_SUBCOMMAND = "restart-notice";
31
+ import { fixForStop } from "./stop-fixes.js";
31
32
  export const RESTART_NOTICE_REQUEST_TIMEOUT_MS = 5_000;
33
+ /** DX-4632: the whole lookup, every candidate included (the old hook's `timeout 8s`). */
34
+ export const RESTART_NOTICE_DEADLINE_MS = 8_000;
32
35
  /**
33
- * How many earlier sessions of the project one run asks about, newest first. Many
36
+ * How many earlier sessions of the project one lookup asks about, newest first. Many
34
37
  * sessions share one checkout and the dashboard answers "nothing" for a LIVE sibling
35
38
  * (it is not a predecessor), so the newest candidate is often not the one.
36
39
  */
37
40
  export const RESTART_NOTICE_MAX_CANDIDATES = 10;
38
41
  /** The registry row whose text is the notice (`src/issues/reminders/seed.yaml`). */
39
42
  export const RESTART_NOTICE_REMINDER_KEY = "plan_restart.waiting_events";
40
- const USAGE = `usage: CLAUDE_CODE_SESSION_ID=<session-id> danx-dashboard-mcp ${RESTART_NOTICE_SUBCOMMAND} [--cwd <project directory>]`;
41
- /** One authenticated GET with a hard timeout, answering the notice text or `null`. */
43
+ /** One authenticated GET (the shared one-shot request, so its bound and its failure names are the same), answering the notice text or `null`. */
42
44
  export async function fetchRestartNotice(target, deps) {
43
- const controller = new AbortController();
44
- const timer = setTimeout(() => controller.abort(), deps.requestTimeoutMs);
45
- let status;
46
- let text;
47
- try {
48
- try {
49
- const response = await deps.fetch(`${target.dashboardUrl}/api/plan-sessions/${encodeURIComponent(target.predecessorId)}/restart-notice`, {
50
- method: "GET",
51
- headers: {
52
- Authorization: `Bearer ${target.token}`,
53
- Accept: "application/json",
54
- [SESSION_ID_HEADER]: target.askingSessionId,
55
- },
56
- signal: controller.signal,
57
- });
58
- status = response.status;
59
- text = await response.text();
60
- }
61
- catch {
62
- return { ok: false, reason: controller.signal.aborted ? "timeout" : "request_failed" };
63
- }
64
- }
65
- finally {
66
- clearTimeout(timer);
67
- }
45
+ // The bearer is the predecessor's; the session id header names the ASKING session.
46
+ 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);
48
+ if ("ok" in sent)
49
+ return { ok: false, reason: sent.reason };
50
+ const { status, text } = sent;
68
51
  if (status === 401 || status === 403)
69
52
  return { ok: false, reason: "unauthorized" };
70
53
  if (status === 404)
71
54
  return { ok: false, reason: "not_found" };
72
55
  if (status < 200 || status >= 300)
73
56
  return { ok: false, reason: "http_error" };
74
- let body;
75
- try {
76
- body = JSON.parse(text);
77
- }
78
- catch {
79
- return { ok: false, reason: "bad_response" };
80
- }
57
+ const parsed = tryParseJsonBody(text);
58
+ const body = parsed.ok ? parsed.body : null;
81
59
  if (typeof body !== "object" || body === null || !("restart" in body))
82
60
  return { ok: false, reason: "bad_response" };
83
61
  if (body.restart === null)
@@ -90,61 +68,63 @@ export async function fetchRestartNotice(target, deps) {
90
68
  return { ok: false, reason: "bad_response" };
91
69
  return { ok: true, text: notice.text };
92
70
  }
93
- function parseCwd(argv) {
94
- if (argv.length === 0)
95
- return process.cwd();
96
- if (argv.length === 2 && argv[0] === "--cwd" && argv[1] !== "")
97
- return argv[1];
98
- return "invalid";
99
- }
100
- /** The bin's `restart-notice` subcommand, wired to the real process. */
101
- export async function runRestartNoticeCommand(argv, env = process.env,
102
- /** Test seam: the home the connection records are read from. */
103
- resolveFrom = {}) {
104
- const cwd = parseCwd(argv);
105
- if (cwd === "invalid") {
106
- process.stderr.write(`bad arguments\n${USAGE}\n`);
107
- return 1;
108
- }
109
- const sessionId = env.CLAUDE_CODE_SESSION_ID;
110
- if (!sessionId) {
111
- process.stderr.write(`no_session_id: CLAUDE_CODE_SESSION_ID is not set\n${USAGE}\n`);
112
- return 1;
71
+ const stop = (reason, detail) => ({ stopped: { reason, detail, fix: fixForStop(reason) } });
72
+ /** The tool's whole body: see the module docblock for the contract. Throws only on a bug, never on a lookup failure. */
73
+ export async function lookupRestartNotice(lookup) {
74
+ if (lookup.sessionId === undefined || lookup.sessionId === "") {
75
+ return stop("no_session_id", "this server has no CLAUDE_CODE_SESSION_ID, so it cannot tell which session it replaced");
113
76
  }
114
- const candidates = findPredecessorCandidates(projectKeyOf(cwd), sessionId, resolveFrom).slice(0, RESTART_NOTICE_MAX_CANDIDATES);
115
- // One candidate failing (its credential moved, its dashboard is down) must not hide a LATER candidate that
116
- // would answer: keep going, and report failure only when no candidate answered at all.
117
- const failures = [];
118
- let answered = 0;
119
- for (const predecessor of candidates) {
120
- let options;
121
- try {
122
- options = resolveDashboardAuth(predecessor.sessionId, env, resolveFrom);
123
- }
124
- catch (err) {
125
- const start = err;
126
- failures.push(`${start.reason}: ${start.message}. Fix: ${start.fix}.`);
127
- continue;
77
+ 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.
81
+ const deadline = new AbortController();
82
+ const deadlineMs = lookup.deadlineMs ?? RESTART_NOTICE_DEADLINE_MS;
83
+ const timer = setTimeout(() => deadline.abort(), deadlineMs);
84
+ try {
85
+ // One candidate failing (its credential moved, its dashboard is down) must not hide a LATER candidate that
86
+ // would answer: keep going, and report failure only when no candidate answered at all.
87
+ const failures = [];
88
+ let answered = 0;
89
+ for (const predecessor of candidates) {
90
+ let options;
91
+ try {
92
+ options = resolveDashboardAuth(predecessor.sessionId, lookup.env, resolveFrom);
93
+ }
94
+ catch (err) {
95
+ if (!(err instanceof SessionStartError))
96
+ throw err; // a bug in the resolver is not a lookup failure: fail loud
97
+ failures.push(`${err.reason}: ${err.message}. Fix: ${err.fix}.`);
98
+ continue;
99
+ }
100
+ const output = await fetchRestartNotice({
101
+ dashboardUrl: options.dashboardUrl,
102
+ token: options.token,
103
+ predecessorId: predecessor.sessionId,
104
+ askingSessionId: lookup.sessionId,
105
+ }, { fetch: lookup.fetch, requestTimeoutMs: lookup.requestTimeoutMs ?? RESTART_NOTICE_REQUEST_TIMEOUT_MS, signal: deadline.signal });
106
+ // A notice the dashboard fully answered is kept even when the deadline fired right after it; every other outcome of a
107
+ // deadline that passed is the stop below (this is the lookup's one deadline check).
108
+ if (output.ok && output.text !== null)
109
+ return { notice: output.text };
110
+ if (deadline.signal.aborted)
111
+ break;
112
+ if (!output.ok) {
113
+ failures.push(`${output.reason}: could not ask the dashboard what session ${predecessor.sessionId} was working`);
114
+ continue;
115
+ }
116
+ answered += 1;
128
117
  }
129
- const output = await fetchRestartNotice({
130
- dashboardUrl: options.dashboardUrl,
131
- token: options.token,
132
- predecessorId: predecessor.sessionId,
133
- askingSessionId: sessionId,
134
- }, { fetch: (input, init) => fetch(input, init), requestTimeoutMs: RESTART_NOTICE_REQUEST_TIMEOUT_MS });
135
- if (!output.ok) {
136
- failures.push(`${output.reason}: could not ask the dashboard what session ${predecessor.sessionId} was working`);
137
- continue;
118
+ // DX-4632: a deadline that passed is a stop even when earlier candidates answered "nothing": an unasked candidate may be the one.
119
+ if (deadline.signal.aborted) {
120
+ return stop("lookup_timeout", `the lookup did not finish within ${deadlineMs} ms (${failures.length} of ${candidates.length} candidates had failed)`);
138
121
  }
139
- answered += 1;
140
- if (output.text !== null) {
141
- process.stdout.write(output.text);
142
- return 0;
122
+ if (answered === 0 && failures.length > 0) {
123
+ return stop("lookup_failed", `no candidate answered (${failures.length} of ${candidates.length} failed): ${failures.join(" | ")}`);
143
124
  }
125
+ return { notice: null };
144
126
  }
145
- if (answered === 0 && failures.length > 0) {
146
- process.stderr.write(`no candidate answered (${failures.length} of ${candidates.length} failed):\n${failures.join("\n")}\n`);
147
- return 1;
127
+ finally {
128
+ clearTimeout(timer);
148
129
  }
149
- return 0;
150
130
  }
@@ -2,8 +2,8 @@
2
2
  * DX-2862 — the record that hands ONE Claude Code session's dashboard
3
3
  * connection from its MCP server to the plugin's hook subcommands.
4
4
  *
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
5
+ * THE PROBLEM THIS SOLVES. A hook's subcommand (`activity`, `background-work`,
6
+ * `ready-cards`) runs in the SESSION's environment, not in the MCP server's, which is the only
7
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
8
  * simply used its own ambient env credential. When that was a different credential from the server's, its report was
9
9
  * dropped silently (the card's incident). The plan event relay (`plan_events_wait`) needs none of this: it runs inside the
@@ -157,7 +157,7 @@ export function recordSessionConnectionAfterConnect(result, inputs, write = writ
157
157
  if (inputs.sessionId === undefined || inputs.sessionId === "") {
158
158
  return {
159
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",
160
+ "the plugin's activity, background-work and ready-cards reports will not reach the dashboard",
161
161
  };
162
162
  }
163
163
  try {
@@ -173,7 +173,7 @@ export function recordSessionConnectionAfterConnect(result, inputs, write = writ
173
173
  catch (err) {
174
174
  return {
175
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",
176
+ "the plugin's activity, background-work and ready-cards reports will not reach the dashboard until it succeeds",
177
177
  };
178
178
  }
179
179
  }
@@ -1,6 +1,6 @@
1
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
2
+ * DX-2862 / DX-4233 - how a one-shot client of the dashboard (`activity`, `background-work`, `ready-cards`, and the
3
+ * predecessor lookup of `restart_notice`) learns WHICH dashboard to call and AS WHOM: the connection record this session's own MCP server wrote when
4
4
  * it connected a plan (`session-connection.ts`), resolved through the same credential resolver the server used, and refused
5
5
  * unless it fingerprints identically. (The in-process relay behind `plan_events_wait` needs none of this: it lives inside the
6
6
  * server that holds the credential.)
@@ -7,6 +7,7 @@
7
7
  * what a stop means for the session) belongs to the relay.
8
8
  */
9
9
  import { errorCodeOf } from "./json-body.js";
10
+ import { boundRequest } from "./request-bound.js";
10
11
  import { keyRevocationOf, keyRevokedHalt } from "./key-revoked-halt.js";
11
12
  import { oneLine } from "./one-line.js";
12
13
  import { SESSION_ID_HEADER } from "./session-options.js";
@@ -64,12 +65,7 @@ const TRANSIENT_CLIENT_STATUSES = new Set([408, 429]);
64
65
  * failures are worth retrying.
65
66
  */
66
67
  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 });
68
+ const bound = boundRequest(deps.requestTimeoutMs, deps.signal);
73
69
  try {
74
70
  let status;
75
71
  let text;
@@ -85,7 +81,7 @@ async function request(options, deps, what, url, init = { method: "GET" }) {
85
81
  method: init.method,
86
82
  headers,
87
83
  body: init.body,
88
- signal: controller.signal,
84
+ signal: bound.signal,
89
85
  });
90
86
  status = response.status;
91
87
  text = await response.text();
@@ -93,7 +89,7 @@ async function request(options, deps, what, url, init = { method: "GET" }) {
93
89
  catch (err) {
94
90
  return {
95
91
  kind: "transient",
96
- detail: controller.signal.aborted
92
+ detail: bound.aborted()
97
93
  ? `${what} timed out after ${deps.requestTimeoutMs} ms`
98
94
  : `${what} failed: ${err.message}`,
99
95
  };
@@ -111,8 +107,7 @@ async function request(options, deps, what, url, init = { method: "GET" }) {
111
107
  return { kind: "answer", status, text, body };
112
108
  }
113
109
  finally {
114
- clearTimeout(timer);
115
- deps.signal?.removeEventListener("abort", abort);
110
+ bound.release();
116
111
  }
117
112
  }
118
113
  /** What is wrong with a 2xx mint body, or `null` when it is a ticket. Never echoes the body. */
@@ -12,6 +12,8 @@
12
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
13
  * is no separate process to restart.
14
14
  */
15
+ const RESTART_NOTICE_STOP_LEAD = "continue without it";
16
+ const RESTART_NOTICE_STOP_TAIL = "call plan_connect to connect this session to a plan";
15
17
  export const STOP_FIXES = {
16
18
  no_connection_record: "call plan_connect again in this session — its danx-dashboard MCP server records the connection a hook command needs " +
17
19
  "(a server older than the one that introduced the record writes none)",
@@ -32,6 +34,10 @@ export const STOP_FIXES = {
32
34
  refused: "call plan_connect again to mint a new listener ticket for this session",
33
35
  // DX-3028 - this session id belongs to a worker dispatch; there is no listener ticket a worker session can ever use.
34
36
  session_is_worker: "this session id belongs to a worker dispatch; start a new session",
37
+ // DX-4632: `restart_notice` only tells a restarted session which plan its predecessor was on; none of its stops blocks anything.
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
+ lookup_timeout: `${RESTART_NOTICE_STOP_LEAD}: the dashboard did not answer in time; ${RESTART_NOTICE_STOP_TAIL}`,
40
+ lookup_failed: `${RESTART_NOTICE_STOP_LEAD}: no earlier session's record could be checked (the detail says why); ${RESTART_NOTICE_STOP_TAIL}`,
35
41
  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
42
  };
37
43
  export const DEFAULT_STOP_FIX = "call plan_connect again in this session to restart the listener";
@@ -27,6 +27,8 @@ export const CALLBACK_ENV = {
27
27
  };
28
28
  const BOOTSTRAP = { permission: null, callback: null };
29
29
  const needs = (permission) => ({ permission, callback: null });
30
+ /** DX-4632: usable with no key (a session that has not signed in yet) and only on a person's session. */
31
+ const OPERATOR_SESSION_BOOTSTRAP = { permission: null, callback: null, operatorSessionOnly: true };
30
32
  const operatorSessionNeeds = (permission) => ({ permission, callback: null, operatorSessionOnly: true });
31
33
  const needsAndPostsTo = (permission, callback) => ({ permission, callback });
32
34
  /**
@@ -44,6 +46,10 @@ export const TOOL_ACCESS = {
44
46
  // DX-4233 - the plugin's relay waits on the session's plan events here: it opens the listener ticket
45
47
  // (`POST /api/plan-sessions/me/stream-ticket`) and heartbeats (`.../me/heartbeat`), both `plans.view`.
46
48
  plan_events_wait: operatorSessionNeeds("plans.view"),
49
+ // DX-4632 - the plugin's session-start hook asks which plan the session this one replaced was working. A restarted session boots
50
+ // signed out, so it holds no permission: like `plan_connect` it is a bootstrap tool, and (like `plan_events_wait`) a person's
51
+ // session's only. The lookup asks the dashboard as the predecessor, with the credential the predecessor's record names.
52
+ restart_notice: OPERATOR_SESSION_BOOTSTRAP,
47
53
  // The one dispatch callback that stays a tool (DX-4525): it reads local files. Its dashboard route's own policy names the
48
54
  // permission (`route-policies/index.ts`); every other callback is a `danxbot_api` call to a route under `callback-routes.ts`.
49
55
  danxbot_slack_reply: needsAndPostsTo("slack.threads.post", "slackReply"),
@@ -62,7 +68,10 @@ export function isUsable(access, grant) {
62
68
  export function toolsFor(grant) {
63
69
  return Object.keys(TOOL_ACCESS).filter((name) => isUsable(TOOL_ACCESS[name], grant)).sort();
64
70
  }
65
- /** The bootstrap set: usable with no key and no callback. */
71
+ /**
72
+ * The bootstrap set a DISPATCH sees: usable with no key, no callback, and not operator-session-only. A signed-out PERSON's
73
+ * session is shown this plus the operator-only bootstrap tools (`restart_notice`): see `bootstrapSurfaceTools()`.
74
+ */
66
75
  export function bootstrapTools() {
67
76
  return toolsFor({ permissions: new Set(), callbacks: new Set(), operatorSession: false });
68
77
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thehammer/danx-dashboard-mcp",
3
- "version": "0.1.274",
3
+ "version": "0.1.276",
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",
@@ -1,136 +0,0 @@
1
- /**
2
- * `danx-dashboard-mcp event-text <event>` — the ONE-SHOT client of
3
- * `GET /api/reminders/event/<event>` (DX-3421).
4
- *
5
- * WHO RUNS IT. The claude-plugins `danxbot` plugin's single event hook
6
- * (`event-hook.sh`, SessionStart matcher "startup|resume|compact" plus
7
- * SubagentStart matcher ".*") — replaces the old `mantra` subcommand's ONE
8
- * consumer, `mantra.sh` (deleted, DX-3421, along with
9
- * `plan-workflow-autoload.sh` and `compaction-skill-reload.sh`; all three
10
- * collapse into the one hook + this one subcommand). `<event>` is one of
11
- * `session_start` | `session_resume` | `after_compaction` |
12
- * `sub_agent_start` — the SAME `npx -y @thehammer/danx-dashboard-mcp@<pin>`
13
- * path the plugin's other one-shot subcommands (`activity`, `background-work`, `ready-cards`, `restart-notice`) already use — never a
14
- * separate install, never a deep import of this package's `dist/` internals.
15
- *
16
- * THE CREDENTIAL — the SAME resolver the other one-shot subcommands use
17
- * (`resolveDashboardAuth`, `session-options.ts`): the dashboard URL, credential source
18
- * and credential all come from the connection record this session's OWN
19
- * danx-dashboard MCP server wrote on its last successful `plan_connect`
20
- * (`session-connection.ts`), never from this process's own ambient env. This
21
- * is only ever called once a session is already connected (`event-hook.sh`
22
- * checks that first via `plan-connection.mjs` and stays completely silent
23
- * otherwise — DX-3421 comment 7748/7752 verdict: no output at all before a
24
- * plan is connected), so a connection record always exists by the time this
25
- * runs.
26
- *
27
- * THE CONTRACT — `event-hook.sh` needs to tell success from failure so it
28
- * can print the operator-facing "could not load" notice (DX-3421 AC: no
29
- * committed offline fallback exists any more — a fetch failure is ALWAYS
30
- * reported to the agent, never silently swallowed and never a `mantra.md`
31
- * reread). So:
32
- *
33
- * - success: the event's EFFECTIVE text on stdout, nothing else, exit 0.
34
- * - failure (missing env, no connection record, network error, timeout,
35
- * non-2xx, 404, bad shape): NOTHING on stdout, one short reason on
36
- * stderr, exit 1. `event-hook.sh` is the one that decides what to print
37
- * for the agent — this module only reports whether the fetch worked.
38
- *
39
- * A HARD TIMEOUT — a SessionStart hook must never hang on a wedged
40
- * dashboard.
41
- */
42
- import { SESSION_ID_HEADER, resolveDashboardAuth } from "./session-options.js";
43
- export const EVENT_TEXT_SUBCOMMAND = "event-text";
44
- export const EVENT_TEXT_REQUEST_TIMEOUT_MS = 5_000;
45
- const USAGE = `usage: CLAUDE_CODE_SESSION_ID=<session-id> danx-dashboard-mcp ${EVENT_TEXT_SUBCOMMAND} <event>`;
46
- function eventPath(event) {
47
- return `/api/reminders/event/${encodeURIComponent(event)}`;
48
- }
49
- /** One authenticated GET, with a hard timeout — never throws, never bubbles a non-2xx. */
50
- export async function fetchEventText(event, options, deps) {
51
- const controller = new AbortController();
52
- const timer = setTimeout(() => controller.abort(), deps.requestTimeoutMs);
53
- let status;
54
- let text;
55
- try {
56
- try {
57
- const response = await deps.fetch(`${options.dashboardUrl}${eventPath(event)}`, {
58
- method: "GET",
59
- headers: {
60
- Authorization: `Bearer ${options.token}`,
61
- Accept: "application/json",
62
- [SESSION_ID_HEADER]: options.sessionId,
63
- },
64
- signal: controller.signal,
65
- });
66
- status = response.status;
67
- text = await response.text();
68
- }
69
- catch {
70
- return { ok: false, reason: controller.signal.aborted ? "timeout" : "request_failed" };
71
- }
72
- }
73
- finally {
74
- clearTimeout(timer);
75
- }
76
- if (status === 401 || status === 403)
77
- return { ok: false, reason: "unauthorized" };
78
- if (status === 404)
79
- return { ok: false, reason: "not_found" };
80
- if (status < 200 || status >= 300)
81
- return { ok: false, reason: "http_error" };
82
- let body;
83
- try {
84
- body = text === "" ? null : JSON.parse(text);
85
- }
86
- catch {
87
- return { ok: false, reason: "bad_response" };
88
- }
89
- const effectiveText = typeof body === "object" &&
90
- body !== null &&
91
- typeof body.effective_text === "string"
92
- ? body.effective_text
93
- : null;
94
- if (effectiveText === null)
95
- return { ok: false, reason: "bad_response" };
96
- return { ok: true, text: effectiveText };
97
- }
98
- /**
99
- * The bin's `event-text` subcommand, wired to the real process. Success
100
- * prints the effective text on stdout and returns 0; every failure prints
101
- * nothing on stdout, one reason on stderr, and returns 1 — `event-hook.sh`
102
- * reads the exit code, never parses stdout to guess.
103
- */
104
- export async function runEventTextCommand(argv, env = process.env,
105
- /** Test seam: the home the session's connection record is read from. */
106
- resolveFrom = {}) {
107
- const [event, ...rest] = argv;
108
- if (rest.length !== 0 || typeof event !== "string" || event === "") {
109
- process.stderr.write(`missing or extra arguments\n${USAGE}\n`);
110
- return 1;
111
- }
112
- const sessionId = env.CLAUDE_CODE_SESSION_ID;
113
- if (!sessionId) {
114
- process.stderr.write(`no_session_id: CLAUDE_CODE_SESSION_ID is not set\n${USAGE}\n`);
115
- return 1;
116
- }
117
- let options;
118
- try {
119
- options = resolveDashboardAuth(sessionId, env, resolveFrom);
120
- }
121
- catch (err) {
122
- const start = err;
123
- process.stderr.write(`${start.reason}: ${start.message}. Fix: ${start.fix}.\n`);
124
- return 1;
125
- }
126
- const output = await fetchEventText(event, options, {
127
- fetch: (input, init) => fetch(input, init),
128
- requestTimeoutMs: EVENT_TEXT_REQUEST_TIMEOUT_MS,
129
- });
130
- if (!output.ok) {
131
- process.stderr.write(`${output.reason}: could not fetch the effective "${event}" text from the dashboard\n`);
132
- return 1;
133
- }
134
- process.stdout.write(output.text);
135
- return 0;
136
- }