@thehammer/danx-dashboard-mcp 0.1.286 → 0.1.287

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
@@ -9,7 +9,7 @@ Stdio MCP server fronting danxbot's dashboard HTTP API. Since DX-3465 it exposes
9
9
  | Var | Purpose |
10
10
  |---|---|
11
11
  | `DANXBOT_DASHBOARD_URL` | Dashboard base, e.g. `http://danxbot-dashboard:5555` (inside compose) or `http://localhost:5555` (host). An operator session gets it from the danxbot plugin: its `dashboard_url` option (default the hosted dashboard), overridden by `DANXBOT_DASHBOARD_URL` in your shell or a repo's committed `.claude/settings.json` `env` block |
12
- | `DANX_DASHBOARD_CREDENTIAL` | A DISPATCH's credential: the bearer verbatim, substituted into the materialized `.mcp.json` from its overlay (DX-2862, `src/credential.ts`). Authoritative when set; it never signs out. An operator session sets nothing: with no credential the server boots SIGNED OUT, every tool except `plan_connect` returns one "not signed in" halt, and `plan_connect` requests access (below). The one-shot subcommands use the same resolver, so a session's tools and its hook reports can never sign in as two different identities; the relay in this server (`plan_events_wait`) signs in with the server's own credential |
12
+ | `DANX_DASHBOARD_CREDENTIAL` | A DISPATCH's credential: the bearer verbatim, substituted into the materialized `.mcp.json` from its overlay (DX-2862, `src/credential.ts`). Authoritative when set; it never signs out. An operator session sets nothing: with no credential the server boots SIGNED OUT, every tool except `plan_connect` returns one "not signed in" halt, and `plan_connect` requests access (below). `restart_notice` uses the same resolver, so a session's tools and its predecessor lookups can never sign in as two different identities; the relay in this server (`plan_events_wait`) signs in with the server's own credential |
13
13
  | `DANXBOT_TEAM` | OPTIONAL, non-secret (DX-4446): the team slug (or id) whose approvers see this session's access request in their danxbot site. Set it in your shell or a repo's committed `.claude/settings.json` `env` block. Unset, the request is answered by link only. A value that is not a team slug fails at boot; a team the dashboard does not know makes `plan_connect` answer `unknown_team` (fix `DANXBOT_TEAM`), never a silent link fallback |
14
14
  | `DANX_REPO_NAME` | OPTIONAL (DX-4555): repo half of the qualified board id; sent as the access request's repo label |
15
15
  | `DANXBOT_BOARD_NAME` | OPTIONAL (DX-4555; needs `DANX_REPO_NAME`): board-slug half of the qualified board id (the dispatch injects `board.slug` here). With neither variable set (the danxbot plugin's own server) the server has no default board: a call that needs a board must carry its `board` argument or the dashboard answers 400 (plan and plan-session routes take their boards from the plan and need none) |
@@ -20,7 +20,7 @@ BOARD-ONLY (DX-1171): board is the first-level concept; repo is DERIVED from boa
20
20
 
21
21
  An operator session has no stored credential. Signed out, `plan_connect` (optionally with `title`, the session's own Claude title) posts `POST /api/access-requests` and answers at once `{state: "approval_required", approvalUrl, confirmCode, expiresAt, instruction}`: show the user the code and open the URL; they approve in the dashboard. The server polls `POST /api/access-requests/claim` in the background, so the key is stored when the approval lands; the next `plan_connect` waits up to about 45 seconds for it, then connects, or answers `approval_pending`. One tool call never blocks for minutes. `denied` is reported once; a request that lapsed (the dashboard deletes it within about a minute of expiry, so a claim answers 404) starts a fresh one on the next call.
22
22
 
23
- - **Storage.** `~/.config/danxbot/session-keys/<sha12(dashboard url)>/<session id>.json`, mode 0600, written write-then-rename, pruned after 7 days. One file per dashboard and per session; this server reads it at boot, and a one-shot subcommand reads the same file through the `session-key` credential source and proves the fingerprint. The key lapses on the dashboard after 24 hours without use (every authenticated call, the relay's heartbeat included, renews it), and is revocable from the account page.
23
+ - **Storage.** `~/.config/danxbot/session-keys/<sha12(dashboard url)>/<session id>.json`, mode 0600, written write-then-rename, pruned after 7 days. One file per dashboard and per session; this server reads it at boot, and `restart_notice` reads the same file through the `session-key` credential source and proves the fingerprint. The key lapses on the dashboard after 24 hours without use (every authenticated call, the relay's heartbeat included, renews it), and is revocable from the account page.
24
24
  - **A 401 on the stored key** deletes the file and returns the sign-in halt (in `plan_connect`: a new request in the same call). That is a key that lapsed. A key a PERSON revoked is answered `401 key_revoked` (DX-4418): the key file is deleted and EVERY dashboard tool, `plan_connect` included, returns one stop halt (who revoked it and when; commit work in progress and end; a dispatched agent makes no further call), for the rest of the process. No access request is made. The worker finds the revocation itself and ends the dispatch as revoked (every dashboard route, the dispatch-complete one included, refuses a revoked key). `plan_events_wait` answers `stopped` with the reason `key_revoked` the same way. **A 404 on create** means the dashboard predates request-and-approve and says so.
25
25
  - A dispatch's `DANX_DASHBOARD_CREDENTIAL` skips all of this. There is no token-file credential source.
26
26
 
@@ -79,8 +79,6 @@ The contract the plugin builds against:
79
79
  - **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.
80
80
  - **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.
81
81
 
82
- 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).
83
-
84
82
  ## `restart_notice` — what a restarted session was working on (DX-3928, DX-4632)
85
83
 
86
84
  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:
@@ -91,10 +89,6 @@ A tool for the danxbot plugin's session-start hook, not for a model, advertised
91
89
 
92
90
  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.
93
91
 
94
- ## `ready-cards` — the plan's cards anyone could pick up now (DX-4534)
95
-
96
- Run by the danxbot plugin's Stop hook for a plan-connected session. `GET /api/plans/mine` answers the session's plan and the boards its cards live on (a session on no plan is a 409 `session_not_connected`, read as an empty answer); then, for each board, `GET /api/issues?board=` with `filter.plan_id` + `filter.status_derived` returns the plan's ToDo cards, and the ready ones are kept: a Story, Bug or Chore with no assigned agent, no live dispatch, no block, no open problem, no unmet `depends_on` and no active `conflict_on`. No dashboard route was added. Exit 0 prints one JSON line `{"plan_id": <n|null>, "cards": [{"id", "title"}]}`; exit 1 prints one stderr line that starts with its reason (`credential_unavailable`, `no_connection_record`, `http_error`, ...) and nothing on stdout.
97
-
98
92
  ## `subagents-live` — the plan pane's local live sub-agent feed (DX-4508)
99
93
 
100
94
  `danx-dashboard-mcp subagents-live <absolute path to the session's main transcript .jsonl>`. Spawned by the danxbot plugin's plan pane, which cannot tail files itself; the dashboard's sub-agent read lags the transcript by about 80s, this by about one second. It tails the session's transcript tree (the main file and `<session-id>/subagents/agent-*.jsonl` beside it) with the same `SessionLogWatcher` the relay runs, from byte 0 once and then only appended bytes, polling every second, and computes each sub-agent through `@thehammer/danx-session-telemetry`'s `createSubagentLiveView` (the same observer, ledger counter and pricing rule the dashboard uses). stdout carries only `{"subagents":[...]}` lines: the first, after the catch-up read, holds every sub-agent (an empty array when there are none); each later one holds only the sub-agents a poll changed. No network, no credential. A missing, relative or non-`.jsonl` path exits 2 with one stderr line; SIGTERM, SIGINT or a closed stdout exits 0.
@@ -2,10 +2,10 @@
2
2
  * DX-2862 / DX-4390 - the ONE resolver for this package's dashboard credential.
3
3
  *
4
4
  * WHY IT IS ONE FUNCTION AND NOT TWO. Two kinds of caller speak to the dashboard for the same
5
- * Claude Code session: the MCP SERVER (the session's tools and its event relay) and the one-shot subcommands the plugin runs
6
- * from hooks. When they resolved their credential independently they could differ (DX-2862): the session's tools worked, a
7
- * subcommand authenticated as somebody else, and its result was dropped with no error anywhere. So resolution lives HERE,
8
- * both go through it, and a subcommand additionally PROVES it holds the server's credential by comparing fingerprints
5
+ * Claude Code session: the MCP SERVER (the session's tools and its event relay) and the `restart_notice` predecessor lookup.
6
+ * When they resolved their credential independently they could differ (DX-2862): the session's tools worked, a
7
+ * lookup authenticated as somebody else, and its result was dropped with no error anywhere. So resolution lives HERE,
8
+ * both go through it, and the lookup additionally PROVES it holds the server's credential by comparing fingerprints
9
9
  * (`session-connection.ts`).
10
10
  *
11
11
  * A credential comes from exactly one of two places:
@@ -27,7 +27,7 @@ export const CREDENTIAL_TOKEN_ENV = "DANX_DASHBOARD_CREDENTIAL";
27
27
  /**
28
28
  * A credential that cannot be resolved. Carries the remedy beside the reason, because both of this
29
29
  * module's callers report failures to a human who is not reading a log: the server prints it to
30
- * stderr, and a one-shot subcommand prints it for the hook that ran it.
30
+ * stderr, and `restart_notice` answers it to the session.
31
31
  */
32
32
  export class CredentialError extends Error {
33
33
  fix;
package/dist/index.js CHANGED
@@ -88,9 +88,6 @@ import { REQUEST_TIMEOUT_MS, realScheduleInterval, realSleep } from "./session-s
88
88
  import { servePlanEventsWait } from "./plan-events-tool.js";
89
89
  import { lookupRestartNotice, RESTART_NOTICE_ARGS } from "./restart-notice.js";
90
90
  import { stopWhenSessionEnds } from "./shutdown.js";
91
- import { BACKGROUND_WORK_SUBCOMMAND, runBackgroundWorkCommand } from "./background-work.js";
92
- import { ACTIVITY_SUBCOMMAND, runActivityCommand } from "./activity.js";
93
- import { READY_CARDS_SUBCOMMAND, runReadyCardsCommand } from "./ready-cards.js";
94
91
  import { SUBAGENTS_LIVE_SUBCOMMAND, runSubagentsLiveCommand } from "./subagents-live.js";
95
92
  import { resolveBootCredential } from "./credential.js";
96
93
  import { SessionKeyUnusableError, deleteSessionKey } from "./session-key.js";
@@ -161,10 +158,10 @@ let client;
161
158
  let listenerReminder;
162
159
  /**
163
160
  * DX-4390 — this server's credential: a dispatch's env bearer, this session's approved key, or
164
- * none (signed out). The plan event relay in this server signs in with it directly (`access.currentToken()`). The one-shot
165
- * subcommands the plugin runs from hooks cannot see it, so `access.connection()` (the URL, where the credential is, its
161
+ * none (signed out). The plan event relay in this server signs in with it directly (`access.currentToken()`).
162
+ * `restart_notice` runs against other sessions' credentials and cannot see it, so `access.connection()` (the URL, where the credential is, its
166
163
  * fingerprint; never the credential itself) is written to the session's connection record on a successful `plan_connect`
167
- * for them.
164
+ * for it.
168
165
  */
169
166
  let access;
170
167
  // DX-4434 — late-bound with `access` and `client`; see `permission-request.ts`.
@@ -329,8 +326,8 @@ async function syncToolsAfterSignIn() {
329
326
  */
330
327
  const sleepHoldingProcess = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
331
328
  /**
332
- * DX-2862 / DX-4390 — the credential this server boots with, through the ONE resolver the
333
- * one-shot subcommands also use (`session-options.ts`), so a session's tools and its hook reports can never sign in as
329
+ * DX-2862 / DX-4390 — the credential this server boots with, through the ONE resolver
330
+ * `restart_notice` also uses (`session-options.ts`), so a session's tools and its lookups can never sign in as
334
331
  * two different identities. None is not an error: the server boots signed out. A stored key that
335
332
  * is there but unusable is reported loudly and deleted - it is a cache of a revocable key, and the
336
333
  * session asks for a new one.
@@ -737,7 +734,7 @@ strictTool("plan_connect",
737
734
  'The `naming` block\'s exact `{Name}: PLAN-NNN {short title}`, after renaming this thread. Omit on the first connect.'),
738
735
  disconnect: z.boolean().optional().describe("true = leave `plan_id` (your current plan); no `title`."),
739
736
  },
740
- // DX-2862 — the connect is also the moment the session's connection record is written for the one-shot subcommands: of the
737
+ // DX-2862 — the connect is also the moment the session's connection record is written for `restart_notice` and the listener reminder: of the
741
738
  // dashboard MCP servers a session may have configured, the one that connected the plan is the one whose credential they
742
739
  // must resolve (the relay needs none of this: it lives in each server, with that server's own credential).
743
740
  async (args) => {
@@ -861,40 +858,14 @@ async function main() {
861
858
  // symlink-aware (DX-1647) so it holds under the symlinked `npx` bin the worker
862
859
  // spawns, not just a direct `node dist/index.js`.
863
860
  //
864
- // The subcommands are one-shot clients (activity, background-work, ready-cards, subagents-live)
865
- // the danxbot plugin runs. None boots the MCP server; the dashboard URL, credential source and credential come from the
866
- // session-connection record this server's own `plan_connect` wrote (`session-connection.ts`), never from the subcommand's own
867
- // environment (DX-2862: trusting an ambient env credential is the exact bug this repo fixed). There is no event-stream
868
- // subcommand: a session's plan events are served in this process by `plan_events_wait` (DX-4233).
861
+ // The only subcommand is `subagents-live`, which the danxbot plugin's plan pane runs (DX-4508); it does not boot the MCP
862
+ // server and uses no network or credential. A session's plan events are served in this process by `plan_events_wait` (DX-4233).
869
863
  //
870
864
  // Any OTHER argument is refused rather than ignored, so a typo cannot
871
865
  // silently start an MCP server on stdio where a subcommand was meant to run.
872
866
  if (isEntrypointModule(import.meta.url, process.argv[1])) {
873
867
  const [subcommand, ...rest] = process.argv.slice(2);
874
- if (subcommand === BACKGROUND_WORK_SUBCOMMAND) {
875
- runBackgroundWorkCommand(rest).then((code) => process.exit(code), (err) => {
876
- console.error(`[danx-dashboard-mcp] background-work fatal: ${err.message}`);
877
- process.stdout.write(`${JSON.stringify({ ok: false, reason: "fatal" })}\n`);
878
- process.exit(0);
879
- });
880
- }
881
- else if (subcommand === ACTIVITY_SUBCOMMAND) {
882
- // DX-3284 — the same hook-only contract as background-work: a rejection prints one
883
- // failure line and exits 0, so an async hook never surfaces a stack trace.
884
- runActivityCommand(rest).then((code) => process.exit(code), (err) => {
885
- console.error(`[danx-dashboard-mcp] activity fatal: ${err.message}`);
886
- process.stdout.write(`${JSON.stringify({ ok: false, reason: "fatal" })}\n`);
887
- process.exit(0);
888
- });
889
- }
890
- else if (subcommand === READY_CARDS_SUBCOMMAND) {
891
- // DX-4534 — the plugin Stop hook's read; exit 0 (one JSON line) / 1 (read failed).
892
- runReadyCardsCommand(rest).then((code) => process.exit(code), (err) => {
893
- console.error(`[danx-dashboard-mcp] ${READY_CARDS_SUBCOMMAND} fatal: ${err.message}`);
894
- process.exit(1);
895
- });
896
- }
897
- else if (subcommand === SUBAGENTS_LIVE_SUBCOMMAND) {
868
+ if (subcommand === SUBAGENTS_LIVE_SUBCOMMAND) {
898
869
  // DX-4508 — the plugin pane's local live sub-agent feed: stdout is its JSON Lines channel, so a fatal goes to
899
870
  // stderr only and exits 1; a refused path exits 2 from the command itself.
900
871
  runSubagentsLiveCommand(rest).then((code) => process.exit(code), (err) => {
@@ -903,9 +874,8 @@ if (isEntrypointModule(import.meta.url, process.argv[1])) {
903
874
  });
904
875
  }
905
876
  else if (subcommand !== undefined) {
906
- console.error(`[danx-dashboard-mcp] unknown subcommand "${subcommand}" (the only ones are ` +
907
- `"${BACKGROUND_WORK_SUBCOMMAND}", "${ACTIVITY_SUBCOMMAND}", ` +
908
- `"${READY_CARDS_SUBCOMMAND}", and "${SUBAGENTS_LIVE_SUBCOMMAND}")`);
877
+ console.error(`[danx-dashboard-mcp] unknown subcommand "${subcommand}" (the only one is ` +
878
+ `"${SUBAGENTS_LIVE_SUBCOMMAND}")`);
909
879
  process.exit(2);
910
880
  }
911
881
  else {
@@ -1,7 +1,6 @@
1
1
  /**
2
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
3
+ * the request early (a lookup's one deadline, a closed relay). Used by `sendSessionRequest` (`restart-notice.ts`) and by `request` in `session-stream.ts` (the relay's mint, inventory and
5
4
  * heartbeat), so the controller, the timer, the link to the outer signal and the cleanup exist once.
6
5
  */
7
6
  export function boundRequest(timeoutMs, outer) {
@@ -7,7 +7,7 @@
7
7
  * working was unattended: six operator answers sat unread for an hour on 2026-10-01. The session's own connection record cannot
8
8
  * help (it does not exist yet); the PREVIOUS session's record can, found by the project key `plan_connect` wrote into it
9
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
10
+ * that predecessor's record names (`resolveDashboardAuth`, the resolver `session-options.ts` holds), which a signed-out
11
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
12
  * permission) and only on a person's session (`tool-access.ts`).
13
13
  *
@@ -41,12 +41,58 @@
41
41
  * no cursor on the server.
42
42
  */
43
43
  import { z } from "zod";
44
- import { sendSessionRequest, tryParseJsonBody } from "./one-shot-session-request.js";
45
- import { resolveDashboardAuth, SessionStartError } from "./session-options.js";
44
+ import { boundRequest } from "./request-bound.js";
45
+ import { resolveDashboardAuth, SESSION_ID_HEADER, SessionStartError } from "./session-options.js";
46
46
  import { findPredecessorCandidates, projectKeyOf, readSessionConnection } from "./session-connection.js";
47
47
  import { checkedTranscriptPath } from "./session-stats.js";
48
48
  import { readTranscriptLineage, TranscriptReadAborted, TranscriptUnreadable } from "./transcript-lineage.js";
49
49
  import { fixForStop } from "./stop-fixes.js";
50
+ /**
51
+ * One authenticated HTTP call to this session's dashboard, with a hard
52
+ * timeout. Never throws — a network failure or an abort is reported as
53
+ * `RequestFailure`, exactly like any other classified failure the caller
54
+ * might produce from the status/body it gets back on success.
55
+ */
56
+ export async function sendSessionRequest(options, path, init, deps) {
57
+ const bound = boundRequest(deps.requestTimeoutMs, deps.signal);
58
+ try {
59
+ try {
60
+ const response = await deps.fetch(`${options.dashboardUrl}${path}`, {
61
+ method: init.method,
62
+ headers: {
63
+ Authorization: `Bearer ${options.token}`,
64
+ Accept: "application/json",
65
+ ...(init.body !== undefined ? { "Content-Type": "application/json" } : {}),
66
+ [SESSION_ID_HEADER]: options.sessionId,
67
+ },
68
+ body: init.body,
69
+ signal: bound.signal,
70
+ });
71
+ const text = await response.text();
72
+ return { status: response.status, text };
73
+ }
74
+ catch {
75
+ // DX-3367 fix-up — the caught value is never inspected (an aborted
76
+ // fetch's own rejection shape varies by runtime), so binding it to a
77
+ // name the code never reads was dead weight the linter never caught.
78
+ return { ok: false, reason: bound.aborted() ? "timeout" : "request_failed" };
79
+ }
80
+ }
81
+ finally {
82
+ bound.release();
83
+ }
84
+ }
85
+ /** `JSON.parse`, classified rather than thrown — the ONE shared parse point
86
+ * every caller uses once it has decided (from `status`) that the body is
87
+ * worth reading at all. */
88
+ export function tryParseJsonBody(text) {
89
+ try {
90
+ return { ok: true, body: text === "" ? null : JSON.parse(text) };
91
+ }
92
+ catch {
93
+ return { ok: false };
94
+ }
95
+ }
50
96
  export const RESTART_NOTICE_REQUEST_TIMEOUT_MS = 5_000;
51
97
  /** DX-4632: the whole lookup, every candidate included (the old hook's `timeout 8s`). */
52
98
  export const RESTART_NOTICE_DEADLINE_MS = 8_000;
@@ -58,7 +104,7 @@ export const RESTART_NOTICE_DEADLINE_MS = 8_000;
58
104
  export const RESTART_NOTICE_MAX_CANDIDATES = 10;
59
105
  /** The registry row whose text is the notice (`src/issues/reminders/seed.yaml`). */
60
106
  export const RESTART_NOTICE_REMINDER_KEY = "plan_restart.waiting_events";
61
- /** One authenticated GET (the shared one-shot request, so its bound and its failure names are the same), answering the notice text or `null`. */
107
+ /** One authenticated GET (`sendSessionRequest` above, so its bound and its failure names are the same), answering the notice text or `null`. */
62
108
  export async function fetchRestartNotice(target, deps) {
63
109
  // The bearer is the predecessor's; the session id header names the ASKING session.
64
110
  const auth = { dashboardUrl: target.dashboardUrl, token: target.token, sessionId: target.askingSessionId };
@@ -1,9 +1,8 @@
1
1
  /**
2
2
  * DX-2862 — the record that hands ONE Claude Code session's dashboard
3
- * connection from its MCP server to the plugin's hook subcommands.
3
+ * connection from its MCP server to its other readers (`restart_notice`, the listener reminder).
4
4
  *
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
5
+ * THE PROBLEM THIS SOLVES. A reader outside the server process runs in the SESSION's environment, not in the MCP server's, which is the only
7
6
  * place the server's `.mcp.json` env block exists. So it cannot see what the server was configured with, and before this it
8
7
  * simply used its own ambient env credential. When that was a different credential from the server's, its report was
9
8
  * dropped silently (the card's incident). The plan event relay (`plan_events_wait`) needs none of this: it runs inside the
@@ -11,8 +10,8 @@
11
10
  *
12
11
  * WHAT IS WRITTEN, AND WHAT IS NOT. On a successful `plan_connect` the server
13
12
  * writes the dashboard URL, the credential SOURCE (a session key file path, or the name of
14
- * an env var — never a credential) and the credential's FINGERPRINT. A subcommand
15
- * reads that record, resolves the SAME source through the same resolver
13
+ * an env var — never a credential) and the credential's FINGERPRINT. A reader
14
+ * (`restart_notice`) reads that record, resolves the SAME source through the same resolver
16
15
  * (`credential.ts`), and refuses to run unless what it resolved fingerprints
17
16
  * identically. A rotated file or a session environment that disagrees with the
18
17
  * server's therefore fails loud instead of reporting as somebody else.
@@ -91,7 +90,7 @@ export function pruneStaleSessionFiles(dir, now) {
91
90
  }
92
91
  }
93
92
  /**
94
- * Record this session's connection. Write-then-rename, so a subcommand reading
93
+ * Record this session's connection. Write-then-rename, so a reader
95
94
  * concurrently sees the old record or the new one, never a torn one.
96
95
  */
97
96
  export function writeSessionConnection(record, options = {}) {
@@ -146,8 +145,8 @@ export function readSessionConnection(sessionId, options = {}) {
146
145
  * that tool's own answer when it could not be recorded.
147
146
  *
148
147
  * FAIL LOUD, IN THE ONE PLACE THE AGENT IS LOOKING. A connect whose record is
149
- * missing produces a session that is connected but whose hook reports (activity, background work, ready cards, the
150
- * restart notice) refuse to run — so the failure belongs in the connect's answer, not only in a file
148
+ * missing produces a session that is connected but whose `restart_notice` lookup and listener reminder cannot
149
+ * find its connection — so the failure belongs in the connect's answer, not only in a file
151
150
  * nobody reads. It does NOT fail the connect itself: the session IS connected,
152
151
  * and reporting otherwise would be a second lie on top of the first.
153
152
  */
@@ -157,7 +156,7 @@ export function recordSessionConnectionAfterConnect(result, inputs, write = writ
157
156
  if (inputs.sessionId === undefined || inputs.sessionId === "") {
158
157
  return {
159
158
  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 and ready-cards reports will not reach the dashboard",
159
+ "`restart_notice` and the listener reminder will not find this session's connection",
161
160
  };
162
161
  }
163
162
  try {
@@ -173,7 +172,7 @@ export function recordSessionConnectionAfterConnect(result, inputs, write = writ
173
172
  catch (err) {
174
173
  return {
175
174
  connection_record_error: `connected, but recording this session's connection failed (${err.message}) — ` +
176
- "the plugin's activity, background-work and ready-cards reports will not reach the dashboard until it succeeds",
175
+ "`restart_notice` and the listener reminder will not find this session's connection until it succeeds",
177
176
  };
178
177
  }
179
178
  }
@@ -1,9 +1,9 @@
1
1
  /**
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
- * it connected a plan (`session-connection.ts`), resolved through the same credential resolver the server used, and refused
5
- * unless it fingerprints identically. (The in-process relay behind `plan_events_wait` needs none of this: it lives inside the
6
- * server that holds the credential.)
2
+ * DX-2862 / DX-4233 - how `restart_notice` (`restart-notice.ts`, this file's only caller) learns which dashboard to call and
3
+ * as whom when it asks about an earlier session. It reads the connection record that session's own MCP server wrote when it
4
+ * connected a plan (`session-connection.ts`), resolves the credential the record names through the same resolver the server
5
+ * used, and refuses unless it fingerprints identically. The in-process relay behind `plan_events_wait` needs none of this: it
6
+ * lives inside the server that holds the credential.
7
7
  */
8
8
  import { homedir } from "node:os";
9
9
  import { credentialFingerprint, describeCredentialSource, readCredential } from "./credential.js";
@@ -52,7 +52,7 @@ export function resolveDashboardAuth(sessionId, env, options = {}) {
52
52
  const fingerprint = credentialFingerprint(token);
53
53
  if (fingerprint !== record.fingerprint) {
54
54
  throw new SessionStartError("credential_mismatch", `${describeCredentialSource(record.source)} now yields credential ${fingerprint}, but this session's ` +
55
- `danx-dashboard MCP server is using ${record.fingerprint} — a one-shot call would authenticate as somebody else`, fixForStop("credential_mismatch"));
55
+ `danx-dashboard MCP server is using ${record.fingerprint} — a lookup would authenticate as somebody else`, fixForStop("credential_mismatch"));
56
56
  }
57
57
  return { dashboardUrl: record.dashboardUrl.replace(/\/+$/, ""), token, sessionId };
58
58
  }
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * DX-4233 - the remedy a session is shown for each way its dashboard event stream can stop, in ONE place.
3
3
  *
4
- * The client that ends in these texts is the in-process relay behind `plan_events_wait` (`plan-events-relay.ts`); the one-shot
5
- * subcommands use `fixForStop` for a start they cannot make. Pure data and one function with no imports.
4
+ * The client that ends in these texts is the in-process relay behind `plan_events_wait` (`plan-events-relay.ts`); `session-options.ts` uses
5
+ * `fixForStop` for a start it cannot make. Pure data and one function with no imports.
6
6
  */
7
7
  /**
8
8
  * The remedy per terminal reason. ONE map, so the message a session is shown and the message a log carries can never drift.
@@ -15,10 +15,10 @@
15
15
  const RESTART_NOTICE_STOP_LEAD = "continue without it";
16
16
  const RESTART_NOTICE_STOP_TAIL = "call plan_connect to connect this session to a plan";
17
17
  export const STOP_FIXES = {
18
- no_connection_record: "call plan_connect again in this session — its danx-dashboard MCP server records the connection a hook command needs " +
18
+ no_connection_record: "call plan_connect again in this session — its danx-dashboard MCP server records the connection that restart_notice and the listener reminder read " +
19
19
  "(a server older than the one that introduced the record writes none)",
20
20
  credential_unavailable: "call plan_connect again to request access: this session's key file is missing or unreadable",
21
- credential_mismatch: "restart this Claude Code session so its MCP server and the plugin's hook commands pick up the same credential, then call plan_connect again",
21
+ credential_mismatch: "restart this Claude Code session so its MCP server resolves its credential afresh, then call plan_connect again",
22
22
  not_connected: "call plan_connect to connect this session to a plan",
23
23
  plan_archived: "the plan was archived or deleted, so this session was disconnected; ask the operator to restore it, then call plan_connect again",
24
24
  unauthorized: "the dashboard no longer accepts this session's key (it lapsed after a day unused): call plan_connect again to request access",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thehammer/danx-dashboard-mcp",
3
- "version": "0.1.286",
3
+ "version": "0.1.287",
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",
package/dist/activity.js DELETED
@@ -1,260 +0,0 @@
1
- /**
2
- * `danx-dashboard-mcp activity <SubagentStart|SubagentStop|PostToolUse> <event-at>` — the
3
- * ONE-SHOT client of `POST /api/plan-sessions/me/activity` (DX-3284).
4
- *
5
- * WHO RUNS IT. The danxbot Claude Code plugin's `SubagentStart`, `SubagentStop` and
6
- * `PostToolUse` (matcher `Bash`) hooks (`danxbot/scripts/activity-report.mjs` in
7
- * claude-plugins), all registered `async: true`. The plugin runs the INSTALLED package
8
- * with `node` (DX-3811), never a cold `npx`, and pipes the hook's own JSON envelope on
9
- * stdin. This module owns what each event MEANS for the session's running set; the plugin
10
- * decides only whether to call at all (a plan-connected session, a background Bash call).
11
- *
12
- * THE THREE EVENTS (the Claude Code hooks reference, `code.claude.com/docs/en/hooks`):
13
- *
14
- * | Event | Reports | Key |
15
- * |----------------|------------------------------------------------------------------|----------------------------|
16
- * | `SubagentStart`| an `agent` activity, running | `agent-<agent_id>` |
17
- * | `SubagentStop` | the same activity, FINISHED | `agent-<agent_id>` |
18
- * | `PostToolUse` | a `bash` activity, running — only for `Bash` with | `tool_use_id` |
19
- * | | `tool_input.run_in_background: true` | |
20
- *
21
- * THE SUB-AGENT KEY. A sub-agent's transcript is `<session>/subagents/agent-<agent_id>.jsonl`,
22
- * and the transcript producer (`createActivityObserver`) keys its row on that file's
23
- * basename, `agent-<agent_id>`. Reporting the bare `agent_id` would upsert a SECOND row for
24
- * the same sub-agent, so the hook-side key is the same basename: the two producers converge
25
- * on one row. The hooks docs disagree on whether `agent_id` already carries the `agent-`
26
- * prefix, so {@link subagentActivityId} normalizes both forms to the one key.
27
- *
28
- * KNOWN LIMITS (DX-3284, pinned in `plan-session-activity.test.ts`):
29
- * - a sub-agent RESUMED after its SubagentStop reports a second start, but the server's
30
- * first finish wins, so the row stays closed;
31
- * - a SubagentStop with no `agent_id` (Claude Code's internal agents) is refused below, and
32
- * the plugin leaves a failure trace. One WITH an id but no recorded start is
33
- * indistinguishable from a stop that beat its (async) start, so it is recorded as an
34
- * already-finished row — it never counts as running. (`transcript_path` cannot supply it: on these events it is the MAIN session's
35
- * transcript — only `SubagentStop`'s `agent_transcript_path` names the sub-agent's own, and
36
- * it is used below to REFUSE a mismatch, never to guess one.)
37
- *
38
- * THERE IS NO "BACKGROUND SHELL FINISHED" EVENT. None of the documented hook events fires
39
- * when a `run_in_background` Bash command ends (verified against the full event list on
40
- * DX-2954's Step 0 note; `TaskCreated` / `TaskCompleted` are the `TaskCreate` tool, not a
41
- * shell). A `bash` activity therefore gets a start here and no finish: its end comes from
42
- * the dispatch's own terminal write (`finishOpenActivities`), never from inferring one out
43
- * of transcript lines. Until then it reads as running and silent, which is the honest
44
- * signal. Do not "fix" this with a proxy event.
45
- *
46
- * THE EVENT TIME is the hook's own, captured by the plugin when the hook FIRED and passed
47
- * as `<event-at>` — never this process's clock, which starts only after the (possibly
48
- * cold) install step. The server's upsert is monotone in every field, so hooks that finish
49
- * out of order, or one that fires twice, still reach the same row.
50
- *
51
- * THE CONTRACT (identical to `background-work`, so the plugin treats both alike):
52
- * - print EXACTLY ONE JSON line on stdout and ALWAYS exit 0 — a hook has no business
53
- * spamming the session with a stack trace;
54
- * - `{"ok":true,"runningActivities":<n>}` on success;
55
- * - `{"ok":false,"reason":"<why>"}` otherwise. `no_connection_record` and
56
- * `session_not_connected` are the ordinary "this session is not on a plan" answers;
57
- * - human-readable diagnostics go to stderr only.
58
- *
59
- * THE CREDENTIAL — the SAME resolver `plan_events_wait` uses (`resolveSessionOptions`): the URL,
60
- * credential source and credential come from the connection record this session's own MCP
61
- * server wrote on `plan_connect`, never from this process's ambient env (DX-2862).
62
- */
63
- import { win32 } from "node:path";
64
- import { readFileSync } from "node:fs";
65
- import { labelOf } from "@thehammer/danx-session-telemetry";
66
- import { parseEventAtArg } from "./background-work.js";
67
- import { resolveSessionOptions, sendSessionRequest, tryParseJsonBody } from "./one-shot-session-request.js";
68
- export const ACTIVITY_SUBCOMMAND = "activity";
69
- export const ACTIVITY_PATH = "/api/plan-sessions/me/activity";
70
- /** How long this one-shot call may take before it counts as a timeout. */
71
- export const ACTIVITY_REQUEST_TIMEOUT_MS = 5_000;
72
- /** The hook events this subcommand accepts, exactly as Claude Code names them in `hook_event_name`. */
73
- export const ACTIVITY_HOOK_EVENTS = ["SubagentStart", "SubagentStop", "PostToolUse"];
74
- const USAGE = `usage: CLAUDE_CODE_SESSION_ID=<session-id> danx-dashboard-mcp ${ACTIVITY_SUBCOMMAND} <${ACTIVITY_HOOK_EVENTS.join("|")}> <event-at ISO-8601 UTC> (the hook's JSON envelope on stdin)`;
75
- const SUBAGENT_PREFIX = "agent-";
76
- /**
77
- * A sub-agent's transcript file basename — the key `createActivityObserver` reports it under.
78
- *
79
- * DX-3284: the hooks documentation shows `agent_id` BOTH ways — SubagentStart's example carries
80
- * the `agent-` prefix (`agent-abc123`), SubagentStop's does not (`def456`, against the file
81
- * `agent-def456.jsonl`). The key is `agent-` + the id with ONE leading `agent-` stripped, so both
82
- * forms converge on the transcript producer's row; keying on the raw id would leave a phantom
83
- * `agent-agent-<id>` row that no stop ever closes.
84
- */
85
- export function subagentActivityId(agentId) {
86
- const bare = agentId.startsWith(SUBAGENT_PREFIX) ? agentId.slice(SUBAGENT_PREFIX.length) : agentId;
87
- return `${SUBAGENT_PREFIX}${bare}`;
88
- }
89
- /**
90
- * DX-4498 — the facts only a transcript knows (the spawning sub-agent, effort, current tool call, how a background task
91
- * ended, the card its spawning `Agent` call names). A hook envelope carries none of them, so it reports each as unknown; the server's merge
92
- * never lets an unknown erase a value the transcript producer stored. A `SubagentStop` names no status either: its
93
- * finish reads as `done` unless a task notification says otherwise.
94
- */
95
- const NO_TRANSCRIPT_FACTS = {
96
- parentActivityId: null,
97
- effort: null,
98
- currentActivity: null,
99
- endStatus: null,
100
- briefIssueId: null,
101
- };
102
- function nonEmptyString(value) {
103
- return typeof value === "string" && value !== "";
104
- }
105
- /**
106
- * One hook envelope -> the ONE `ActivityChange` it stands for, or the reason it stands for
107
- * none. Pure: `eventAtMs` is the hook's own event time.
108
- */
109
- export function activityChangeFromHook(event, envelope, eventAtMs) {
110
- if (typeof envelope !== "object" || envelope === null || Array.isArray(envelope)) {
111
- return { ok: false, reason: "bad_envelope" };
112
- }
113
- const e = envelope;
114
- // The envelope names the event it was fired for; a mismatch is a wiring bug, not data.
115
- if (e.hook_event_name !== undefined && e.hook_event_name !== event) {
116
- return { ok: false, reason: "event_mismatch" };
117
- }
118
- if (event === "PostToolUse") {
119
- const input = e.tool_input;
120
- if (e.tool_name !== "Bash" || typeof input !== "object" || input === null) {
121
- return { ok: false, reason: "not_background_bash" };
122
- }
123
- const toolInput = input;
124
- if (toolInput.run_in_background !== true)
125
- return { ok: false, reason: "not_background_bash" };
126
- if (!nonEmptyString(e.tool_use_id))
127
- return { ok: false, reason: "no_tool_use_id" };
128
- return {
129
- ok: true,
130
- change: {
131
- kind: "bash",
132
- activityId: e.tool_use_id,
133
- agentType: null,
134
- // DX-4498 — agent-written free text, so it is redacted like every label that reaches the running set.
135
- description: labelOf(toolInput.description),
136
- startedAt: eventAtMs,
137
- // No hook ever refreshes or ends a background shell (see the module docblock), so
138
- // its liveness is its start and `finishedAt` stays null until the dispatch ends.
139
- lastActivityAt: eventAtMs,
140
- finishedAt: null,
141
- ...NO_TRANSCRIPT_FACTS,
142
- },
143
- };
144
- }
145
- // A SubagentStop from one of Claude Code's own internal agents carries no `agent_id`: it names
146
- // no activity, so it is refused here (the plugin leaves a failure trace) and nothing is
147
- // written — a stop must never invent a row.
148
- if (!nonEmptyString(e.agent_id) || e.agent_id === SUBAGENT_PREFIX)
149
- return { ok: false, reason: "no_agent_id" };
150
- const activityId = subagentActivityId(e.agent_id);
151
- if (event === "SubagentStop" && e.agent_transcript_path !== undefined) {
152
- // The one place the hook names the sub-agent's own file: if it disagrees with the
153
- // key derived from `agent_id`, this process would upsert a duplicate row. Refuse.
154
- const file = typeof e.agent_transcript_path === "string" ? win32.basename(e.agent_transcript_path, ".jsonl") : null;
155
- if (file !== activityId)
156
- return { ok: false, reason: "agent_id_mismatch" };
157
- }
158
- return {
159
- ok: true,
160
- change: {
161
- kind: "agent",
162
- activityId,
163
- agentType: nonEmptyString(e.agent_type) ? e.agent_type : null,
164
- description: null,
165
- startedAt: eventAtMs,
166
- lastActivityAt: eventAtMs,
167
- finishedAt: event === "SubagentStop" ? eventAtMs : null,
168
- ...NO_TRANSCRIPT_FACTS,
169
- },
170
- };
171
- }
172
- /**
173
- * One authenticated POST, with a hard timeout, classified into the output shape this
174
- * subcommand always prints — never a throw. Status is checked before any body is parsed
175
- * (an HTML 5xx page must read as `http_error`, not `bad_response`); only a 409 needs its
176
- * body, to tell "no plan" from "a worker's session".
177
- */
178
- export async function postActivity(options, change, deps) {
179
- const sent = await sendSessionRequest(options, ACTIVITY_PATH, { method: "POST", body: JSON.stringify({ activities: [change] }) }, deps);
180
- if ("reason" in sent)
181
- return sent;
182
- const { status, text } = sent;
183
- if (status === 401 || status === 403)
184
- return { ok: false, reason: "unauthorized" };
185
- if (status === 409) {
186
- const parsed = tryParseJsonBody(text);
187
- const error = parsed.ok ? parsed.body?.error : undefined;
188
- if (error === "session_not_connected" || error === "session_is_worker")
189
- return { ok: false, reason: error };
190
- return { ok: false, reason: "http_error" };
191
- }
192
- if (status < 200 || status >= 300)
193
- return { ok: false, reason: "http_error" };
194
- const parsed = tryParseJsonBody(text);
195
- if (!parsed.ok)
196
- return { ok: false, reason: "bad_response" };
197
- const body = parsed.body;
198
- if (typeof body?.runningActivities !== "number")
199
- return { ok: false, reason: "bad_response" };
200
- return { ok: true, runningActivities: body.runningActivities };
201
- }
202
- /** The bin's `activity` subcommand, wired to the real process. Always resolves to exit code 0. */
203
- export async function runActivityCommand(argv, env = process.env,
204
- /** Test seam: the home the session's connection record is read from. */
205
- resolveFrom = {},
206
- /** Test seam: the hook envelope's text (the real process reads stdin). */
207
- readEnvelope = () => readFileSync(0, "utf8")) {
208
- const write = (output) => {
209
- process.stdout.write(`${JSON.stringify(output)}\n`);
210
- };
211
- const usageFailure = (detail) => {
212
- write({ ok: false, reason: "usage" });
213
- process.stderr.write(`${detail}\n${USAGE}\n`);
214
- return 0;
215
- };
216
- if (argv.length !== 2)
217
- return usageFailure("expected exactly two arguments");
218
- const event = ACTIVITY_HOOK_EVENTS.includes(argv[0]) ? argv[0] : null;
219
- if (event === null)
220
- return usageFailure(`unknown hook event "${argv[0]}"`);
221
- const eventAt = parseEventAtArg(argv[1]);
222
- if (typeof eventAt === "object")
223
- return usageFailure(eventAt.error);
224
- let envelope;
225
- try {
226
- envelope = JSON.parse(readEnvelope());
227
- }
228
- catch {
229
- write({ ok: false, reason: "bad_envelope" });
230
- process.stderr.write("the hook envelope on stdin is not JSON\n");
231
- return 0;
232
- }
233
- const mapped = activityChangeFromHook(event, envelope, Date.parse(eventAt));
234
- if (!mapped.ok) {
235
- write(mapped);
236
- process.stderr.write(`${event} envelope reports no activity: ${mapped.reason}\n`);
237
- return 0;
238
- }
239
- // The connection record is this session's: an envelope for ANOTHER session must never
240
- // be reported under it.
241
- const envelopeSession = envelope.session_id;
242
- if (envelopeSession !== undefined && envelopeSession !== env.CLAUDE_CODE_SESSION_ID) {
243
- write({ ok: false, reason: "session_mismatch" });
244
- process.stderr.write("the envelope's session_id is not CLAUDE_CODE_SESSION_ID\n");
245
- return 0;
246
- }
247
- const resolved = await resolveSessionOptions(env, resolveFrom, USAGE);
248
- if (!resolved.ok) {
249
- // A session that never `plan_connect`-ed has no connection record: the ordinary
250
- // "not on a plan" case, silent for the plugin.
251
- write(resolved.output);
252
- process.stderr.write(`${resolved.detail}\n`);
253
- return 0;
254
- }
255
- write(await postActivity(resolved.options, mapped.change, {
256
- fetch: (input, init) => fetch(input, init),
257
- requestTimeoutMs: ACTIVITY_REQUEST_TIMEOUT_MS,
258
- }));
259
- return 0;
260
- }
@@ -1,162 +0,0 @@
1
- /**
2
- * `danx-dashboard-mcp background-work <count|clear> <event-at>` — the ONE-SHOT client of
3
- * `PUT /api/plan-sessions/me/background-work` (DX-3367).
4
- *
5
- * WHO RUNS IT. The danxbot Claude Code plugin's `Stop` / `SubagentStop` /
6
- * `SessionStart` / `StopFailure` hooks (`danxbot/hooks/hooks.json` in
7
- * claude-plugins), via the SAME `npx -y @thehammer/danx-dashboard-mcp`
8
- * MECHANISM the plugin already uses for `plan_events_wait` — no separate install, no
9
- * 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 `plan_events_wait` 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.
20
- *
21
- * THE CONTRACT (binding, so a future plugin build against this module can
22
- * trust it without re-reading this file):
23
- * - print EXACTLY ONE JSON line on stdout, and ALWAYS exit 0 — success or
24
- * failure alike, because a hook must stay silent rather than spam the
25
- * session's own turn with a stack trace;
26
- * - a session with no connection record (never `plan_connect`-ed, e.g. an
27
- * operator session that never joined a plan) is a silent, expected
28
- * no-op — `{"ok":false,"reason":"no_connection_record"}` — the plugin
29
- * hook simply does not forward anything further in this case, exactly
30
- * what "no-op when the session isn't plan-connected" means in practice;
31
- * - on success, `{"ok":true,"count":<number|null>,"applied":<boolean>}`
32
- * echoes the SUBMITTED `count`; `applied` is the server's answer to
33
- * whether it was stored (DX-3676: `false` for an out-of-order report
34
- * older than the one already on file — the caller must not treat it as
35
- * the dashboard's current count);
36
- * - DX-3676 — `<event-at>` is the ISO-8601 UTC time the reporting HOOK
37
- * fired, captured by the caller, never this process's own clock: the
38
- * hooks are async and can finish out of order, and the server orders
39
- * reports by this time;
40
- * - human-readable diagnostics go to stderr only, never stdout.
41
- *
42
- * THE CREDENTIAL — the SAME resolver `plan_events_wait` uses, via `resolveDashboardAuth`
43
- * (the relay): 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).
47
- *
48
- * A HARD TIMEOUT — `BACKGROUND_WORK_REQUEST_TIMEOUT_MS`. A `Stop` hook
49
- * blocks the session's turn boundary, so this call must never hang: a slow
50
- * or wedged dashboard is `{ok:false, reason:"timeout"}`, not a stuck hook.
51
- *
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.
57
- */
58
- import { resolveSessionOptions, sendSessionRequest, tryParseJsonBody } from "./one-shot-session-request.js";
59
- export const BACKGROUND_WORK_SUBCOMMAND = "background-work";
60
- export const BACKGROUND_WORK_PATH = "/api/plan-sessions/me/background-work";
61
- /** How long this one-shot call may take before it counts as a timeout. */
62
- export const BACKGROUND_WORK_REQUEST_TIMEOUT_MS = 5_000;
63
- const USAGE = `usage: CLAUDE_CODE_SESSION_ID=<session-id> danx-dashboard-mcp ${BACKGROUND_WORK_SUBCOMMAND} <non-negative-integer|clear> <event-at ISO-8601 UTC>`;
64
- /**
65
- * Parse the ONE positional argument: `clear`, or a non-negative integer
66
- * string. Anything else is a usage error — never guessed or floored.
67
- */
68
- export function parseCountArg(raw) {
69
- if (raw === "clear")
70
- return null;
71
- if (!/^\d+$/.test(raw))
72
- return { error: `count must be "clear" or a non-negative integer, got "${raw}"` };
73
- return Number.parseInt(raw, 10);
74
- }
75
- /** DX-3676 — an ISO-8601 UTC instant, as `Date#toISOString` writes it (the server accepts exactly this). */
76
- const ISO_UTC_INSTANT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,3})?Z$/;
77
- /** Parse the event-time argument (DX-3676). Never substituted with this process's clock. */
78
- export function parseEventAtArg(raw) {
79
- if (!ISO_UTC_INSTANT.test(raw) || Number.isNaN(Date.parse(raw))) {
80
- return { error: `event-at must be an ISO-8601 UTC timestamp (e.g. 2026-09-30T00:00:00.000Z), got "${raw}"` };
81
- }
82
- return raw;
83
- }
84
- /**
85
- * One authenticated PUT, with a hard timeout, classified into the output
86
- * shape this subcommand always prints — never a throw, never a non-2xx
87
- * bubbling past this function.
88
- *
89
- * DX-3367 fix-up — STATUS IS CHECKED BEFORE THE BODY IS EVER PARSED. A 5xx
90
- * (or any other non-2xx) response can carry an HTML error page instead of
91
- * JSON; parsing first would misreport that as `bad_response` when the true
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.
96
- */
97
- export async function putBackgroundWork(options, report, deps) {
98
- const sent = await sendSessionRequest(options, BACKGROUND_WORK_PATH, { method: "PUT", body: JSON.stringify({ count: report.count, eventAt: report.eventAt }) }, deps);
99
- if ("reason" in sent)
100
- return sent;
101
- const { status, text } = sent;
102
- if (status === 401 || status === 403) {
103
- return { ok: false, reason: "unauthorized" };
104
- }
105
- if (status < 200 || status >= 300) {
106
- return { ok: false, reason: "http_error" };
107
- }
108
- const parsed = tryParseJsonBody(text);
109
- if (!parsed.ok)
110
- return { ok: false, reason: "bad_response" };
111
- const body = parsed.body;
112
- if (typeof body !== "object" ||
113
- body === null ||
114
- !("count" in body) ||
115
- (body.count !== null && typeof body.count !== "number") ||
116
- typeof body.applied !== "boolean") {
117
- return { ok: false, reason: "bad_response" };
118
- }
119
- const shaped = body;
120
- return { ok: true, count: shaped.count, applied: shaped.applied };
121
- }
122
- /** The bin's `background-work` subcommand, wired to the real process. Always resolves to exit code 0. */
123
- export async function runBackgroundWorkCommand(argv, env = process.env,
124
- /** Test seam: the home the session's connection record is read from. */
125
- resolveFrom = {}) {
126
- const write = (output) => {
127
- process.stdout.write(`${JSON.stringify(output)}\n`);
128
- };
129
- if (argv.length !== 2) {
130
- write({ ok: false, reason: "usage" });
131
- process.stderr.write(`${USAGE}\n`);
132
- return 0;
133
- }
134
- const parsed = parseCountArg(argv[0]);
135
- if (typeof parsed === "object" && parsed !== null && "error" in parsed) {
136
- write({ ok: false, reason: "usage" });
137
- process.stderr.write(`${parsed.error}\n${USAGE}\n`);
138
- return 0;
139
- }
140
- const count = parsed;
141
- const eventAt = parseEventAtArg(argv[1]);
142
- if (typeof eventAt === "object") {
143
- write({ ok: false, reason: "usage" });
144
- process.stderr.write(`${eventAt.error}\n${USAGE}\n`);
145
- return 0;
146
- }
147
- const resolved = await resolveSessionOptions(env, resolveFrom, USAGE);
148
- if (!resolved.ok) {
149
- // A session that never `plan_connect`-ed has no connection record — this
150
- // is the ORDINARY "not plan-connected" case, not an error the plugin
151
- // should surface: it simply forwards nothing further this turn.
152
- write(resolved.output);
153
- process.stderr.write(`${resolved.detail}\n`);
154
- return 0;
155
- }
156
- const output = await putBackgroundWork(resolved.options, { count, eventAt }, {
157
- fetch: (input, init) => fetch(input, init),
158
- requestTimeoutMs: BACKGROUND_WORK_REQUEST_TIMEOUT_MS,
159
- });
160
- write(output);
161
- return 0;
162
- }
@@ -1,105 +0,0 @@
1
- /**
2
- * DX-3367 fix-up — shared scaffolding for a "one-shot session request"
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.
10
- *
11
- * Two pieces:
12
- * - `sendSessionRequest` — the authenticated HTTP call itself: bearer +
13
- * session header, a hard abort-timeout, and network-level failure
14
- * classification (`timeout` / `request_failed`). Returns the raw
15
- * `{status, text}` UNPARSED — each caller decides for itself which
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`).
20
- * - `resolveSessionOptions` — session-id + credential resolution via
21
- * `resolveDashboardAuth`, with `SessionStartError` narrowed by
22
- * `instanceof` rather than an unchecked `as SessionStartError` cast, so
23
- * an error of some OTHER shape (a bug in the resolver, a thrown
24
- * non-Error) still comes back as a reported reason instead of a value
25
- * whose `.reason`/`.message`/`.fix` fields are silently `undefined`.
26
- */
27
- import { boundRequest } from "./request-bound.js";
28
- import { SESSION_ID_HEADER, resolveDashboardAuth, SessionStartError } from "./session-options.js";
29
- /**
30
- * One authenticated HTTP call to this session's dashboard, with a hard
31
- * timeout. Never throws — a network failure or an abort is reported as
32
- * `OneShotFailure`, exactly like any other classified failure the caller
33
- * might produce from the status/body it gets back on success.
34
- */
35
- export async function sendSessionRequest(options, path, init, deps) {
36
- const bound = boundRequest(deps.requestTimeoutMs, deps.signal);
37
- try {
38
- try {
39
- const response = await deps.fetch(`${options.dashboardUrl}${path}`, {
40
- method: init.method,
41
- headers: {
42
- Authorization: `Bearer ${options.token}`,
43
- Accept: "application/json",
44
- ...(init.body !== undefined ? { "Content-Type": "application/json" } : {}),
45
- [SESSION_ID_HEADER]: options.sessionId,
46
- },
47
- body: init.body,
48
- signal: bound.signal,
49
- });
50
- const text = await response.text();
51
- return { status: response.status, text };
52
- }
53
- catch {
54
- // DX-3367 fix-up — the caught value is never inspected (an aborted
55
- // fetch's own rejection shape varies by runtime), so binding it to a
56
- // name the code never reads was dead weight the linter never caught.
57
- return { ok: false, reason: bound.aborted() ? "timeout" : "request_failed" };
58
- }
59
- }
60
- finally {
61
- bound.release();
62
- }
63
- }
64
- /** `JSON.parse`, classified rather than thrown — the ONE shared parse point
65
- * every caller uses once it has decided (from `status`) that the body is
66
- * worth reading at all. */
67
- export function tryParseJsonBody(text) {
68
- try {
69
- return { ok: true, body: text === "" ? null : JSON.parse(text) };
70
- }
71
- catch {
72
- return { ok: false };
73
- }
74
- }
75
- /**
76
- * Resolve this process's session id + dashboard credential, the identical
77
- * first step every one-shot subcommand's `runXCommand` takes before it can
78
- * make its own request. `detail` is the stderr-only human-readable line the
79
- * caller prints alongside `output` — never part of the stdout JSON contract.
80
- */
81
- export async function resolveSessionOptions(env, resolveFrom, usage) {
82
- const sessionId = env.CLAUDE_CODE_SESSION_ID;
83
- if (!sessionId) {
84
- return { ok: false, output: { ok: false, reason: "no_session_id" }, detail: usage };
85
- }
86
- try {
87
- const options = resolveDashboardAuth(sessionId, env, resolveFrom);
88
- return { ok: true, options };
89
- }
90
- catch (err) {
91
- // DX-3367 fix-up — `instanceof`, never an unchecked `as SessionStartError`
92
- // cast: `resolveDashboardAuth` throws exactly `SessionStartError` today,
93
- // but a cast reports a fabricated reason/message/fix (all `undefined`)
94
- // for any OTHER thrown shape instead of a real one, silently.
95
- if (err instanceof SessionStartError) {
96
- return {
97
- ok: false,
98
- output: { ok: false, reason: err.reason },
99
- detail: `${err.reason}: ${err.message}. Fix: ${err.fix}.`,
100
- };
101
- }
102
- const message = err instanceof Error ? err.message : String(err);
103
- return { ok: false, output: { ok: false, reason: "start_failed" }, detail: `start_failed: ${message}` };
104
- }
105
- }
@@ -1,176 +0,0 @@
1
- /**
2
- * `danx-dashboard-mcp ready-cards` — DX-4534. The ONE-SHOT client the claude-plugins `danxbot`
3
- * plugin's Stop hook runs for a plan-connected session, to learn which of its plan's cards
4
- * anyone could pick up right now.
5
- *
6
- * WHY IT EXISTS. An orchestrating plan session must keep every unblocked card moving, and
7
- * nothing enforced it: on 2026-10-04 one builder ran while two readied, unblocked cards sat
8
- * untouched, because the session only looked for work when a builder reported back. The Stop
9
- * hook turns that check into something the harness runs. This is its read.
10
- *
11
- * NO DASHBOARD ROUTE IS ADDED. It composes existing reads on the session's own credential:
12
- * 1. `GET /api/plans/mine` answers the session's plan (`id`) and the boards its cards live on
13
- * (`boards`) in one read; a session on no plan is a 409 `session_not_connected`.
14
- * 2. `GET /api/issues?board=<b>` with `filter.plan_id` + `filter.status_derived=ToDo` and the
15
- * judged fields, once per board: a plan can span boards (PLAN-11 spans danxbot and
16
- * gpt-manager), and an issues read without `board` sees only the default one.
17
- * Server-side filtering narrows the read; `isReadyCard` is the whole definition of "ready".
18
- *
19
- * READY means a card a dispatcher or a session could claim this minute: a Story, Bug or Chore
20
- * (Epic and Feature are containers, a Task is a leaf nobody dispatches), derived status ToDo,
21
- * nobody holding it (no assigned agent, no live dispatch), and nothing in its way: no hold
22
- * (`blocked`), no open problem (a human is needed), no unmet `depends_on`, no active
23
- * `conflict_on`. A card whose reason it cannot run is recorded on it therefore stops counting,
24
- * which is how the hook's "dispatch it, or record why not" instruction is satisfiable.
25
- *
26
- * THE CONTRACT, which the hook reads by exit code:
27
- * - exit 0, one JSON line on stdout: `{"plan_id": <n|null>, "cards": [{"id","title"}...]}`;
28
- * `plan_id` null (the dashboard has the session on no plan) comes with an empty list.
29
- * - exit 1, one reason on stderr, nothing on stdout: credential, network, non-2xx, bad shape.
30
- */
31
- import { resolveSessionOptions, sendSessionRequest, tryParseJsonBody } from "./one-shot-session-request.js";
32
- export const READY_CARDS_SUBCOMMAND = "ready-cards";
33
- export const READY_CARDS_REQUEST_TIMEOUT_MS = 5_000;
34
- /** `GET /api/issues` page size (its maximum): a plan's ToDo cards never approach it. */
35
- const CARDS_PAGE_SIZE = 1000;
36
- /** The dispatchable card types: Epic and Feature are containers, a Task is a leaf nobody dispatches. */
37
- const DISPATCHABLE_TYPES = new Set(["Story", "Bug", "Chore"]);
38
- /**
39
- * The fields `isReadyCard` judges beyond the always-returned row, each with the check a row's value
40
- * must pass: the one list the request's `fields` tree and the row parse both come from.
41
- */
42
- const JUDGED_FIELDS = {
43
- dispatch: (v) => v !== undefined,
44
- blocked: (v) => v !== undefined,
45
- open_problem_count: (v) => typeof v === "number",
46
- waiting_on: (v) => typeof v === "boolean",
47
- conflict_on_active_count: (v) => typeof v === "number",
48
- };
49
- const CARD_FIELDS = Object.fromEntries(Object.keys(JUDGED_FIELDS).map((name) => [name, true]));
50
- const USAGE = `usage: CLAUDE_CODE_SESSION_ID=<session-id> danx-dashboard-mcp ${READY_CARDS_SUBCOMMAND}`;
51
- /** The row, or null when any field `isReadyCard` judges is missing or the wrong type: a card never counts on a guess. */
52
- function parseCardRow(raw) {
53
- if (typeof raw !== "object" || raw === null)
54
- return null;
55
- const r = raw;
56
- const assigned = r.assigned_agent;
57
- if (typeof r.id !== "string" || typeof r.type !== "string" || typeof r.title !== "string")
58
- return null;
59
- if (!(assigned === null || typeof assigned === "string"))
60
- return null;
61
- if (!Object.entries(JUDGED_FIELDS).every(([name, valid]) => valid(r[name])))
62
- return null;
63
- return r;
64
- }
65
- /** Whether a ToDo card could be claimed right now (see the module banner). */
66
- export function isReadyCard(card) {
67
- return (DISPATCHABLE_TYPES.has(card.type) &&
68
- card.assigned_agent === null &&
69
- card.dispatch === null &&
70
- card.blocked === null &&
71
- card.open_problem_count === 0 &&
72
- !card.waiting_on &&
73
- card.conflict_on_active_count === 0);
74
- }
75
- function classifyStatus(status) {
76
- if (status === 401 || status === 403)
77
- return "unauthorized";
78
- if (status === 404)
79
- return "not_found";
80
- if (status < 200 || status >= 300)
81
- return "http_error";
82
- return null;
83
- }
84
- /** The session's plan and its boards, `null` when the dashboard has the session on no plan, or a failure. */
85
- async function readSessionPlan(options, deps) {
86
- const sent = await sendSessionRequest(options, "/api/plans/mine", { method: "GET" }, deps);
87
- if ("ok" in sent)
88
- return sent;
89
- const parsed = tryParseJsonBody(sent.text);
90
- const body = parsed.ok ? parsed.body : null;
91
- // A session on no plan is the ordinary answer, not a fault: a 409 `session_not_connected`.
92
- if (sent.status === 409 && body?.error === "session_not_connected")
93
- return { ok: true, plan: null };
94
- const failed = classifyStatus(sent.status);
95
- if (failed !== null)
96
- return { ok: false, reason: failed };
97
- if (body === null ||
98
- typeof body.id !== "number" ||
99
- !Array.isArray(body.boards) ||
100
- !body.boards.every((b) => typeof b === "string")) {
101
- return { ok: false, reason: "bad_response" };
102
- }
103
- return { ok: true, plan: { planId: body.id, boards: body.boards } };
104
- }
105
- /** The plan's ready cards on one board. */
106
- async function readBoardReadyCards(options, deps, planId, board) {
107
- const filter = encodeURIComponent(JSON.stringify({ plan_id: planId, status_derived: "ToDo" }));
108
- const fields = encodeURIComponent(JSON.stringify(CARD_FIELDS));
109
- const sent = await sendSessionRequest(options, `/api/issues?board=${encodeURIComponent(board)}&filter=${filter}&fields=${fields}&limit=${CARDS_PAGE_SIZE}&sort=priority`, { method: "GET" }, deps);
110
- if ("ok" in sent)
111
- return sent;
112
- const failed = classifyStatus(sent.status);
113
- if (failed !== null)
114
- return { ok: false, reason: failed };
115
- const parsed = tryParseJsonBody(sent.text);
116
- const body = parsed.ok ? parsed.body : null;
117
- if (body === null || !Array.isArray(body.issues) || typeof body.total !== "number")
118
- return { ok: false, reason: "bad_response" };
119
- // A truncated read would silently miss ready cards: fail loudly instead.
120
- if (body.total > body.issues.length)
121
- return { ok: false, reason: "too_many_cards" };
122
- const cards = [];
123
- for (const raw of body.issues) {
124
- const row = parseCardRow(raw);
125
- if (row === null)
126
- return { ok: false, reason: "bad_response" };
127
- if (isReadyCard(row))
128
- cards.push({ id: row.id, title: row.title });
129
- }
130
- return { ok: true, cards };
131
- }
132
- /** One authenticated read of the session's plan's ready cards, across every board the plan spans. Never throws. */
133
- export async function fetchReadyCards(options, deps) {
134
- const read = await readSessionPlan(options, deps);
135
- if (!read.ok)
136
- return read;
137
- if (read.plan === null)
138
- return { ok: true, planId: null, cards: [] };
139
- const { planId } = read.plan;
140
- const perBoard = await Promise.all(read.plan.boards.map((board) => readBoardReadyCards(options, deps, planId, board)));
141
- const cards = [];
142
- for (const onBoard of perBoard) {
143
- if (!onBoard.ok)
144
- return onBoard;
145
- cards.push(...onBoard.cards);
146
- }
147
- return { ok: true, planId: read.plan.planId, cards };
148
- }
149
- /** The bin's `ready-cards` subcommand, wired to the real process. */
150
- export async function runReadyCardsCommand(argv, env = process.env,
151
- /** Test seams: the home the connection record is read from, and the HTTP client. */
152
- seams = {}) {
153
- if (argv.length !== 0) {
154
- process.stderr.write(`unexpected arguments\n${USAGE}\n`);
155
- return 1;
156
- }
157
- // `resolveSessionOptions` words a missing session id as the usage text alone; every stderr line here starts with its reason.
158
- if (!env.CLAUDE_CODE_SESSION_ID) {
159
- process.stderr.write(`no_session_id: CLAUDE_CODE_SESSION_ID is not set\n${USAGE}\n`);
160
- return 1;
161
- }
162
- const resolved = await resolveSessionOptions(env, { home: seams.home }, USAGE);
163
- if (!resolved.ok) {
164
- // `detail` names its reason except for a missing session id, where it is only the usage text.
165
- const named = resolved.detail.startsWith(`${resolved.output.reason}:`);
166
- process.stderr.write(named ? `${resolved.detail}\n` : `${resolved.output.reason}: CLAUDE_CODE_SESSION_ID is not set\n${resolved.detail}\n`);
167
- return 1;
168
- }
169
- const output = await fetchReadyCards(resolved.options, seams.deps ?? { fetch: (input, init) => fetch(input, init), requestTimeoutMs: READY_CARDS_REQUEST_TIMEOUT_MS });
170
- if (!output.ok) {
171
- process.stderr.write(`${output.reason}: could not read the plan's ready cards from the dashboard\n`);
172
- return 1;
173
- }
174
- process.stdout.write(`${JSON.stringify({ plan_id: output.planId, cards: output.cards })}\n`);
175
- return 0;
176
- }