@thehammer/danx-dashboard-mcp 0.1.273 → 0.1.274

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 same resolver serves the `bridge` subcommand, so a session's tools and its event stream can never sign in as two different identities |
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 |
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,8 +20,8 @@ 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; the bridge 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, and the bridge's heartbeat, renew it), and is revocable from the account page.
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). The session's event bridge ends with the stop reason `key_revoked` the same way. **A 404 on create** means the dashboard predates request-and-approve and says so.
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.
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
 
27
27
  ### Asking for one more permission (DX-4434)
@@ -54,24 +54,31 @@ All exposed as `mcp__danx-dashboard__<name>` once wired through the workspace `m
54
54
 
55
55
  There is no dedicated `resource_fields` tool (removed DX-3465 — it was a thin wrapper `danxbot_api` already reaches with no agent-specific behavior earning it its own tool). What an opt-in field TREE may name for one resource (`{resource, description, always, hashes, fields, relations}`) is `danxbot_api({method:"GET", path:"/api/resources/<resource>/fields"})` — install-global, no `board`; `resource: "issue"` is the root, and a relation's own `resource` in the response is what to call this again with, one level deeper.
56
56
 
57
- A dedicated tool survives only for something no `danxbot_api` call can do, never a card/plan/dispatch-callback operation: `request_permission` (asks for a permission its key lacks), `plan_connect` (binds the session to a plan; the event-bridge hook keys on it), and the two that read LOCAL files, `attach_file` and `danxbot_slack_reply` (`files[]`, see `src/lifecycle.ts`). The live list is always `tool-defs.json` in this package, regenerated by `npm run gen-tool-defs` — read it rather than trusting a list copied into prose.
57
+ A dedicated tool survives only for something no `danxbot_api` call can do, never a card/plan/dispatch-callback operation: `request_permission` (asks for a permission its key lacks), `plan_connect` (binds the session to a plan; the plugin's relay loop starts on it), and the two that read LOCAL files, `attach_file` and `danxbot_slack_reply` (`files[]`, see `src/lifecycle.ts`). The live list is always `tool-defs.json` in this package, regenerated by `npm run gen-tool-defs` — read it rather than trusting a list copied into prose.
58
58
 
59
59
  Card and plan reads/writes go through `danxbot_api` against the issue/plan route families documented in `GET /api/openapi` (via `danxbot_api_spec`) — `POST /api/issues`, `GET /api/issues/:id` (or `/batch` for many at once, DX-2727), `PATCH /api/issues/:id/edit`, `POST /api/issues/:id/transition`, the problems/solutions/dependencies/comments/quality-gates/retro sub-resource routes, and the equivalent `/api/plans/*` family (`GET /api/plans/:planId` — cheap scalars by default, `fields` opts into `cards`/`records`/`architecture`/`session_history`/`worker_dispatches`; `GET /api/plans` for the list, with an optional `status` filter over the same computed `complete`/`awaiting-session`/`building`/`planning` value `GET /api/plans/:planId` always returns ungated).
60
60
 
61
- ## `bridge` — a working session's event stream client
61
+ ## `plan_events_wait` — a working session's plan events (DX-4233)
62
62
 
63
- A program runs this, not an agent: the danxbot Claude Code plugin's plan event bridge spawns `bridge` for a session, relays each event's `text` into the session, and stores the delivered ids. `plan_connect` only connects the session; it mints no ticket.
63
+ 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. 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.
64
64
 
65
- ```bash
66
- CLAUDE_CODE_SESSION_ID=<session> npx -y @thehammer/danx-dashboard-mcp@<version> bridge [--resume-ids <id>,<id>,...]
67
- ```
65
+ Input `{plan_id: number, cursor: string | null, timeout_ms: 1000..30000, transcript_path?: string}`. Answer, one of:
66
+
67
+ - `{events: [{cursor: string, text: string}]}`: 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.
68
+ - `{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.
69
+
70
+ The contract the plugin builds against:
71
+
72
+ - **One rule, by arrival.** The server gives every record (a dashboard event, a digest, a notice, a pacing or resume message) the next number of a sequence that only ever increases (`seq`). A call drops every record whose seq is at or below the cursor's and re-answers everything above it until a later call's cursor passes it. So a call cancelled by a plugin reload loses nothing, and a dashboard event that arrives after a higher-numbered one (the dashboard numbers at insert and publishes at commit) is simply the next record: never dropped, delivered once.
73
+ - **The cursor** is the string `<seq>:<id>,<id>,...`: the record's seq, a colon, then the dashboard event ids delivered up to and including it, in arrival order, at most the last 64 (`CURSOR_IDS_MAX`); `0:` is the start. Every number is base-10 with no leading zero, the ids are distinct and positive, and anything else is refused loudly (the call errors). The plugin stores it verbatim and sends it back verbatim. The ids are what makes a restart exact: after a restart of the MCP server (a resumed session) the fresh relay continues the sequence from the cursor's seq, resumes the stream after its highest id, and seeds the listener's duplicate guard with all of them, so the events the dashboard replays from its 30 s commit-order overlap are dropped when already delivered and delivered when never delivered. The same holds across the 40 s gap below. A crash between delivering a record and storing its cursor can repeat that one record; nothing is lost.
74
+ - **The accepted restart gap.** Only an insert that commits more than the dashboard's 30 s overlap after the event it follows can be missed, and only by a restart resume (the dashboard's own limit); a running relay never misses one. A burst of more than 64 events inside the overlap can replay the oldest once (a duplicate, never a loss).
75
+ - **One plan.** `plan_id` is the plan the session is on. A plan different from the last call's drops the plan-scoped records buffered or held for the plan it left (dashboard events, the digest, the idle nudge), so a plan move starts clean. Session-scoped records (the pacing and auto-resume messages, which are never replayed, and the relay's own failure and revoked-key notices) are kept across a plan move and across the gap below. One wait is pending at a time: a newer call supersedes the pending one (which answers empty at once); an aborted request drops its waiter.
76
+ - **One heartbeat window.** `POST /api/plan-sessions/me/heartbeat` every 15 s while a call is pending or was made within the last 40 s; after that the stream is closed and heartbeats stop, so a dead or unloaded plugin module reads not-healthy (the dashboard's liveness window is 60 s). The plugin therefore waits 20 s per call and backs off at most 10 s between failed calls. Stats and the transcript title ride the heartbeat when `transcript_path` is given. The sequence survives the gap, so a cursor stays valid across it.
77
+ - **Failures inside the server.** A transient mint failure (network, timeout, 408, 429, 5xx) and a dropped stream back off 1 s doubling to 30 s (the one schedule, `listen.ts`); a lapsed ticket lease (15 min) re-mints and re-reads a failed plan inventory; three heartbeats that fail to land force a reconnect and tell the session once; a revoked key tells the session once.
78
+ - **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.
79
+ - **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.
68
80
 
69
- - **Credential — the session's own, never the ambient one (DX-2862).** The session id is the only thing `bridge` takes from its environment; `--resume-ids` is the only argument and holds no secret. Everything else comes from the connection record this session's own danx-dashboard MCP server wrote on `plan_connect` (`src/session-connection.ts`): the dashboard URL, the credential's SOURCE (a session key file, or the env var name), and its FINGERPRINT. `bridge` resolves that source through the same resolver the server used and refuses to run unless the result fingerprints identically — `credential_mismatch`. Before that fix it used whatever `DANX_DASHBOARD_CREDENTIAL` the session's environment held, and on a machine where that differed from the server's credential the dashboard admitted the stream and dropped every event, with nothing logged anywhere.
70
- - **No client-side reach check (DX-2920).** `bridge` used to prove board reach itself, before ever opening the stream (`GET /api/plans/mine?fields=cards` + a per-board `GET /api/issues/:id/problems`). That check is DELETED. The dashboard briefly enforced the identical requirement server-side (DX-2863: refusing a mint or stream admission with `403 issuer_cannot_read_plan_boards`, and ending a live stream `{"reason":"scope_narrowed","boards":[…]}`), but DX-3468 removed that mechanism too — DX-3409 made every credential team-bound, so a plan session's issuer and every board its connected plan covers are always in the same team, and the check could never fire any more. `bridge` no longer independently verifies board reach at all, client- or server-side. Once a ticket is minted, `bridge` emits, once, `{"type":"ready"}` — no `boards` field, since reach is no longer proven client-side.
71
- - **Ticket.** `bridge` mints the session's listener ticket (`POST /api/plan-sessions/me/stream-ticket`, 10 s timeout) and keeps it in the process. A ticket authorizes reading that one session's event stream and nothing else, and is only issued while the session is connected to a plan. Minting a new one ends the previous listener.
72
- - **Output — JSON Lines.** One `{"type":"event","id":<id|null>,"text":"…"}` per event on the connected plan's cards, where `text` is `[DX-8 "Title" repo:board] newms87 answered "Which rollout order?": chose "Pause E2E" — note: "only this week"`, `… answered "…": "<free-form answer>"`, `… commented: "…"`, `… opened a problem: "…"`, `… blocked the card: "…"`, or `… unblocked the card`. An event it cannot read still produces one, with a `could not read event` text. Nothing for keep-alives, reconnects or re-mints. The session's own writes are never echoed back to it.
73
- - **Stopping.** Last, one `{"type":"stopped","reason":"…","detail":"…","fix":"…"}` and exit, only on a terminal outcome. `fix` is the remedy shown to the session (`STOP_FIXES` in `src/bridge.ts` — never a log-only hint), so a session that hits a stop it cannot otherwise see still knows what to do about it. Terminal reasons: `no_connection_record` / `credential_unavailable` / `credential_mismatch` (the start-time checks above), `not_connected` (a `409 session_not_connected` at mint OR at stream admission, or a live `end("not_connected")` — all three mapped identically), `plan_archived` (DX-3809 — the plan was archived or deleted while the stream was live; the session is disconnected, and reconnects by hand after a restore), `unauthorized` (401/403), `mint_refused` (any other non-transient mint refusal), `mint_bad_response`, `superseded` / `replaced` (exit 0 — a newer or replacing connection already serves this session, so `fix` says no action is needed), `revoked`, or `refused` (two freshly minted tickets refused in a row with no more specific classification). A transient mint failure (network, timeout, 408, 429, 5xx) backs off and retries; a lapsed ticket lease re-mints.
74
- - **Reconnect and resume.** A read-idle timeout (three missed keep-alives) turns a silently dead connection into a drop. Capped exponential backoff (1s → 30s) that resets only after a healthy connection, with `Last-Event-ID`, so a dashboard restart replays what was missed and nothing is emitted twice. `--resume-ids` carries the same guarantee across a process restart: the ids already delivered seed the duplicate guard, and the highest is the first `Last-Event-ID`. The dashboard floors that replay at the later of the session's first ticket and when it joined its current plan, so a restart loses nothing and a plan move replays nothing from before the move.
81
+ The one-shot subcommands below (`activity`, `background-work`, `event-text`, `ready-cards`, `restart-notice`) 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).
75
82
 
76
83
  ## `restart-notice` — what a restarted session was working on (DX-3928)
77
84
 
@@ -83,7 +90,7 @@ Run by the danxbot plugin's Stop hook for a plan-connected session. `GET /api/pl
83
90
 
84
91
  ## `subagents-live` — the plan pane's local live sub-agent feed (DX-4508)
85
92
 
86
- `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 bridge 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.
93
+ `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.
87
94
 
88
95
  ## Build + test
89
96
 
package/dist/activity.js CHANGED
@@ -56,7 +56,7 @@
56
56
  * `session_not_connected` are the ordinary "this session is not on a plan" answers;
57
57
  * - human-readable diagnostics go to stderr only.
58
58
  *
59
- * THE CREDENTIAL — the SAME resolver `bridge` uses (`resolveSessionOptions`): the URL,
59
+ * THE CREDENTIAL — the SAME resolver `plan_events_wait` uses (`resolveSessionOptions`): the URL,
60
60
  * credential source and credential come from the connection record this session's own MCP
61
61
  * server wrote on `plan_connect`, never from this process's ambient env (DX-2862).
62
62
  */
@@ -5,13 +5,13 @@
5
5
  * WHO RUNS IT. The danxbot Claude Code plugin's `Stop` / `SubagentStop` /
6
6
  * `SessionStart` / `StopFailure` hooks (`danxbot/hooks/hooks.json` in
7
7
  * claude-plugins), via the SAME `npx -y @thehammer/danx-dashboard-mcp`
8
- * MECHANISM the plugin already uses for `bridge` — no separate install, no
8
+ * MECHANISM the plugin already uses for `plan_events_wait` — no separate install, no
9
9
  * deep import of this package's `dist/` internals.
10
10
  * DX-3367 fix-up — that does NOT mean the SAME PINNED VERSION: `npx -y
11
11
  * <pkg>` resolves whatever version string that ONE hook's own invocation
12
12
  * names, and this plugin's hooks pin each call independently (verified
13
13
  * against production drift, comment 7102 on the card: the background-work
14
- * hooks pinned `0.1.136` while `bridge` was still pinned `0.1.95`) — a
14
+ * hooks pinned `0.1.136` while `plan_events_wait` was still pinned `0.1.95`) — a
15
15
  * version bump to one hook's pin never moves the others. The plugin decides
16
16
  * WHAT to report (parsing `Stop`/`SubagentStop`'s own `background_tasks`
17
17
  * snapshot, counting only `shell` / `subagent` / `workflow` entries,
@@ -39,8 +39,8 @@
39
39
  * reports by this time;
40
40
  * - human-readable diagnostics go to stderr only, never stdout.
41
41
  *
42
- * THE CREDENTIAL — the SAME resolver `bridge` uses, via `resolveBridgeOptions`
43
- * (`bridge.ts`): the dashboard URL, credential source, and credential come
42
+ * THE CREDENTIAL — the SAME resolver `plan_events_wait` uses, via `resolveDashboardAuth`
43
+ * (the relay): the dashboard URL, credential source, and credential come
44
44
  * from the connection record this session's OWN danx-dashboard MCP server
45
45
  * wrote on its last successful `plan_connect` (`session-connection.ts`),
46
46
  * never from this process's own ambient env (DX-2862).
@@ -1,13 +1,12 @@
1
1
  /**
2
2
  * DX-2862 / DX-4390 - the ONE resolver for this package's dashboard credential.
3
3
  *
4
- * WHY IT IS ONE FUNCTION AND NOT TWO. Two processes speak to the dashboard for the same Claude
5
- * Code session: the MCP SERVER (the session's card + plan tools) and `danx-dashboard-mcp bridge`
6
- * (the session's event stream, run by the danxbot plugin's plan event bridge). When they resolved
7
- * their credential independently they could differ (DX-2862): the session's tools worked, the
8
- * bridge's stream was admitted, and every operator comment was dropped with no error anywhere.
9
- * So resolution lives HERE, both callers go through it, and the bridge additionally PROVES it
10
- * holds the server's credential by comparing fingerprints (`session-connection.ts`).
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
9
+ * (`session-connection.ts`).
11
10
  *
12
11
  * A credential comes from exactly one of two places:
13
12
  *
@@ -28,7 +27,7 @@ export const CREDENTIAL_TOKEN_ENV = "DANX_DASHBOARD_CREDENTIAL";
28
27
  /**
29
28
  * A credential that cannot be resolved. Carries the remedy beside the reason, because both of this
30
29
  * module's callers report failures to a human who is not reading a log: the server prints it to
31
- * stderr, and the bridge posts it into the session's inbox.
30
+ * stderr, and a one-shot subcommand prints it for the hook that ran it.
32
31
  */
33
32
  export class CredentialError extends Error {
34
33
  fix;
@@ -0,0 +1,65 @@
1
+ export const DIGEST_INTERVAL_MS = 10 * 60_000;
2
+ export const DIGEST_PREFIX = "[plan events]";
3
+ /** ONE combined record for every event held since the last flush. */
4
+ function formatDigest(entries) {
5
+ const header = `${DIGEST_PREFIX} digest — ${entries.length} agent/machine event${entries.length === 1 ? "" : "s"} since the last update:`;
6
+ return [header, ...entries.map((e) => e.text)].join("\n");
7
+ }
8
+ export class DigestHold {
9
+ emit;
10
+ held = [];
11
+ timer = null;
12
+ /** `emit` receives every record the session should now be told, in order. */
13
+ constructor(emit) {
14
+ this.emit = emit;
15
+ }
16
+ /**
17
+ * Decide immediate vs. held. `operator` and `null` flush any pending digest first and then emit at once. `agent` /
18
+ * `machine` are appended to the hold, arming the timer only for the FIRST held event since the last flush: a later
19
+ * addition must never push the deadline out, or "at most every 10 minutes" would erode into "10 minutes after the last
20
+ * event", which can never fire while events keep arriving. Deduped by id (defense in depth, see the module docblock).
21
+ */
22
+ route(event) {
23
+ if (event.origin === "agent" || event.origin === "machine") {
24
+ if (event.id !== null && this.heldIds().includes(event.id))
25
+ return;
26
+ this.held.push(event);
27
+ // unref'd: a held digest never keeps the MCP server alive after its session ended
28
+ this.timer ??= setTimeout(() => this.flush(), DIGEST_INTERVAL_MS).unref();
29
+ return;
30
+ }
31
+ this.flush();
32
+ this.emit(event);
33
+ }
34
+ /** Combine every held event into ONE record and emit it, if there is anything to combine. */
35
+ flush() {
36
+ this.cancel();
37
+ if (this.held.length === 0)
38
+ return;
39
+ const entries = this.held;
40
+ this.held = [];
41
+ const ids = entries.map((e) => e.id).filter((id) => id !== null);
42
+ this.emit({ type: "event", id: ids.length === 0 ? null : Math.max(...ids), ids, text: formatDigest(entries), origin: null });
43
+ }
44
+ /**
45
+ * Stop the timer WITHOUT flushing. For a stop where a newer listener already streams this session and will receive (and
46
+ * hold) the same events via the dashboard's replay, flushing here too would double-deliver the digest; for any other stop
47
+ * whatever is held was never marked delivered, so a later start picks it up the same way. A live, un-fired timer would also
48
+ * keep a process's event loop alive for no reason.
49
+ */
50
+ cancel() {
51
+ if (this.timer === null)
52
+ return;
53
+ clearTimeout(this.timer);
54
+ this.timer = null;
55
+ }
56
+ /** Drop everything held, without emitting it: the caller is letting go of the stream and the dashboard replays it. */
57
+ reset() {
58
+ this.cancel();
59
+ this.held = [];
60
+ }
61
+ /** Every id currently held, unflushed: handed to the next listener so it drops a replay of one. */
62
+ heldIds() {
63
+ return this.held.map((e) => e.id).filter((id) => id !== null);
64
+ }
65
+ }
@@ -10,11 +10,11 @@
10
10
  * collapse into the one hook + this one subcommand). `<event>` is one of
11
11
  * `session_start` | `session_resume` | `after_compaction` |
12
12
  * `sub_agent_start` — the SAME `npx -y @thehammer/danx-dashboard-mcp@<pin>`
13
- * path the plugin's other dashboard calls already use (`bridge`) — never a
13
+ * path the plugin's other one-shot subcommands (`activity`, `background-work`, `ready-cards`, `restart-notice`) already use — never a
14
14
  * separate install, never a deep import of this package's `dist/` internals.
15
15
  *
16
- * THE CREDENTIAL — the SAME resolver `bridge` uses
17
- * (`resolveBridgeOptions`, `bridge.ts`): the dashboard URL, credential source
16
+ * THE CREDENTIAL — the SAME resolver the other one-shot subcommands use
17
+ * (`resolveDashboardAuth`, `session-options.ts`): the dashboard URL, credential source
18
18
  * and credential all come from the connection record this session's OWN
19
19
  * danx-dashboard MCP server wrote on its last successful `plan_connect`
20
20
  * (`session-connection.ts`), never from this process's own ambient env. This
@@ -39,7 +39,7 @@
39
39
  * A HARD TIMEOUT — a SessionStart hook must never hang on a wedged
40
40
  * dashboard.
41
41
  */
42
- import { SESSION_ID_HEADER, resolveBridgeOptions } from "./bridge.js";
42
+ import { SESSION_ID_HEADER, resolveDashboardAuth } from "./session-options.js";
43
43
  export const EVENT_TEXT_SUBCOMMAND = "event-text";
44
44
  export const EVENT_TEXT_REQUEST_TIMEOUT_MS = 5_000;
45
45
  const USAGE = `usage: CLAUDE_CODE_SESSION_ID=<session-id> danx-dashboard-mcp ${EVENT_TEXT_SUBCOMMAND} <event>`;
@@ -116,7 +116,7 @@ resolveFrom = {}) {
116
116
  }
117
117
  let options;
118
118
  try {
119
- options = resolveBridgeOptions({ sessionId, resumeIds: [] }, env, resolveFrom);
119
+ options = resolveDashboardAuth(sessionId, env, resolveFrom);
120
120
  }
121
121
  catch (err) {
122
122
  const start = err;
package/dist/handlers.js CHANGED
@@ -41,16 +41,16 @@ const PLAN_SESSIONS_BASE_PATH = "/api/plan-sessions";
41
41
  * plan it left (`movedFrom`).
42
42
  *
43
43
  * IT DOES NOTHING ABOUT HEARING THE PLAN'S EVENTS, deliberately. Delivery belongs
44
- * to the danxbot Claude Code plugin's plan event bridge, a background process
45
- * that holds the session's ONE listener ticket and starts when this tool
44
+ * to the plan event relay inside this MCP server (`plan_events_wait`, `plan-events-relay.ts`),
45
+ * which holds the session's ONE listener ticket and is started by the danxbot plugin's loop once this tool
46
46
  * succeeds. The dashboard keeps one ticket per session, so a ticket minted here
47
- * would end the bridge's stream.
47
+ * would end the relay's stream.
48
48
  *
49
49
  * `title` (DX-2816) rides here, not on a header, because the AGENT — not this
50
50
  * server — can read Claude's own session title at connect time (via a
51
51
  * Desktop-side `get_session` tool no MCP server subprocess can call). Sent
52
52
  * only when the caller supplied one; the server leaves an unset title alone.
53
- * The event bridge keeps it current afterwards, from the transcript (DX-4461,
53
+ * The event relay keeps it current afterwards, from the transcript (DX-4461,
54
54
  * `session-title.ts`).
55
55
  */
56
56
  export async function planConnect(client, args) {
@@ -0,0 +1,121 @@
1
+ /**
2
+ * DX-3028 / DX-4233 - ONE heartbeat tick of a session's event stream, of the in-process relay behind
3
+ * `plan_events_wait`: what to send (connection state, session stats, the transcript's title), what
4
+ * to do with the answer, and the self-heal for a stream that reads as open while no heartbeat lands. WHEN a tick fires, and
5
+ * what an announced notice does, belongs to the owner.
6
+ *
7
+ * A tick never throws and never stops the stream: the stats and title are cosmetic and the heartbeat is liveness, so a
8
+ * failure of either costs that part (said once on stderr), never the beat.
9
+ */
10
+ import { keyRevokedHalt } from "./key-revoked-halt.js";
11
+ import { oneLine } from "./one-line.js";
12
+ import { DIGEST_PREFIX } from "./digest-hold.js";
13
+ import { HEARTBEAT_INTERVAL_MS, HEARTBEAT_SILENT_FAILURE_THRESHOLD, sendHeartbeat, } from "./session-stream.js";
14
+ import { OperatorSessionStats } from "./session-stats.js";
15
+ import { TranscriptTitleReader } from "./session-title.js";
16
+ export class HeartbeatReporter {
17
+ deps;
18
+ sessionStats;
19
+ titleReader;
20
+ transcriptPath;
21
+ // DX-4461 - the title the dashboard last answered with. Only the LATEST landed answer counts: one without a title (an older
22
+ // dashboard, including one rolled back to mid-session, or a refusal) stops the title until an answer carries one again,
23
+ // since an older dashboard refuses the `title` key and a refused heartbeat is a dead listener.
24
+ dashboardTitle;
25
+ lastTitleError = null;
26
+ // DX-3099 - consecutive heartbeats that failed to LAND while the owner believed it was streaming.
27
+ failureStreak = 0;
28
+ // DX-4418 - the stop notice for a revoked key goes out once; every later beat is refused the same way.
29
+ revocationAnnounced = false;
30
+ constructor(deps, transcriptPath) {
31
+ this.deps = deps;
32
+ this.transcriptPath = transcriptPath;
33
+ this.sessionStats = new OperatorSessionStats(transcriptPath, deps.makeStatsWatcher);
34
+ this.titleReader = transcriptPath === null ? null : new TranscriptTitleReader(transcriptPath);
35
+ }
36
+ /**
37
+ * The relay learns the session's transcript from each `plan_events_wait` call, after the reporter exists: a different path
38
+ * (a resumed session) starts a fresh collector and title reader; the same one, or none, changes nothing.
39
+ */
40
+ useTranscript(path) {
41
+ if (path === undefined || path === this.transcriptPath)
42
+ return;
43
+ this.sessionStats.stop();
44
+ this.transcriptPath = path;
45
+ this.sessionStats = new OperatorSessionStats(path, this.deps.makeStatsWatcher);
46
+ this.titleReader = new TranscriptTitleReader(path);
47
+ this.dashboardTitle = undefined;
48
+ this.lastTitleError = null;
49
+ }
50
+ /** A fresh connect cycle earns a clean slate for the silent-failure count. */
51
+ resetFailureStreak() {
52
+ this.failureStreak = 0;
53
+ }
54
+ /** Stop tailing the transcript. Idempotent. */
55
+ stop() {
56
+ this.sessionStats.stop();
57
+ }
58
+ titleToReport() {
59
+ if (this.titleReader === null || this.dashboardTitle === undefined)
60
+ return undefined;
61
+ // The title is cosmetic and the heartbeat is liveness: a transcript the title cannot be read from costs the title (said
62
+ // once per distinct failure), never the beat.
63
+ try {
64
+ const title = this.titleReader.read();
65
+ this.lastTitleError = null;
66
+ return title !== null && title !== this.dashboardTitle ? title : undefined;
67
+ }
68
+ catch (err) {
69
+ const message = err instanceof Error ? err.message : String(err);
70
+ if (message !== this.lastTitleError)
71
+ process.stderr.write(`session title: ${message}\n`);
72
+ this.lastTitleError = message;
73
+ return undefined;
74
+ }
75
+ }
76
+ /** One heartbeat. Fired without being awaited: its own failures are reported here, never thrown. */
77
+ tick() {
78
+ const title = this.titleToReport();
79
+ void this.sessionStats
80
+ .tick(async (stats) => {
81
+ const answer = await sendHeartbeat(this.deps.auth(), this.deps.http, this.deps.connection(), stats, title);
82
+ if (answer.landed)
83
+ this.dashboardTitle = answer.title ?? undefined;
84
+ // DX-4418 - a person revoked this session's key: tell the session to stop, at once and only once. Its next
85
+ // dashboard call says the same.
86
+ if (answer.revocation !== null && !this.revocationAnnounced) {
87
+ this.revocationAnnounced = true;
88
+ this.deps.announce(keyRevokedHalt(answer.revocation, false));
89
+ }
90
+ return answer;
91
+ })
92
+ .then((landed) => {
93
+ // DX-3099 - only counts while the owner still believes it is streaming: that is the exact desync the card found (the
94
+ // client believes it is fine; the dashboard has nothing recent for it). A failure while reconnecting or stopped is
95
+ // not new information: the mint backoff already owns recovery there.
96
+ if (!this.deps.streaming() || landed) {
97
+ this.failureStreak = 0;
98
+ return;
99
+ }
100
+ this.failureStreak += 1;
101
+ if (this.failureStreak < HEARTBEAT_SILENT_FAILURE_THRESHOLD)
102
+ return;
103
+ this.failureStreak = 0;
104
+ // The session finds out, rather than continuing to believe it is connected.
105
+ this.deps.announce(oneLine(`${DIGEST_PREFIX} this session's listener stopped reaching the dashboard for ` +
106
+ `${Math.round((HEARTBEAT_SILENT_FAILURE_THRESHOLD * HEARTBEAT_INTERVAL_MS) / 1000)}s while it still ` +
107
+ `believed it was streaming — forcing a fresh connection now.`));
108
+ // End the CURRENT connection now, rather than wait on the stream reader's own read-idle timeout (which this exact
109
+ // failure mode can outlive indefinitely: the connection reads as established and keeps producing keep-alive bytes,
110
+ // only the outbound heartbeat silently never lands). The new attempt reconnects with the SAME ticket; the
111
+ // dashboard's one-open-stream-per-ticket rule then ends the old, superseded entry, releasing the exclusive claim
112
+ // as a side effect of THIS reconnect, never an external watchdog.
113
+ this.deps.forceReconnect();
114
+ })
115
+ .catch((err) => {
116
+ // DX-3962: a stats failure must never take the stream down. `tick` is fired without being awaited, so a throw here
117
+ // would be an unhandled rejection that kills the process and with it the session's plan events.
118
+ process.stderr.write(`operator session stats: heartbeat tick failed: ${err instanceof Error ? err.message : String(err)}\n`);
119
+ });
120
+ }
121
+ }