@thehammer/danx-dashboard-mcp 0.1.176 → 0.1.177

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.
@@ -5,8 +5,8 @@
5
5
  * WHO RUNS IT. The danxbot Claude Code plugin's `Stop` / `SubagentStop` /
6
6
  * `SessionStart` / `StopFailure` hooks (`danxbot/hooks/hooks.json` in
7
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.
8
+ * MECHANISM the plugin already uses for `bridge` — no separate install, no
9
+ * deep import of this package's `dist/` internals.
10
10
  * DX-3367 fix-up — that does NOT mean the SAME PINNED VERSION: `npx -y
11
11
  * <pkg>` resolves whatever version string that ONE hook's own invocation
12
12
  * names, and this plugin's hooks pin each call independently (verified
@@ -18,9 +18,8 @@
18
18
  * sending `clear` on `SessionStart`/`StopFailure`); this module only
19
19
  * delivers the report.
20
20
  *
21
- * THE CONTRACT, mirroring `plan-state.ts`'s (binding the same way, so a
22
- * future plugin build against this module can trust it without re-reading
23
- * this file):
21
+ * THE CONTRACT (binding, so a future plugin build against this module can
22
+ * trust it without re-reading this file):
24
23
  * - print EXACTLY ONE JSON line on stdout, and ALWAYS exit 0 — success or
25
24
  * failure alike, because a hook must stay silent rather than spam the
26
25
  * session's own turn with a stack trace;
@@ -40,21 +39,21 @@
40
39
  * reports by this time;
41
40
  * - human-readable diagnostics go to stderr only, never stdout.
42
41
  *
43
- * THE CREDENTIAL — the SAME resolver `bridge` and `plan-state` use, via
44
- * `resolveBridgeOptions` (`bridge.ts`): the dashboard URL, credential
45
- * source, and credential come from the connection record this session's OWN
46
- * danx-dashboard MCP server wrote on its last successful `plan_connect`
47
- * (`session-connection.ts`), never from this process's own ambient env —
48
- * the identical DX-2862 reasoning `plan-state.ts` already gives for itself.
42
+ * THE CREDENTIAL — the SAME resolver `bridge` uses, via `resolveBridgeOptions`
43
+ * (`bridge.ts`): the dashboard URL, credential source, and credential come
44
+ * from the connection record this session's OWN danx-dashboard MCP server
45
+ * wrote on its last successful `plan_connect` (`session-connection.ts`),
46
+ * never from this process's own ambient env (DX-2862).
49
47
  *
50
48
  * A HARD TIMEOUT — `BACKGROUND_WORK_REQUEST_TIMEOUT_MS`. A `Stop` hook
51
49
  * blocks the session's turn boundary, so this call must never hang: a slow
52
50
  * or wedged dashboard is `{ok:false, reason:"timeout"}`, not a stuck hook.
53
51
  *
54
- * THE REQUEST/RUN SCAFFOLDING IS SHARED WITH `plan-state.ts`
55
- * (`one-shot-session-request.ts`, DX-3367 fix-up) — the two subcommands no
56
- * longer maintain independent copies of the authenticated-fetch-with-
57
- * timeout logic or the session/credential resolution boilerplate.
52
+ * THE REQUEST/RUN SCAFFOLDING (`one-shot-session-request.ts`, DX-3367
53
+ * fix-up) is shared, general-purpose plumbing for any one-shot subcommand
54
+ * shaped like this one — not maintained as an independent copy of the
55
+ * authenticated-fetch-with-timeout logic or the session/credential
56
+ * resolution boilerplate.
58
57
  */
59
58
  import { resolveSessionOptions, sendSessionRequest, tryParseJsonBody } from "./one-shot-session-request.js";
60
59
  export const BACKGROUND_WORK_SUBCOMMAND = "background-work";
@@ -90,10 +89,10 @@ export function parseEventAtArg(raw) {
90
89
  * DX-3367 fix-up — STATUS IS CHECKED BEFORE THE BODY IS EVER PARSED. A 5xx
91
90
  * (or any other non-2xx) response can carry an HTML error page instead of
92
91
  * JSON; parsing first would misreport that as `bad_response` when the true
93
- * classification is `http_error`. Unlike `plan-state.ts`'s `fetchTurnState`,
94
- * this subcommand has no status that needs the BODY inspected to classify
95
- * (no `409 session_not_connected` equivalent), so status alone decides
96
- * whether a body read is even attempted.
92
+ * classification is `http_error`. This subcommand has no status that needs
93
+ * the BODY inspected to classify (no `409 session_not_connected`
94
+ * equivalent), so status alone decides whether a body read is even
95
+ * attempted.
97
96
  */
98
97
  export async function putBackgroundWork(options, report, deps) {
99
98
  const sent = await sendSessionRequest(options, BACKGROUND_WORK_PATH, { method: "PUT", body: JSON.stringify({ count: report.count, eventAt: report.eventAt }) }, deps);
@@ -10,11 +10,10 @@
10
10
  * collapse into the one hook + this one subcommand). `<event>` is one of
11
11
  * `session_start` | `session_resume` | `after_compaction` |
12
12
  * `sub_agent_start` — the SAME `npx -y @thehammer/danx-dashboard-mcp@<pin>`
13
- * path the plugin's other dashboard calls already use (`plan-state`,
14
- * `bridge`) — never a separate install, never a deep import of this
15
- * package's `dist/` internals.
13
+ * path the plugin's other dashboard calls already use (`bridge`) — never a
14
+ * separate install, never a deep import of this package's `dist/` internals.
16
15
  *
17
- * THE CREDENTIAL — the SAME resolver `bridge` / `plan-state` use
16
+ * THE CREDENTIAL — the SAME resolver `bridge` uses
18
17
  * (`resolveBridgeOptions`, `bridge.ts`): the dashboard URL, credential source
19
18
  * and credential all come from the connection record this session's OWN
20
19
  * danx-dashboard MCP server wrote on its last successful `plan_connect`
@@ -37,8 +36,8 @@
37
36
  * stderr, exit 1. `event-hook.sh` is the one that decides what to print
38
37
  * for the agent — this module only reports whether the fetch worked.
39
38
  *
40
- * A HARD TIMEOUT (mirrors `plan-state.ts`) — a SessionStart hook must never
41
- * hang on a wedged dashboard.
39
+ * A HARD TIMEOUT — a SessionStart hook must never hang on a wedged
40
+ * dashboard.
42
41
  */
43
42
  import { SESSION_ID_HEADER, resolveBridgeOptions } from "./bridge.js";
44
43
  export const EVENT_TEXT_SUBCOMMAND = "event-text";
@@ -1,4 +1,38 @@
1
1
  import { assertVersionFloorSatisfied, MCP_MIN_VERSION_HEADER } from "./version-floor.js";
2
+ /**
3
+ * DX-3750 — route-path prefixes (`<basePath><path>`, BEFORE any query
4
+ * string) that are NEVER board-scoped: the dashboard route serving them
5
+ * takes no `?board=` at all — either because the resource is GLOBAL
6
+ * (plans — see `plan_list`'s own doc: "Plans are GLOBAL, not board-scoped")
7
+ * or because it scopes some other way a `board` param would be meaningless
8
+ * for (dispatches scope by `repo`; a board resolves from a PATH segment on
9
+ * every route but its own list, which itself scopes by `repo`; `/api/routes`
10
+ * and `/api/openapi` are cross-board discovery). Server-side, each of these
11
+ * families' LIST route validates its query params with its own
12
+ * `rejectUnknownParams(params, TOP_LEVEL_..._PARAM_KEYS)` allowlist
13
+ * (`src/issues/plans-routes.ts`, `src/dashboard/dispatches-routes.ts`,
14
+ * `src/issues/boards-routes.ts`) — none of those allowlists names `board`
15
+ * (DX-3725), so stamping it 400s. THIS is the one list; `isBoardScopedRoute`
16
+ * is the one function that reads it — no second, independently-drifting
17
+ * copy anywhere else in this package.
18
+ *
19
+ * Deliberately prefix-matched, not exact-matched: every sub-route of a
20
+ * non-board-scoped family (`/api/plans/:id/cards`, `/api/boards/:id/export`,
21
+ * `/api/dispatches/:id/logs`, …) is equally never board-scoped — a plan, a
22
+ * dispatch and a board-export all resolve their target from a PATH segment
23
+ * or a `repo`/global scope, never from `?board=`.
24
+ */
25
+ const NON_BOARD_SCOPED_PATH_PREFIXES = [
26
+ "/api/plans",
27
+ "/api/dispatches",
28
+ "/api/boards",
29
+ "/api/routes",
30
+ "/api/openapi",
31
+ ];
32
+ /** DX-3750 — does `routePath` (`<basePath><path>`, no query string) take a `?board=` at all? See `NON_BOARD_SCOPED_PATH_PREFIXES`'s own doc. */
33
+ export function isBoardScopedRoute(routePath) {
34
+ return !NON_BOARD_SCOPED_PATH_PREFIXES.some((prefix) => routePath === prefix || routePath.startsWith(`${prefix}/`));
35
+ }
2
36
  export class DashboardHttpClient {
3
37
  config;
4
38
  fetchImpl;
@@ -97,7 +131,12 @@ export class DashboardHttpClient {
97
131
  cleanPath = rawPath.startsWith("/") ? rawPath : `/${rawPath}`;
98
132
  }
99
133
  const params = new URLSearchParams(existingQs ?? "");
100
- params.set("board", boardOverride ?? this.config.board);
134
+ // DX-3750 — only a board-scoped route takes `?board=` at all; see
135
+ // `isBoardScopedRoute`'s own doc for why an unconditional stamp 400s
136
+ // plans/dispatches/boards-list through `danxbot_api`.
137
+ if (isBoardScopedRoute(`${basePath}${cleanPath}`)) {
138
+ params.set("board", boardOverride ?? this.config.board);
139
+ }
101
140
  if (extraQuery) {
102
141
  for (const [k, v] of Object.entries(extraQuery)) {
103
142
  if (v === undefined || v === null)
package/dist/index.js CHANGED
@@ -74,7 +74,6 @@
74
74
  */
75
75
  import { isEntrypointModule } from "./entrypoint.js";
76
76
  import { BRIDGE_SUBCOMMAND, runBridgeCommand } from "./bridge.js";
77
- import { PLAN_STATE_SUBCOMMAND, runPlanStateCommand } from "./plan-state.js";
78
77
  import { BACKGROUND_WORK_SUBCOMMAND, runBackgroundWorkCommand } from "./background-work.js";
79
78
  import { EVENT_TEXT_SUBCOMMAND, runEventTextCommand } from "./event-text.js";
80
79
  import { resolveDeclaredCredential } from "./credential.js";
@@ -460,22 +459,14 @@ async function main() {
460
459
  // symlink-aware (DX-1647) so it holds under the symlinked `npx` bin the worker
461
460
  // spawns, not just a direct `node dist/index.js`.
462
461
  //
463
- // `bridge` and `plan-state` are the two subcommands. `bridge` is the session
464
- // event stream client the danxbot plugin's plan event bridge runs. It never
465
- // boots the MCP server; its only input is `CLAUDE_CODE_SESSION_ID` (read from
466
- // its environment) plus `--resume-ids` — the dashboard URL, credential source,
467
- // and credential itself come from the session-connection record this server's
468
- // own `plan_connect` wrote (`session-connection.ts`), never from the bridge's
469
- // own environment (DX-2862: a bridge trusting its own ambient
470
- // `DANXBOT_DISPATCH_TOKEN` is the exact bug this repo fixed).
471
- //
472
- // `plan-state` (DX-2921) is the ONE-SHOT client of
473
- // `GET /api/plan-sessions/me/turn-state`, run by the DX-2888 plugin's
474
- // turn-start / turn-end hooks — see `plan-state.ts`'s own doc comment for the
475
- // full contract. It ALWAYS exits 0 (success or failure alike: the hook must
476
- // stay silent), so even an unexpected rejection here is caught and reported
477
- // the same `{ok:false, reason}` way rather than propagating a non-zero exit
478
- // or an uncaught stack trace into the hook.
462
+ // `bridge` is the session event stream client the danxbot plugin's plan
463
+ // event bridge runs. It never boots the MCP server; its only input is
464
+ // `CLAUDE_CODE_SESSION_ID` (read from its environment) plus `--resume-ids` —
465
+ // the dashboard URL, credential source, and credential itself come from the
466
+ // session-connection record this server's own `plan_connect` wrote
467
+ // (`session-connection.ts`), never from the bridge's own environment
468
+ // (DX-2862: a bridge trusting its own ambient `DANXBOT_DISPATCH_TOKEN` is the
469
+ // exact bug this repo fixed).
479
470
  //
480
471
  // Any OTHER argument is refused rather than ignored, so a typo cannot
481
472
  // silently start an MCP server on stdio where a subcommand was meant to run.
@@ -487,13 +478,6 @@ if (isEntrypointModule(import.meta.url, process.argv[1])) {
487
478
  process.exit(1);
488
479
  });
489
480
  }
490
- else if (subcommand === PLAN_STATE_SUBCOMMAND) {
491
- runPlanStateCommand(rest).then((code) => process.exit(code), (err) => {
492
- console.error(`[danx-dashboard-mcp] plan-state fatal: ${err.message}`);
493
- process.stdout.write(`${JSON.stringify({ ok: false, reason: "fatal" })}\n`);
494
- process.exit(0);
495
- });
496
- }
497
481
  else if (subcommand === BACKGROUND_WORK_SUBCOMMAND) {
498
482
  runBackgroundWorkCommand(rest).then((code) => process.exit(code), (err) => {
499
483
  console.error(`[danx-dashboard-mcp] background-work fatal: ${err.message}`);
@@ -502,12 +486,11 @@ if (isEntrypointModule(import.meta.url, process.argv[1])) {
502
486
  });
503
487
  }
504
488
  else if (subcommand === EVENT_TEXT_SUBCOMMAND) {
505
- // DX-3421 (was DX-3366's `mantra` subcommand) — unlike plan-state/
506
- // background-work, a rejection here is a genuine fatal (never expected:
507
- // runEventTextCommand itself already classifies every network/shape
508
- // failure into its own {ok:false} exit 1), so it exits 1 too rather than
509
- // the "always 0, stay silent" contract those two hook-only subcommands
510
- // use.
489
+ // DX-3421 (was DX-3366's `mantra` subcommand) — unlike background-work, a
490
+ // rejection here is a genuine fatal (never expected: runEventTextCommand
491
+ // itself already classifies every network/shape failure into its own
492
+ // {ok:false} exit 1), so it exits 1 too rather than the "always 0, stay
493
+ // silent" contract background-work's hook-only subcommand uses.
511
494
  runEventTextCommand(rest).then((code) => process.exit(code), (err) => {
512
495
  console.error(`[danx-dashboard-mcp] event-text fatal: ${err.message}`);
513
496
  process.exit(1);
@@ -515,7 +498,7 @@ if (isEntrypointModule(import.meta.url, process.argv[1])) {
515
498
  }
516
499
  else if (subcommand !== undefined) {
517
500
  console.error(`[danx-dashboard-mcp] unknown subcommand "${subcommand}" (the only ones are ` +
518
- `"${BRIDGE_SUBCOMMAND}", "${PLAN_STATE_SUBCOMMAND}", "${BACKGROUND_WORK_SUBCOMMAND}", and "${EVENT_TEXT_SUBCOMMAND}")`);
501
+ `"${BRIDGE_SUBCOMMAND}", "${BACKGROUND_WORK_SUBCOMMAND}", and "${EVENT_TEXT_SUBCOMMAND}")`);
519
502
  process.exit(2);
520
503
  }
521
504
  else {
@@ -1,21 +1,22 @@
1
1
  /**
2
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.
3
+ * subcommand. Extracted when `plan-state` (`GET /api/plan-sessions/me/turn-state`,
4
+ * removed by DX-3689 — it never had a caller) and `background-work`
5
+ * (`PUT /api/plan-sessions/me/background-work`) turned out to duplicate this
6
+ * exact shape independently (`fetchTurnState` / `putBackgroundWork`,
7
+ * `runPlanStateCommand` / `runBackgroundWorkCommand`). This module is the ONE
8
+ * place that shape lives now; a future one-shot subcommand extends it
9
+ * instead of copying `background-work.ts` again.
9
10
  *
10
- * Two pieces, matching the two duplicated concerns:
11
+ * Two pieces:
11
12
  * - `sendSessionRequest` — the authenticated HTTP call itself: bearer +
12
13
  * session header, a hard abort-timeout, and network-level failure
13
14
  * classification (`timeout` / `request_failed`). Returns the raw
14
15
  * `{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`).
16
+ * statuses need the body inspected and which don't, so a non-2xx status
17
+ * with an unparseable (e.g. HTML) body is classified from the status
18
+ * alone rather than colliding with "the JSON didn't parse"
19
+ * (`bad_response`).
19
20
  * - `resolveSessionOptions` — session-id + credential resolution via
20
21
  * `resolveBridgeOptions`, with `BridgeStartError` narrowed by
21
22
  * `instanceof` rather than an unchecked `as BridgeStartError` cast, so
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thehammer/danx-dashboard-mcp",
3
- "version": "0.1.176",
3
+ "version": "0.1.177",
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, danxbot_complete, and other per-dispatch lifecycle callbacks) (DX-3465, PLN-11 R-25).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,174 +0,0 @@
1
- /**
2
- * `danx-dashboard-mcp plan-state` — the ONE-SHOT client of
3
- * `GET /api/plan-sessions/me/turn-state` (DX-2921).
4
- *
5
- * WHO RUNS IT. The danxbot Claude Code plugin's DX-2888 turn-start /
6
- * turn-end hooks, via the plugin's existing `bridgeCommand()` npx path — the
7
- * SAME package pin, no separate install, no deep import of this package's
8
- * `dist/` internals (that was DX-2921's whole reason to exist: the DX-2888
9
- * architecture review found the first build of this logic vendored a private
10
- * copy of the MCP package and hardcoded the dashboard HTTP contract inside
11
- * the PLUGIN). The plugin decides what to DO with the answer (inject plan
12
- * numbers at turn start, refuse to let a turn end while the session could
13
- * legally start work and has nothing running); this module only fetches it.
14
- *
15
- * THE CONTRACT (agreed with the DX-2888 plugin side, issue comment 4245 on
16
- * DX-2921 — binding, because the plugin is already built against it):
17
- * - print EXACTLY ONE JSON line on stdout, and ALWAYS exit 0 — success or
18
- * failure alike, because the hook must stay silent rather than spam the
19
- * session's own turn with a stack trace;
20
- * - a session that is not connected to a plan prints
21
- * `{"ok":false,"reason":"session_not_connected"}` — that EXACT literal,
22
- * because the plugin keys its no-log-spam rule on it (it is also the
23
- * dashboard's own 409 error code for this case — see
24
- * `requireConnectedPlanId`, `plan-session-context.ts`);
25
- * - on success, every count is a finite number, `plan` carries `id` +
26
- * `name`, and every `startable` / `held` entry carries a string `id` +
27
- * `title` — the plugin rejects anything else as `bad_response`, so this
28
- * module validates the server's response BEFORE printing it, rather than
29
- * trusting a 200 status to mean a well-shaped body;
30
- * - human-readable diagnostics go to stderr only, never stdout.
31
- *
32
- * THE CREDENTIAL — the SAME resolver `bridge` uses (DX-2862 / DX-2921 AC),
33
- * via `resolveBridgeOptions` (`bridge.ts`): the dashboard URL, credential
34
- * source and credential all come from the connection record this session's
35
- * OWN danx-dashboard MCP server wrote on its last successful `plan_connect`
36
- * (`session-connection.ts`), never from this process's own ambient env. That
37
- * is deliberate here for the identical reason it is deliberate for `bridge`:
38
- * a hook process trusting whatever `DANXBOT_DISPATCH_TOKEN` happens to be in
39
- * its environment is precisely the DX-2862 bug (a hook's ambient credential
40
- * silently pointed at a different dashboard from the one the session's tools
41
- * actually use).
42
- *
43
- * A HARD TIMEOUT (AC 30672) — `PLAN_STATE_REQUEST_TIMEOUT_MS`. This call
44
- * blocks a turn boundary the plugin is holding open, so it must never hang:
45
- * a slow or wedged dashboard is `{ok:false, reason:"timeout"}`, not a stuck
46
- * hook.
47
- *
48
- * THE REQUEST/RUN SCAFFOLDING IS SHARED WITH `background-work.ts`
49
- * (`one-shot-session-request.ts`, DX-3367 fix-up).
50
- */
51
- import { resolveSessionOptions, sendSessionRequest, tryParseJsonBody } from "./one-shot-session-request.js";
52
- export const PLAN_STATE_SUBCOMMAND = "plan-state";
53
- export const TURN_STATE_PATH = "/api/plan-sessions/me/turn-state";
54
- /** How long this one-shot call may take before it counts as a timeout. */
55
- export const PLAN_STATE_REQUEST_TIMEOUT_MS = 5_000;
56
- const USAGE = `usage: CLAUDE_CODE_SESSION_ID=<session-id> danx-dashboard-mcp ${PLAN_STATE_SUBCOMMAND}`;
57
- function errorCodeOf(body) {
58
- return typeof body === "object" && body !== null && typeof body.error === "string"
59
- ? body.error
60
- : null;
61
- }
62
- function isFiniteNumber(v) {
63
- return typeof v === "number" && Number.isFinite(v);
64
- }
65
- function isCardRef(v) {
66
- return (typeof v === "object" &&
67
- v !== null &&
68
- typeof v.id === "string" &&
69
- typeof v.title === "string");
70
- }
71
- /**
72
- * The strict shape check the contract pins (comment 4245): finite counts, a
73
- * `plan` with `id` + `name`, and a string `id` + `title` on every
74
- * `startable` / `held` entry. `null` on anything short of that — a 200 whose
75
- * body does not match is `bad_response`, exactly like a non-2xx.
76
- */
77
- export function parseTurnStateBody(body) {
78
- if (typeof body !== "object" || body === null)
79
- return null;
80
- const b = body;
81
- const plan = b.plan;
82
- if (typeof plan !== "object" || plan === null)
83
- return null;
84
- const p = plan;
85
- if (!isFiniteNumber(p.id) || typeof p.name !== "string")
86
- return null;
87
- const counts = b.counts;
88
- if (typeof counts !== "object" || counts === null)
89
- return null;
90
- const c = counts;
91
- if (!isFiniteNumber(c.open) || !isFiniteNumber(c.inProgress) || !isFiniteNumber(c.needsYou))
92
- return null;
93
- if (!Array.isArray(b.startable) || !b.startable.every(isCardRef))
94
- return null;
95
- if (!Array.isArray(b.held) || !b.held.every(isCardRef))
96
- return null;
97
- return {
98
- plan: { id: p.id, name: p.name },
99
- counts: { open: c.open, inProgress: c.inProgress, needsYou: c.needsYou },
100
- startable: b.startable,
101
- held: b.held,
102
- };
103
- }
104
- /**
105
- * One authenticated GET, with a hard timeout, classified into the output
106
- * shape this subcommand always prints — never a throw, never a non-2xx
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`.
115
- */
116
- export async function fetchTurnState(options, deps) {
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" };
130
- }
131
- }
132
- if (status === 401 || status === 403) {
133
- return { ok: false, reason: "unauthorized" };
134
- }
135
- if (status < 200 || status >= 300) {
136
- return { ok: false, reason: "http_error" };
137
- }
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) {
143
- return { ok: false, reason: "bad_response" };
144
- }
145
- return { ok: true, ...turnState };
146
- }
147
- /** The bin's `plan-state` subcommand, wired to the real process. Always resolves to exit code 0. */
148
- export async function runPlanStateCommand(argv, env = process.env,
149
- /** Test seam: the home the session's connection record is read from. */
150
- resolveFrom = {}) {
151
- const write = (output) => {
152
- process.stdout.write(`${JSON.stringify(output)}\n`);
153
- };
154
- if (argv.length !== 0) {
155
- // A malformed invocation is still a "the hook must stay silent" failure,
156
- // not a crash — same ok:false/exit-0 contract as every other failure
157
- // path here, with the usage line reserved for a human reading stderr.
158
- write({ ok: false, reason: "usage" });
159
- process.stderr.write(`${USAGE}\n`);
160
- return 0;
161
- }
162
- const resolved = await resolveSessionOptions(env, resolveFrom, USAGE);
163
- if (!resolved.ok) {
164
- write(resolved.output);
165
- process.stderr.write(`${resolved.detail}\n`);
166
- return 0;
167
- }
168
- const output = await fetchTurnState(resolved.options, {
169
- fetch: (input, init) => fetch(input, init),
170
- requestTimeoutMs: PLAN_STATE_REQUEST_TIMEOUT_MS,
171
- });
172
- write(output);
173
- return 0;
174
- }