@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 +23 -16
- package/dist/activity.js +1 -1
- package/dist/background-work.js +4 -4
- package/dist/credential.js +7 -8
- package/dist/digest-hold.js +65 -0
- package/dist/event-text.js +5 -5
- package/dist/handlers.js +4 -4
- package/dist/heartbeat-reporter.js +121 -0
- package/dist/index.js +93 -37
- package/dist/json-body.js +3 -3
- package/dist/listen.js +19 -13
- package/dist/one-shot-session-request.js +7 -7
- package/dist/plan-events-relay.js +425 -0
- package/dist/plan-events-tool.js +9 -0
- package/dist/restart-notice.js +3 -3
- package/dist/session-access.js +2 -2
- package/dist/session-connection.js +16 -17
- package/dist/session-key.js +2 -2
- package/dist/session-options.js +58 -0
- package/dist/session-stats.js +10 -19
- package/dist/session-stream.js +358 -0
- package/dist/session-title.js +5 -5
- package/dist/shutdown.js +12 -0
- package/dist/stop-fixes.js +40 -0
- package/dist/subagents-live.js +2 -2
- package/dist/tool-access.js +7 -1
- package/dist/tool-surface.js +5 -7
- package/package.json +3 -2
- package/dist/bridge.js +0 -1125
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
|
|
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;
|
|
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).
|
|
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
|
|
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
|
-
## `
|
|
61
|
+
## `plan_events_wait` — a working session's plan events (DX-4233)
|
|
62
62
|
|
|
63
|
-
A program
|
|
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
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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
|
|
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 `
|
|
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
|
*/
|
package/dist/background-work.js
CHANGED
|
@@ -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 `
|
|
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 `
|
|
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 `
|
|
43
|
-
* (
|
|
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).
|
package/dist/credential.js
CHANGED
|
@@ -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
|
|
5
|
-
* Code session: the MCP SERVER (the session's
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
|
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
|
+
}
|
package/dist/event-text.js
CHANGED
|
@@ -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
|
|
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
|
|
17
|
-
* (`
|
|
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,
|
|
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 =
|
|
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
|
|
45
|
-
*
|
|
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
|
|
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
|
|
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
|
+
}
|