@thehammer/danx-dashboard-mcp 0.1.138 → 0.1.139

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.
@@ -4,13 +4,19 @@
4
4
  *
5
5
  * WHO RUNS IT. The danxbot Claude Code plugin's `Stop` / `SubagentStop` /
6
6
  * `SessionStart` / `StopFailure` hooks (`danxbot/hooks/hooks.json` in
7
- * claude-plugins), via the SAME `npx -y @thehammer/danx-dashboard-mcp` pin
8
- * the plugin already uses for `bridge` and `plan-state` — no separate
9
- * install, no deep import of this package's `dist/` internals. The plugin
10
- * decides WHAT to report (parsing `Stop`/`SubagentStop`'s own
11
- * `background_tasks` snapshot, counting only `shell` / `subagent` /
12
- * `workflow` entries, sending `clear` on `SessionStart`/`StopFailure`); this
13
- * module only delivers the report.
7
+ * claude-plugins), via the SAME `npx -y @thehammer/danx-dashboard-mcp`
8
+ * MECHANISM the plugin already uses for `bridge` and `plan-state` — no
9
+ * separate install, no deep import of this package's `dist/` internals.
10
+ * DX-3367 fix-up — that does NOT mean the SAME PINNED VERSION: `npx -y
11
+ * <pkg>` resolves whatever version string that ONE hook's own invocation
12
+ * names, and this plugin's hooks pin each call independently (verified
13
+ * against production drift, comment 7102 on the card: the background-work
14
+ * hooks pinned `0.1.136` while `bridge` was still pinned `0.1.95`) — a
15
+ * version bump to one hook's pin never moves the others. The plugin decides
16
+ * WHAT to report (parsing `Stop`/`SubagentStop`'s own `background_tasks`
17
+ * snapshot, counting only `shell` / `subagent` / `workflow` entries,
18
+ * sending `clear` on `SessionStart`/`StopFailure`); this module only
19
+ * delivers the report.
14
20
  *
15
21
  * THE CONTRACT, mirroring `plan-state.ts`'s (binding the same way, so a
16
22
  * future plugin build against this module can trust it without re-reading
@@ -23,8 +29,10 @@
23
29
  * no-op — `{"ok":false,"reason":"no_connection_record"}` — the plugin
24
30
  * hook simply does not forward anything further in this case, exactly
25
31
  * what "no-op when the session isn't plan-connected" means in practice;
26
- * - on success, `{"ok":true,"count":<number|null>}` echoes what was
27
- * stored;
32
+ * - on success, `{"ok":true,"count":<number|null>}` echoes the `count`
33
+ * that was SUBMITTED for storage, not a re-read of the row — see
34
+ * `recordBackgroundWork`'s own doc (`db/plan-session-background-work.ts`)
35
+ * for why a plain no-op write is indistinguishable from a real one here;
28
36
  * - human-readable diagnostics go to stderr only, never stdout.
29
37
  *
30
38
  * THE CREDENTIAL — the SAME resolver `bridge` and `plan-state` use, via
@@ -37,8 +45,13 @@
37
45
  * A HARD TIMEOUT — `BACKGROUND_WORK_REQUEST_TIMEOUT_MS`. A `Stop` hook
38
46
  * blocks the session's turn boundary, so this call must never hang: a slow
39
47
  * or wedged dashboard is `{ok:false, reason:"timeout"}`, not a stuck hook.
48
+ *
49
+ * THE REQUEST/RUN SCAFFOLDING IS SHARED WITH `plan-state.ts`
50
+ * (`one-shot-session-request.ts`, DX-3367 fix-up) — the two subcommands no
51
+ * longer maintain independent copies of the authenticated-fetch-with-
52
+ * timeout logic or the session/credential resolution boilerplate.
40
53
  */
41
- import { SESSION_ID_HEADER, resolveBridgeOptions } from "./bridge.js";
54
+ import { resolveSessionOptions, sendSessionRequest, tryParseJsonBody } from "./one-shot-session-request.js";
42
55
  export const BACKGROUND_WORK_SUBCOMMAND = "background-work";
43
56
  export const BACKGROUND_WORK_PATH = "/api/plan-sessions/me/background-work";
44
57
  /** How long this one-shot call may take before it counts as a timeout. */
@@ -59,48 +72,30 @@ export function parseCountArg(raw) {
59
72
  * One authenticated PUT, with a hard timeout, classified into the output
60
73
  * shape this subcommand always prints — never a throw, never a non-2xx
61
74
  * bubbling past this function.
75
+ *
76
+ * DX-3367 fix-up — STATUS IS CHECKED BEFORE THE BODY IS EVER PARSED. A 5xx
77
+ * (or any other non-2xx) response can carry an HTML error page instead of
78
+ * JSON; parsing first would misreport that as `bad_response` when the true
79
+ * classification is `http_error`. Unlike `plan-state.ts`'s `fetchTurnState`,
80
+ * this subcommand has no status that needs the BODY inspected to classify
81
+ * (no `409 session_not_connected` equivalent), so status alone decides
82
+ * whether a body read is even attempted.
62
83
  */
63
84
  export async function putBackgroundWork(options, count, deps) {
64
- const controller = new AbortController();
65
- const timer = setTimeout(() => controller.abort(), deps.requestTimeoutMs);
66
- let status;
67
- let text;
68
- try {
69
- try {
70
- const response = await deps.fetch(`${options.dashboardUrl}${BACKGROUND_WORK_PATH}`, {
71
- method: "PUT",
72
- headers: {
73
- Authorization: `Bearer ${options.token}`,
74
- Accept: "application/json",
75
- "Content-Type": "application/json",
76
- [SESSION_ID_HEADER]: options.sessionId,
77
- },
78
- body: JSON.stringify({ count }),
79
- signal: controller.signal,
80
- });
81
- status = response.status;
82
- text = await response.text();
83
- }
84
- catch (err) {
85
- return { ok: false, reason: controller.signal.aborted ? "timeout" : "request_failed" };
86
- }
87
- }
88
- finally {
89
- clearTimeout(timer);
90
- }
91
- let body = null;
92
- try {
93
- body = text === "" ? null : JSON.parse(text);
94
- }
95
- catch {
96
- return { ok: false, reason: "bad_response" };
97
- }
85
+ const sent = await sendSessionRequest(options, BACKGROUND_WORK_PATH, { method: "PUT", body: JSON.stringify({ count }) }, deps);
86
+ if ("reason" in sent)
87
+ return sent;
88
+ const { status, text } = sent;
98
89
  if (status === 401 || status === 403) {
99
90
  return { ok: false, reason: "unauthorized" };
100
91
  }
101
92
  if (status < 200 || status >= 300) {
102
93
  return { ok: false, reason: "http_error" };
103
94
  }
95
+ const parsed = tryParseJsonBody(text);
96
+ if (!parsed.ok)
97
+ return { ok: false, reason: "bad_response" };
98
+ const body = parsed.body;
104
99
  if (typeof body !== "object" ||
105
100
  body === null ||
106
101
  !("count" in body) ||
@@ -128,26 +123,16 @@ resolveFrom = {}) {
128
123
  return 0;
129
124
  }
130
125
  const count = parsed;
131
- const sessionId = env.CLAUDE_CODE_SESSION_ID;
132
- if (!sessionId) {
133
- write({ ok: false, reason: "no_session_id" });
134
- process.stderr.write(`${USAGE}\n`);
135
- return 0;
136
- }
137
- let options;
138
- try {
139
- options = resolveBridgeOptions({ sessionId, resumeIds: [] }, env, resolveFrom);
140
- }
141
- catch (err) {
142
- const start = err;
126
+ const resolved = await resolveSessionOptions(env, resolveFrom, USAGE);
127
+ if (!resolved.ok) {
143
128
  // A session that never `plan_connect`-ed has no connection record — this
144
129
  // is the ORDINARY "not plan-connected" case, not an error the plugin
145
130
  // should surface: it simply forwards nothing further this turn.
146
- write({ ok: false, reason: start.reason });
147
- process.stderr.write(`${start.reason}: ${start.message}. Fix: ${start.fix}.\n`);
131
+ write(resolved.output);
132
+ process.stderr.write(`${resolved.detail}\n`);
148
133
  return 0;
149
134
  }
150
- const output = await putBackgroundWork(options, count, {
135
+ const output = await putBackgroundWork(resolved.options, count, {
151
136
  fetch: (input, init) => fetch(input, init),
152
137
  requestTimeoutMs: BACKGROUND_WORK_REQUEST_TIMEOUT_MS,
153
138
  });
package/dist/index.js CHANGED
@@ -97,7 +97,6 @@ import { isEntrypointModule } from "./entrypoint.js";
97
97
  import { BRIDGE_SUBCOMMAND, runBridgeCommand } from "./bridge.js";
98
98
  import { PLAN_STATE_SUBCOMMAND, runPlanStateCommand } from "./plan-state.js";
99
99
  import { BACKGROUND_WORK_SUBCOMMAND, runBackgroundWorkCommand } from "./background-work.js";
100
- import { MANTRA_SUBCOMMAND, runMantraCommand } from "./mantra.js";
101
100
  import { resolveDeclaredCredential } from "./credential.js";
102
101
  import { recordSessionConnectionAfterConnect } from "./session-connection.js";
103
102
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
@@ -1189,20 +1188,9 @@ if (isEntrypointModule(import.meta.url, process.argv[1])) {
1189
1188
  process.exit(0);
1190
1189
  });
1191
1190
  }
1192
- else if (subcommand === MANTRA_SUBCOMMAND) {
1193
- // DX-3366 — unlike plan-state/background-work, a rejection here is a
1194
- // genuine fatal (never expected: runMantraCommand itself already
1195
- // classifies every network/shape failure into its own {ok:false} exit
1196
- // 1), so it exits 1 too rather than the "always 0, stay silent" contract
1197
- // those two hook-only subcommands use.
1198
- runMantraCommand(rest).then((code) => process.exit(code), (err) => {
1199
- console.error(`[danx-dashboard-mcp] mantra fatal: ${err.message}`);
1200
- process.exit(1);
1201
- });
1202
- }
1203
1191
  else if (subcommand !== undefined) {
1204
1192
  console.error(`[danx-dashboard-mcp] unknown subcommand "${subcommand}" (the only ones are ` +
1205
- `"${BRIDGE_SUBCOMMAND}", "${PLAN_STATE_SUBCOMMAND}", "${BACKGROUND_WORK_SUBCOMMAND}", and "${MANTRA_SUBCOMMAND}")`);
1193
+ `"${BRIDGE_SUBCOMMAND}", "${PLAN_STATE_SUBCOMMAND}", and "${BACKGROUND_WORK_SUBCOMMAND}")`);
1206
1194
  process.exit(2);
1207
1195
  }
1208
1196
  else {
@@ -0,0 +1,104 @@
1
+ /**
2
+ * DX-3367 fix-up — shared scaffolding for a "one-shot session request"
3
+ * subcommand: `plan-state` (`GET /api/plan-sessions/me/turn-state`) and
4
+ * `background-work` (`PUT /api/plan-sessions/me/background-work`) each
5
+ * duplicated this exact shape independently (`fetchTurnState` /
6
+ * `putBackgroundWork`, `runPlanStateCommand` / `runBackgroundWorkCommand`).
7
+ * This module is the ONE place that shape lives now; a future one-shot
8
+ * subcommand extends it instead of copying either file again.
9
+ *
10
+ * Two pieces, matching the two duplicated concerns:
11
+ * - `sendSessionRequest` — the authenticated HTTP call itself: bearer +
12
+ * session header, a hard abort-timeout, and network-level failure
13
+ * classification (`timeout` / `request_failed`). Returns the raw
14
+ * `{status, text}` UNPARSED — each caller decides for itself which
15
+ * statuses need the body inspected (e.g. `plan-state`'s 409
16
+ * `session_not_connected`) and which don't, so a non-2xx status with an
17
+ * unparseable (e.g. HTML) body is classified from the status alone
18
+ * rather than colliding with "the JSON didn't parse" (`bad_response`).
19
+ * - `resolveSessionOptions` — session-id + credential resolution via
20
+ * `resolveBridgeOptions`, with `BridgeStartError` narrowed by
21
+ * `instanceof` rather than an unchecked `as BridgeStartError` cast, so
22
+ * an error of some OTHER shape (a bug in the resolver, a thrown
23
+ * non-Error) still comes back as a reported reason instead of a value
24
+ * whose `.reason`/`.message`/`.fix` fields are silently `undefined`.
25
+ */
26
+ import { SESSION_ID_HEADER, resolveBridgeOptions, BridgeStartError } from "./bridge.js";
27
+ /**
28
+ * One authenticated HTTP call to this session's dashboard, with a hard
29
+ * timeout. Never throws — a network failure or an abort is reported as
30
+ * `OneShotFailure`, exactly like any other classified failure the caller
31
+ * might produce from the status/body it gets back on success.
32
+ */
33
+ export async function sendSessionRequest(options, path, init, deps) {
34
+ const controller = new AbortController();
35
+ const timer = setTimeout(() => controller.abort(), deps.requestTimeoutMs);
36
+ try {
37
+ try {
38
+ const response = await deps.fetch(`${options.dashboardUrl}${path}`, {
39
+ method: init.method,
40
+ headers: {
41
+ Authorization: `Bearer ${options.token}`,
42
+ Accept: "application/json",
43
+ ...(init.body !== undefined ? { "Content-Type": "application/json" } : {}),
44
+ [SESSION_ID_HEADER]: options.sessionId,
45
+ },
46
+ body: init.body,
47
+ signal: controller.signal,
48
+ });
49
+ const text = await response.text();
50
+ return { status: response.status, text };
51
+ }
52
+ catch {
53
+ // DX-3367 fix-up — the caught value is never inspected (an aborted
54
+ // fetch's own rejection shape varies by runtime), so binding it to a
55
+ // name the code never reads was dead weight the linter never caught.
56
+ return { ok: false, reason: controller.signal.aborted ? "timeout" : "request_failed" };
57
+ }
58
+ }
59
+ finally {
60
+ clearTimeout(timer);
61
+ }
62
+ }
63
+ /** `JSON.parse`, classified rather than thrown — the ONE shared parse point
64
+ * every caller uses once it has decided (from `status`) that the body is
65
+ * worth reading at all. */
66
+ export function tryParseJsonBody(text) {
67
+ try {
68
+ return { ok: true, body: text === "" ? null : JSON.parse(text) };
69
+ }
70
+ catch {
71
+ return { ok: false };
72
+ }
73
+ }
74
+ /**
75
+ * Resolve this process's session id + dashboard credential, the identical
76
+ * first step every one-shot subcommand's `runXCommand` takes before it can
77
+ * make its own request. `detail` is the stderr-only human-readable line the
78
+ * caller prints alongside `output` — never part of the stdout JSON contract.
79
+ */
80
+ export async function resolveSessionOptions(env, resolveFrom, usage) {
81
+ const sessionId = env.CLAUDE_CODE_SESSION_ID;
82
+ if (!sessionId) {
83
+ return { ok: false, output: { ok: false, reason: "no_session_id" }, detail: usage };
84
+ }
85
+ try {
86
+ const options = resolveBridgeOptions({ sessionId, resumeIds: [] }, env, resolveFrom);
87
+ return { ok: true, options };
88
+ }
89
+ catch (err) {
90
+ // DX-3367 fix-up — `instanceof`, never an unchecked `as BridgeStartError`
91
+ // cast: `resolveBridgeOptions` throws exactly `BridgeStartError` today,
92
+ // but a cast reports a fabricated reason/message/fix (all `undefined`)
93
+ // for any OTHER thrown shape instead of a real one, silently.
94
+ if (err instanceof BridgeStartError) {
95
+ return {
96
+ ok: false,
97
+ output: { ok: false, reason: err.reason },
98
+ detail: `${err.reason}: ${err.message}. Fix: ${err.fix}.`,
99
+ };
100
+ }
101
+ const message = err instanceof Error ? err.message : String(err);
102
+ return { ok: false, output: { ok: false, reason: "start_failed" }, detail: `start_failed: ${message}` };
103
+ }
104
+ }
@@ -44,8 +44,11 @@
44
44
  * blocks a turn boundary the plugin is holding open, so it must never hang:
45
45
  * a slow or wedged dashboard is `{ok:false, reason:"timeout"}`, not a stuck
46
46
  * hook.
47
+ *
48
+ * THE REQUEST/RUN SCAFFOLDING IS SHARED WITH `background-work.ts`
49
+ * (`one-shot-session-request.ts`, DX-3367 fix-up).
47
50
  */
48
- import { SESSION_ID_HEADER, resolveBridgeOptions } from "./bridge.js";
51
+ import { resolveSessionOptions, sendSessionRequest, tryParseJsonBody } from "./one-shot-session-request.js";
49
52
  export const PLAN_STATE_SUBCOMMAND = "plan-state";
50
53
  export const TURN_STATE_PATH = "/api/plan-sessions/me/turn-state";
51
54
  /** How long this one-shot call may take before it counts as a timeout. */
@@ -102,45 +105,29 @@ export function parseTurnStateBody(body) {
102
105
  * One authenticated GET, with a hard timeout, classified into the output
103
106
  * shape this subcommand always prints — never a throw, never a non-2xx
104
107
  * bubbling past this function.
108
+ *
109
+ * DX-3367 fix-up — status is checked BEFORE the body is parsed, except for
110
+ * the ONE status (`409`) whose classification genuinely depends on the body
111
+ * (`session_not_connected`). Any other non-2xx — including a 5xx that comes
112
+ * back as an HTML error page instead of JSON — is `http_error` without ever
113
+ * attempting to parse it, so a body-parse failure on those statuses can
114
+ * never be misreported as `bad_response`.
105
115
  */
106
116
  export async function fetchTurnState(options, deps) {
107
- const controller = new AbortController();
108
- const timer = setTimeout(() => controller.abort(), deps.requestTimeoutMs);
109
- let status;
110
- let text;
111
- try {
112
- try {
113
- const response = await deps.fetch(`${options.dashboardUrl}${TURN_STATE_PATH}`, {
114
- method: "GET",
115
- headers: {
116
- Authorization: `Bearer ${options.token}`,
117
- Accept: "application/json",
118
- [SESSION_ID_HEADER]: options.sessionId,
119
- },
120
- signal: controller.signal,
121
- });
122
- status = response.status;
123
- text = await response.text();
117
+ const sent = await sendSessionRequest(options, TURN_STATE_PATH, { method: "GET" }, deps);
118
+ if ("reason" in sent)
119
+ return sent;
120
+ const { status, text } = sent;
121
+ if (status === 409) {
122
+ // The exact literal the plugin's no-log-spam rule keys on (AC 30672 /
123
+ // comment 4245) — the dashboard's own 409 error code for an unconnected
124
+ // session, passed through verbatim rather than renamed. A 409 whose body
125
+ // does NOT carry that code falls through to the ordinary `http_error`
126
+ // classification below, same as any other non-2xx.
127
+ const parsed409 = tryParseJsonBody(text);
128
+ if (parsed409.ok && errorCodeOf(parsed409.body) === "session_not_connected") {
129
+ return { ok: false, reason: "session_not_connected" };
124
130
  }
125
- catch (err) {
126
- return { ok: false, reason: controller.signal.aborted ? "timeout" : "request_failed" };
127
- }
128
- }
129
- finally {
130
- clearTimeout(timer);
131
- }
132
- let body = null;
133
- try {
134
- body = text === "" ? null : JSON.parse(text);
135
- }
136
- catch {
137
- return { ok: false, reason: "bad_response" };
138
- }
139
- // The exact literal the plugin's no-log-spam rule keys on (AC 30672 /
140
- // comment 4245) — the dashboard's own 409 error code for an unconnected
141
- // session, passed through verbatim rather than renamed.
142
- if (status === 409 && errorCodeOf(body) === "session_not_connected") {
143
- return { ok: false, reason: "session_not_connected" };
144
131
  }
145
132
  if (status === 401 || status === 403) {
146
133
  return { ok: false, reason: "unauthorized" };
@@ -148,11 +135,14 @@ export async function fetchTurnState(options, deps) {
148
135
  if (status < 200 || status >= 300) {
149
136
  return { ok: false, reason: "http_error" };
150
137
  }
151
- const parsed = parseTurnStateBody(body);
152
- if (parsed === null) {
138
+ const parsed = tryParseJsonBody(text);
139
+ if (!parsed.ok)
140
+ return { ok: false, reason: "bad_response" };
141
+ const turnState = parseTurnStateBody(parsed.body);
142
+ if (turnState === null) {
153
143
  return { ok: false, reason: "bad_response" };
154
144
  }
155
- return { ok: true, ...parsed };
145
+ return { ok: true, ...turnState };
156
146
  }
157
147
  /** The bin's `plan-state` subcommand, wired to the real process. Always resolves to exit code 0. */
158
148
  export async function runPlanStateCommand(argv, env = process.env,
@@ -169,23 +159,13 @@ resolveFrom = {}) {
169
159
  process.stderr.write(`${USAGE}\n`);
170
160
  return 0;
171
161
  }
172
- const sessionId = env.CLAUDE_CODE_SESSION_ID;
173
- if (!sessionId) {
174
- write({ ok: false, reason: "no_session_id" });
175
- process.stderr.write(`${USAGE}\n`);
176
- return 0;
177
- }
178
- let options;
179
- try {
180
- options = resolveBridgeOptions({ sessionId, resumeIds: [] }, env, resolveFrom);
181
- }
182
- catch (err) {
183
- const start = err;
184
- write({ ok: false, reason: start.reason });
185
- process.stderr.write(`${start.reason}: ${start.message}. Fix: ${start.fix}.\n`);
162
+ const resolved = await resolveSessionOptions(env, resolveFrom, USAGE);
163
+ if (!resolved.ok) {
164
+ write(resolved.output);
165
+ process.stderr.write(`${resolved.detail}\n`);
186
166
  return 0;
187
167
  }
188
- const output = await fetchTurnState(options, {
168
+ const output = await fetchTurnState(resolved.options, {
189
169
  fetch: (input, init) => fetch(input, init),
190
170
  requestTimeoutMs: PLAN_STATE_REQUEST_TIMEOUT_MS,
191
171
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thehammer/danx-dashboard-mcp",
3
- "version": "0.1.138",
3
+ "version": "0.1.139",
4
4
  "description": "Stdio MCP server wrapping danxbot's dashboard /api/issues/* normalized DB-backed HTTP routes for dispatched agents (DX-704 Phase 2).",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/dist/mantra.js DELETED
@@ -1,128 +0,0 @@
1
- /**
2
- * `danx-dashboard-mcp mantra` — the ONE-SHOT client of
3
- * `GET /api/reminders/mantra.session_start` (DX-3366).
4
- *
5
- * WHO RUNS IT. The claude-plugins `danxbot` plugin's `mantra.sh` SessionStart
6
- * hook, via the SAME `npx -y @thehammer/danx-dashboard-mcp@<pin>` path the
7
- * plugin's other dashboard calls already use (`plan-state`, `bridge`) — never
8
- * a separate install, never a deep import of this package's `dist/`
9
- * internals.
10
- *
11
- * THE CREDENTIAL — the SAME resolver `bridge` / `plan-state` use
12
- * (`resolveBridgeOptions`, `bridge.ts`): the dashboard URL, credential source
13
- * and credential all come from the connection record this session's OWN
14
- * danx-dashboard MCP server wrote on its last successful `plan_connect`
15
- * (`session-connection.ts`), never from this process's own ambient env. The
16
- * mantra is only ever fetched once a session is already connected (`mantra.sh`
17
- * checks that first via `plan-connection.mjs` and stays with the short nudge
18
- * otherwise), so a connection record always exists by the time this runs.
19
- *
20
- * THE CONTRACT — unlike `plan-state` (which must stay silent for a hook, so it
21
- * always exits 0), this subcommand's caller (`mantra.sh`) needs to tell
22
- * success from failure so it can fall back to the git-committed `mantra.md` +
23
- * a one-line notice (DX-3366 AC: "Dashboard unreachable → the hook prints the
24
- * committed file and says so in one line; never silently"). So:
25
- *
26
- * - success: the reminder's EFFECTIVE text (`override_text ?? default_text`)
27
- * on stdout, nothing else, exit 0.
28
- * - failure (missing env, no connection record, network error, timeout,
29
- * non-2xx, 404, bad shape): NOTHING on stdout, one short reason on
30
- * stderr, exit 1. `mantra.sh` is the one that decides what to print for
31
- * a human — this module only reports whether the fetch worked.
32
- *
33
- * A HARD TIMEOUT (mirrors `plan-state.ts`) — a SessionStart hook must never
34
- * hang on a wedged dashboard.
35
- */
36
- import { SESSION_ID_HEADER, resolveBridgeOptions } from "./bridge.js";
37
- export const MANTRA_SUBCOMMAND = "mantra";
38
- export const MANTRA_REMINDER_KEY = "mantra.session_start";
39
- export const MANTRA_REQUEST_TIMEOUT_MS = 5_000;
40
- const USAGE = `usage: CLAUDE_CODE_SESSION_ID=<session-id> danx-dashboard-mcp ${MANTRA_SUBCOMMAND}`;
41
- function reminderPath(key) {
42
- return `/api/reminders/${encodeURIComponent(key)}`;
43
- }
44
- /** One authenticated GET, with a hard timeout — never throws, never bubbles a non-2xx. */
45
- export async function fetchMantraText(options, deps) {
46
- const controller = new AbortController();
47
- const timer = setTimeout(() => controller.abort(), deps.requestTimeoutMs);
48
- let status;
49
- let text;
50
- try {
51
- try {
52
- const response = await deps.fetch(`${options.dashboardUrl}${reminderPath(MANTRA_REMINDER_KEY)}`, {
53
- method: "GET",
54
- headers: {
55
- Authorization: `Bearer ${options.token}`,
56
- Accept: "application/json",
57
- [SESSION_ID_HEADER]: options.sessionId,
58
- },
59
- signal: controller.signal,
60
- });
61
- status = response.status;
62
- text = await response.text();
63
- }
64
- catch {
65
- return { ok: false, reason: controller.signal.aborted ? "timeout" : "request_failed" };
66
- }
67
- }
68
- finally {
69
- clearTimeout(timer);
70
- }
71
- if (status === 401 || status === 403)
72
- return { ok: false, reason: "unauthorized" };
73
- if (status === 404)
74
- return { ok: false, reason: "not_found" };
75
- if (status < 200 || status >= 300)
76
- return { ok: false, reason: "http_error" };
77
- let body;
78
- try {
79
- body = text === "" ? null : JSON.parse(text);
80
- }
81
- catch {
82
- return { ok: false, reason: "bad_response" };
83
- }
84
- const effectiveText = typeof body === "object" && body !== null && typeof body.effective_text === "string"
85
- ? body.effective_text
86
- : null;
87
- if (effectiveText === null)
88
- return { ok: false, reason: "bad_response" };
89
- return { ok: true, text: effectiveText };
90
- }
91
- /**
92
- * The bin's `mantra` subcommand, wired to the real process. Success prints
93
- * the effective text on stdout and returns 0; every failure prints nothing on
94
- * stdout, one reason on stderr, and returns 1 — `mantra.sh` reads the exit
95
- * code, never parses stdout to guess.
96
- */
97
- export async function runMantraCommand(argv, env = process.env,
98
- /** Test seam: the home the session's connection record is read from. */
99
- resolveFrom = {}) {
100
- if (argv.length !== 0) {
101
- process.stderr.write(`unrecognized arguments\n${USAGE}\n`);
102
- return 1;
103
- }
104
- const sessionId = env.CLAUDE_CODE_SESSION_ID;
105
- if (!sessionId) {
106
- process.stderr.write(`no_session_id: CLAUDE_CODE_SESSION_ID is not set\n${USAGE}\n`);
107
- return 1;
108
- }
109
- let options;
110
- try {
111
- options = resolveBridgeOptions({ sessionId, resumeIds: [] }, env, resolveFrom);
112
- }
113
- catch (err) {
114
- const start = err;
115
- process.stderr.write(`${start.reason}: ${start.message}. Fix: ${start.fix}.\n`);
116
- return 1;
117
- }
118
- const output = await fetchMantraText(options, {
119
- fetch: (input, init) => fetch(input, init),
120
- requestTimeoutMs: MANTRA_REQUEST_TIMEOUT_MS,
121
- });
122
- if (!output.ok) {
123
- process.stderr.write(`${output.reason}: could not fetch the effective mantra text from the dashboard\n`);
124
- return 1;
125
- }
126
- process.stdout.write(output.text);
127
- return 0;
128
- }