@thehammer/danx-dashboard-mcp 0.1.286 → 0.1.289

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
 
@@ -63,7 +63,7 @@ Card and plan reads/writes go through `danxbot_api` against the issue/plan route
63
63
 
64
64
  A program calls this, not a person: the danxbot Claude Code plugin's relay loop, inside the session, calls it over and over on the session's own danx-dashboard MCP server and hands every event it gets to the session. A plan-orchestrator dispatch (a headless session that works one plan) has no plugin relay, so it keeps ONE background waiter sub-agent that loops on this tool until events exist and returns them with the last cursor, and the orchestrator passes that cursor to the next waiter it arms (DX-4667); the `plan-orchestrator` profile alone is granted the tool on a dispatch (it is an opt-in tool). The `bridge` subcommand is gone: no process is spawned, no `npx` cold start, and nothing outlives the session, because the stream lives in the MCP server, which ends with its session.
65
65
 
66
- Input `{plan_id: number, cursor: string | null, timeout_ms: 1000..30000, transcript_path?: string}`. Answer, one of:
66
+ Input `{plan_id: number | null, cursor: string | null, timeout_ms: 1000..30000, transcript_path?: string}`. `plan_id` is `null` for a signed-in session on no plan (DX-4882): its stream still carries the pacing and resume messages of its Claude account, and it receives no plan events. A move between a plan and `null` starts clean like a move between plans: the plan-scoped records of the plan left are dropped, the session-scoped ones kept. Answer, one of:
67
67
 
68
68
  - `{events: [{cursor: string, text: string, urgent: boolean}]}`: every record the caller has not delivered, in arrival order (empty at the timeout). Each record carries the cursor to store once THAT record is delivered. There is no `id`: the caller compares nothing. `urgent` (DX-4721) is decided in one place by the record's kind (`src/urgent-records.ts`): true for a critical usage-pacing stop and the revoked-key notice, false for everything else (activity events, nudge, resume message, digest, the relay's failure notice, an unreadable line). The caller adds an urgent record to a running turn at once and delivers any other after the turn ends; `urgent` never changes order, seq or acknowledgement. The dashboard writes no operator-stop record yet; when it does, it becomes one more kind in that function.
69
69
  - `{stopped: {reason, detail, fix}}`: a terminal outcome (`not_connected`, `plan_archived`, `unauthorized`, `key_revoked`, `mint_refused`, `mint_bad_response`, `session_is_worker`, `superseded`, `replaced`, `revoked`, `refused`, `credential_unavailable`, `relay_failed`). `fix` is the remedy text (`STOP_FIXES`, `src/stop-fixes.ts`). The next call starts from scratch.
@@ -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) => {
@@ -771,7 +768,7 @@ async (args) => {
771
768
  await syncToolsAfterSignIn();
772
769
  // DX-3997 — a (re)connect starts or re-keys the plugin's relay loop; the next `danxbot_api` must re-read health.
773
770
  listenerReminder.invalidate();
774
- // DX-2985 — a leave drops the local connection record (the relay's stream ends `not_connected`; a later connect writes a fresh one).
771
+ // DX-2985 — a leave drops the local connection record (its stream stays open, DX-4882: the relay keeps serving the session's pacing and resume messages; a later connect writes a fresh one).
775
772
  if (args.disconnect === true) {
776
773
  removeSessionConnectionAfterLeave(result, config.session?.id);
777
774
  return jsonResult(result);
@@ -792,20 +789,22 @@ async (args) => {
792
789
  });
793
790
  // ---------------- plan_events_wait (DX-4233) ----------------
794
791
  strictTool("plan_events_wait",
792
+ // DX-4882 - `plan_id` is null for a signed-in session on no plan: its stream still carries its account's pacing and resume messages.
795
793
  // DX-4233 - for the danxbot plugin's relay loop. DX-4667 - and for a background waiter sub-agent of a plan orchestrator, which
796
794
  // loops on it until events exist and returns them (and the cursor) to the orchestrator it wakes. It answers `events` (each
797
795
  // `{cursor, text, urgent}`; empty after `timeout_ms`), the cursor to send being the last delivered event's, or `stopped` with the remedy
798
796
  // to tell the session. General-audience text (DX-4550): nothing here names our own setup.
799
- "Waits for events on the plan this session is connected to. For a relay or a background waiter that calls it in a loop " +
800
- "and hands every event to the session; do not call it for any other reason. `plan_id` is the plan the session is on (a " +
801
- "different plan than the last call's starts clean). `cursor` is the `cursor` of the last event delivered, as answered (null " +
797
+ "Waits for events for this session: the events of the plan it is connected to, and the messages about its usage. For a relay " +
798
+ "or a background waiter that calls it in a loop and hands every event to the session; do not call it for any other reason. " +
799
+ "`plan_id` is the plan the session is on, or null when it is on no plan (then only the usage messages arrive; a different " +
800
+ "plan than the last call's, or a move to or from null, starts clean). `cursor` is the `cursor` of the last event delivered, as answered (null " +
802
801
  "when none). Answers `{events: [{cursor, text, urgent}]}`, with `events` empty once `timeout_ms` passes, or `{stopped: " +
803
802
  "{reason, detail, fix}}` when the stream ended: tell the session the `fix`. An event stays until a later call's " +
804
803
  "`cursor` passes it. `urgent` is true for an event the session must hear at once (a critical usage limit, a revoked " +
805
804
  "key) and false for every other: deliver an urgent one to the session even while it is working, and any other once its " +
806
805
  "current turn ends. Only one wait is pending per session: a newer call ends the older. " +
807
806
  "`transcript_path` is the session's transcript file, for its usage and title.", {
808
- plan_id: z.number().int().positive().describe("Id of the plan this session is connected to."),
807
+ plan_id: z.number().int().positive().nullable().describe("Id of the plan this session is connected to, or null when it is on no plan."),
809
808
  cursor: z
810
809
  .string()
811
810
  .nullable()
@@ -861,40 +860,14 @@ async function main() {
861
860
  // symlink-aware (DX-1647) so it holds under the symlinked `npx` bin the worker
862
861
  // spawns, not just a direct `node dist/index.js`.
863
862
  //
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).
863
+ // The only subcommand is `subagents-live`, which the danxbot plugin's plan pane runs (DX-4508); it does not boot the MCP
864
+ // server and uses no network or credential. A session's plan events are served in this process by `plan_events_wait` (DX-4233).
869
865
  //
870
866
  // Any OTHER argument is refused rather than ignored, so a typo cannot
871
867
  // silently start an MCP server on stdio where a subcommand was meant to run.
872
868
  if (isEntrypointModule(import.meta.url, process.argv[1])) {
873
869
  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) {
870
+ if (subcommand === SUBAGENTS_LIVE_SUBCOMMAND) {
898
871
  // DX-4508 — the plugin pane's local live sub-agent feed: stdout is its JSON Lines channel, so a fatal goes to
899
872
  // stderr only and exits 1; a refused path exits 2 from the command itself.
900
873
  runSubagentsLiveCommand(rest).then((code) => process.exit(code), (err) => {
@@ -903,9 +876,8 @@ if (isEntrypointModule(import.meta.url, process.argv[1])) {
903
876
  });
904
877
  }
905
878
  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}")`);
879
+ console.error(`[danx-dashboard-mcp] unknown subcommand "${subcommand}" (the only one is ` +
880
+ `"${SUBAGENTS_LIVE_SUBCOMMAND}")`);
909
881
  process.exit(2);
910
882
  }
911
883
  else {
package/dist/json-body.js CHANGED
@@ -1,20 +1,9 @@
1
1
  /**
2
2
  * DX-2920 — the ONE place that reads a JSON body's top-level `.error` string.
3
3
  *
4
- * WHY THIS EXISTS. the stream client (`session-stream.ts`, classifying a mint refusal) and `listen.ts`
5
- * (classifying a stream admission refusal) both need to answer "what error
6
- * code did this JSON body carry", and before this card each answered it a
7
- * different way: the mint code parsed the body directly, `listen.ts` never
8
- * looked past a flattened, human-readable detail string at all — a caller
9
- * that wanted to react to a SPECIFIC refusal (the admission-time 409 the
10
- * mint route also uses) had to regex-scrape that flattened string back apart,
11
- * a second, fragile source of truth for the same fact. This module is generic
12
- * — it knows nothing about "session_not_connected" or "stream_ticket_invalid",
13
- * only that a JSON object may carry a top-level string field named `error` —
14
- * so it can live in BOTH the domain-aware `session-stream.ts` and the deliberately
15
- * generic `listen.ts` (whose own docblock states it carries no domain
16
- * knowledge of the dashboard's specific error shapes) without either one
17
- * importing the other.
4
+ * WHY THIS EXISTS. the stream client (`session-stream.ts`, classifying a mint refusal such as `409 session_is_worker`) reads a refusal's
5
+ * error code from the parsed body, and this is the one place that decides what "carries an error code" means. Generic: it knows no
6
+ * dashboard error name, only that a JSON object may carry a top-level string field named `error`.
18
7
  */
19
8
  export function errorCodeOf(body) {
20
9
  return typeof body === "object" && body !== null && typeof body.error === "string"
package/dist/listen.js CHANGED
@@ -16,16 +16,10 @@
16
16
  * DX-4342's `resume` frame (`formatResumeLine`): all are advisory, written fresh by the dashboard's own keep-alive tick,
17
17
  * and never replayed, so they ride this same shape with `id: null` rather
18
18
  * than growing a THIRD output variant.
19
- * - `{type:"stopped", reason, detail, refusal?}` — once, last. `reason`
19
+ * - `{type:"stopped", reason, detail}` — once, last. `reason`
20
20
  * is the dashboard's own end reason (`superseded`, `replaced`, `revoked`,
21
21
  * `not_connected`), `refused` (the ticket was not admitted), or
22
- * `lease_expired` (no healthy connection for the lease). DX-2920 —
23
- * `refusal` is populated ONLY for `reason === "refused"`: `{status,
24
- * errorCode, body}`, the admission response parsed ONCE here, so a
25
- * consumer can recognize a SPECIFIC refusal (the dashboard's own
26
- * `409 session_not_connected`, the same shape the mint route uses) by
27
- * status + error code rather than re-deriving it from the flattened
28
- * `detail` string. A wire `reason` value this build does not recognize (a
22
+ * `lease_expired` (no healthy connection for the lease). A wire `reason` value this build does not recognize (a
29
23
  * newer dashboard's reason an older client hasn't shipped yet)
30
24
  * normalizes to `"unknown"` rather than passing the raw string through,
31
25
  * so the closed set stays closed — `fixForStop`'s generic
@@ -48,7 +42,6 @@
48
42
  * (`src/issues/db/issue-activity.ts`); this published package cannot import
49
43
  * danxbot source.
50
44
  */
51
- import { errorCodeOf } from "./json-body.js";
52
45
  import { LINE_PREFIX, oneLine } from "./one-line.js";
53
46
  import { pacingRelayLine } from "./pacing-relay.js";
54
47
  import { isUrgent } from "./urgent-records.js";
@@ -490,8 +483,8 @@ export async function runListener(options, deps) {
490
483
  };
491
484
  for (const id of capResumeIds(options.resumeIds))
492
485
  remember(id);
493
- function stop(reason, detail, code, extra) {
494
- deps.write({ type: "stopped", reason, detail, refusal: extra?.refusal });
486
+ function stop(reason, detail, code) {
487
+ deps.write({ type: "stopped", reason, detail });
495
488
  return code;
496
489
  }
497
490
  /**
@@ -614,24 +607,7 @@ export async function runListener(options, deps) {
614
607
  const response = await deps.fetch(options.streamUrl, { headers, signal: controller.signal });
615
608
  if (response.status >= 400 && response.status < 500 && !RETRYABLE_CLIENT_STATUSES.has(response.status)) {
616
609
  const text = await response.text();
617
- // DX-2920 — parsed ONCE, here, into a structured outcome a consumer can
618
- // classify by status + error code (the relay does, matching a specific
619
- // admission refusal such as the dashboard's own `409 session_not_connected`)
620
- // rather than re-deriving it from the flattened `detail` string below.
621
- let body = null;
622
- try {
623
- body = text === "" ? null : JSON.parse(text);
624
- }
625
- catch {
626
- body = null;
627
- }
628
- return {
629
- kind: "refused",
630
- status: response.status,
631
- errorCode: errorCodeOf(body),
632
- body,
633
- detail: `HTTP ${response.status} ${oneLine(text, 300)}`,
634
- };
610
+ return { kind: "refused", detail: `HTTP ${response.status} ${oneLine(text, 300)}` };
635
611
  }
636
612
  if (!response.ok || response.body === null) {
637
613
  await response.body?.cancel();
@@ -682,9 +658,7 @@ export async function runListener(options, deps) {
682
658
  return stop(outcome.reason, "the dashboard ended this listener", 1);
683
659
  }
684
660
  if (outcome.kind === "refused") {
685
- return stop("refused", `the dashboard refused the listener ticket: ${outcome.detail}`, 1, {
686
- refusal: { status: outcome.status, errorCode: outcome.errorCode, body: outcome.body },
687
- });
661
+ return stop("refused", `the dashboard refused the listener ticket: ${outcome.detail}`, 1);
688
662
  }
689
663
  if (outcome.detail !== undefined)
690
664
  lastDropDetail = outcome.detail;
@@ -30,8 +30,9 @@
30
30
  * THE ACCEPTED GAP: an insert that commits more than that 30 s overlap after the event it follows is missed by a RESTART
31
31
  * resume (the dashboard's own limit); a running relay never misses one. And a burst of more than `CURSOR_IDS_MAX` events
32
32
  * inside the overlap can replay the oldest of them once (a duplicate, never a loss).
33
- * 3. ONE PLAN. A call names the plan the session is on (`planId`). A plan different from the last call's drops the
34
- * PLAN-scoped records buffered or held for the plan it left, so a move starts clean. SESSION-scoped records (pacing, resume
33
+ * 3. ONE PLAN. A call names the plan the session is on (`planId`), or `null` for a signed-in session on no plan (DX-4882: its stream
34
+ * still carries the pacing and resume messages of its account). A plan different from the last call's, to or from `null` included,
35
+ * drops the PLAN-scoped records buffered or held for the plan it left, so a move starts clean. SESSION-scoped records (pacing, resume
35
36
  * and the relay's own failure and revoked-key notices: they say nothing about a plan, and the dashboard never replays
36
37
  * them) are kept, here and across the 40 s suspend below. The ids of a dropped record leave the cursors of the records that
37
38
  * stay: delivering a kept record must not claim an event that was dropped undelivered. The stream stays on a move: it
@@ -55,7 +56,7 @@
55
56
  import { DigestHold } from "./digest-hold.js";
56
57
  import { HeartbeatReporter } from "./heartbeat-reporter.js";
57
58
  import { capResumeIds, DELIVERED_ID_MEMORY, HEALTHY_CONNECTION_MS, INITIAL_BACKOFF_MS, MAX_BACKOFF_MS, runListener, } from "./listen.js";
58
- import { classifyAdmissionRefusal, fetchPlanInventory, HEARTBEAT_INTERVAL_MS, heartbeatConnection, MAX_REFUSED_TICKETS_IN_A_ROW, mintTicket, } from "./session-stream.js";
59
+ import { fetchPlanInventory, HEARTBEAT_INTERVAL_MS, heartbeatConnection, MAX_REFUSED_TICKETS_IN_A_ROW, mintTicket, } from "./session-stream.js";
59
60
  import { fixForStop } from "./stop-fixes.js";
60
61
  import { isUrgent } from "./urgent-records.js";
61
62
  /** The shortest and longest wait one call may ask for; a short ceiling keeps an orphaned waiter's life short. */
@@ -91,7 +92,8 @@ export class PlanEventRelay {
91
92
  buffer = [];
92
93
  waiter = null;
93
94
  run = null;
94
- lastPlanId = null;
95
+ /** The plan the last call named (`null` = a session on no plan); `undefined` before the first call, which has no plan to have moved from. */
96
+ lastPlanId = undefined;
95
97
  /** The last seq given: it continues from a presented cursor and survives a suspend, so a cursor stays valid across the gap. */
96
98
  seq = 0;
97
99
  /** The dashboard event ids that arrived (or were presented in a cursor), oldest first, bounded: the next record's `recent`. */
@@ -103,6 +105,8 @@ export class PlanEventRelay {
103
105
  connectionState = "reconnecting";
104
106
  stoppedReason = null;
105
107
  lastInventoryError = null;
108
+ /** DX-4882: whether the connected plan's inventory has been read in this run (a session on no plan has none to read). */
109
+ inventoryRead = false;
106
110
  lastActivityAt = 0;
107
111
  heartbeat = null;
108
112
  reporter = null;
@@ -120,8 +124,10 @@ export class PlanEventRelay {
120
124
  // DX-4233: one wait at a time; the older call answers empty so its caller is never left hanging.
121
125
  this.supersedeWaiter();
122
126
  // DX-4233: a plan move starts clean (the very first call has no last plan; after a suspend the last plan is still known, and `dropPlanScoped` already ran).
123
- if (this.lastPlanId !== null && args.planId !== this.lastPlanId)
127
+ if (this.lastPlanId !== undefined && args.planId !== this.lastPlanId) {
124
128
  this.startClean();
129
+ this.planMovedInventory(args.planId);
130
+ }
125
131
  this.lastPlanId = args.planId;
126
132
  if (cursor !== null) {
127
133
  // DX-4233: a fresh relay (a restarted server) continues the caller's sequence and remembers the ids it was handed; a
@@ -224,6 +230,31 @@ export class PlanEventRelay {
224
230
  // dropped events belong to a plan the session left, and the dashboard replays only the session's current plan, so nothing
225
231
  // legitimate is replayed into the guard; and the gap, which does need them replayed, ends the run, so `start` builds a fresh `seenIds` from the next call's cursor.
226
232
  }
233
+ /** Whether the last call named a plan (not `null` = no plan, not `undefined` = no call yet). */
234
+ onAPlan() {
235
+ return typeof this.lastPlanId === "number";
236
+ }
237
+ /**
238
+ * DX-4882: the plan changed, so the inventory read for the old one no longer describes the session. On no plan there is nothing to read (and
239
+ * nothing to report as unavailable); on a plan, a live run reads it now (a run that is not live reads it when it starts).
240
+ */
241
+ planMovedInventory(planId) {
242
+ this.inventoryRead = false;
243
+ this.lastInventoryError = null;
244
+ const run = this.run;
245
+ const auth = this.deps.auth();
246
+ if (planId === null || run === null || auth === null)
247
+ return;
248
+ void this.readInventory(run, auth, { ...this.deps, signal: run.abort.signal });
249
+ }
250
+ /** The inventory is a property of the credential and the plan: read best effort, never a gate; a failed read is reported on the heartbeat (`inventory: unavailable`) and tried again at the next re-mint. */
251
+ async readInventory(run, auth, http) {
252
+ const inventory = await fetchPlanInventory(auth, http);
253
+ if (!this.current(run))
254
+ return;
255
+ this.inventoryRead = inventory.kind === "ok";
256
+ this.lastInventoryError = inventory.kind === "ok" ? null : inventory.detail;
257
+ }
227
258
  /** The session moved to another plan: drop what was buffered or held for the plan it left; session-scoped records stay. */
228
259
  startClean() {
229
260
  this.dropPlanScoped();
@@ -297,6 +328,7 @@ export class PlanEventRelay {
297
328
  // replays its 30 s overlap before the highest of them, so a replayed one is dropped and a never-delivered one is delivered.
298
329
  this.seenIds = capResumeIds([...(cursor?.ids ?? []), ...this.buffer.flatMap((r) => r.own)]);
299
330
  this.stoppedReason = null;
331
+ this.inventoryRead = false;
300
332
  const run = { abort: new AbortController(), forceReconnect: null };
301
333
  this.run = run;
302
334
  void this.loop(run).catch((err) => {
@@ -336,7 +368,6 @@ export class PlanEventRelay {
336
368
  const http = { ...this.deps, signal: run.abort.signal };
337
369
  let backoff = INITIAL_BACKOFF_MS;
338
370
  let refusedInRow = 0;
339
- let inventoryRead = false;
340
371
  for (;;) {
341
372
  if (!this.current(run))
342
373
  return;
@@ -356,14 +387,11 @@ export class PlanEventRelay {
356
387
  backoff = Math.min(MAX_BACKOFF_MS, backoff * 2);
357
388
  continue;
358
389
  }
359
- // The inventory is a property of the credential and the plan: read best effort, never a gate. A failed read is reported
360
- // on the heartbeat (`inventory: unavailable`), never silently healthy, and is tried again at the next re-mint.
361
- if (!inventoryRead) {
362
- const inventory = await fetchPlanInventory(auth, http);
390
+ // DX-4882: only a session on a plan has an inventory to read.
391
+ if (!this.inventoryRead && this.onAPlan()) {
392
+ await this.readInventory(run, auth, http);
363
393
  if (!this.current(run))
364
394
  return;
365
- inventoryRead = inventory.kind === "ok";
366
- this.lastInventoryError = inventory.kind === "ok" ? null : inventory.detail;
367
395
  }
368
396
  backoff = INITIAL_BACKOFF_MS;
369
397
  const attempt = { stopped: null, emitted: false };
@@ -412,9 +440,6 @@ export class PlanEventRelay {
412
440
  continue;
413
441
  }
414
442
  if (stopped.reason === "refused") {
415
- const classified = classifyAdmissionRefusal(stopped.refusal);
416
- if (classified !== null)
417
- return this.terminal(run, classified.reason, classified.detail);
418
443
  const provedHealthy = attempt.emitted || this.deps.now() - startedAt >= HEALTHY_CONNECTION_MS;
419
444
  refusedInRow = provedHealthy ? 1 : refusedInRow + 1;
420
445
  if (refusedInRow >= MAX_REFUSED_TICKETS_IN_A_ROW) {
@@ -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
  }