@thehammer/danx-dashboard-mcp 0.1.138 → 0.1.140

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
  });
@@ -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.140",
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",