@thehammer/danx-dashboard-mcp 0.1.286 → 0.1.287
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -8
- package/dist/credential.js +5 -5
- package/dist/index.js +11 -41
- package/dist/request-bound.js +1 -2
- package/dist/restart-notice.js +50 -4
- package/dist/session-connection.js +9 -10
- package/dist/session-options.js +6 -6
- package/dist/stop-fixes.js +4 -4
- package/package.json +1 -1
- package/dist/activity.js +0 -260
- package/dist/background-work.js +0 -162
- package/dist/one-shot-session-request.js +0 -105
- package/dist/ready-cards.js +0 -176
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).
|
|
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
|
|
23
|
+
- **Storage.** `~/.config/danxbot/session-keys/<sha12(dashboard url)>/<session id>.json`, mode 0600, written write-then-rename, pruned after 7 days. One file per dashboard and per session; this server reads it at boot, and `restart_notice` reads the same file through the `session-key` credential source and proves the fingerprint. The key lapses on the dashboard after 24 hours without use (every authenticated call, the relay's heartbeat included, renews it), and is revocable from the account page.
|
|
24
24
|
- **A 401 on the stored key** deletes the file and returns the sign-in halt (in `plan_connect`: a new request in the same call). That is a key that lapsed. A key a PERSON revoked is answered `401 key_revoked` (DX-4418): the key file is deleted and EVERY dashboard tool, `plan_connect` included, returns one stop halt (who revoked it and when; commit work in progress and end; a dispatched agent makes no further call), for the rest of the process. No access request is made. The worker finds the revocation itself and ends the dispatch as revoked (every dashboard route, the dispatch-complete one included, refuses a revoked key). `plan_events_wait` answers `stopped` with the reason `key_revoked` the same way. **A 404 on create** means the dashboard predates request-and-approve and says so.
|
|
25
25
|
- A dispatch's `DANX_DASHBOARD_CREDENTIAL` skips all of this. There is no token-file credential source.
|
|
26
26
|
|
|
@@ -79,8 +79,6 @@ The contract the plugin builds against:
|
|
|
79
79
|
- **Nothing outlives the session.** The heartbeat interval, the backoff sleep and the digest hold are unref'd; `close()` (run when the transport closes and when stdin ends) aborts the stream and the mint and inventory requests and clears a pending call's wait timer, the one ref'd timer, which otherwise holds the process only until the call answers.
|
|
80
80
|
- **Credential and audience.** The server's own, from its boot-time `CLAUDE_CODE_SESSION_ID`: no connection record, fingerprint check or environment variable is involved. The tool is advertised only to a person's session holding `plans.view`, never to a dispatch. The stream's advisory frames (the idle nudge, DX-4340's pacing message, DX-4342's auto-resume message) arrive as records like any event, at once and never held for a digest. The digest (agent and machine events held up to 10 minutes) is one record, `[plan events] digest`, and a held id is never acknowledged before it is returned.
|
|
81
81
|
|
|
82
|
-
The one-shot subcommands below (`activity`, `background-work`, `ready-cards`) still read the connection record this server wrote on `plan_connect` (`src/session-connection.ts`) and refuse to run unless the credential it names fingerprints identically (`credential_mismatch`, DX-2862).
|
|
83
|
-
|
|
84
82
|
## `restart_notice` — what a restarted session was working on (DX-3928, DX-4632)
|
|
85
83
|
|
|
86
84
|
A tool for the danxbot plugin's session-start hook, not for a model, advertised only on a person's session (never a dispatch) and **usable before sign-in**: a restarted Claude Code session gets a new session id, no plan binding and, until a person approves its first call, no dashboard key of its own, so nothing else could tell it that the plan it had been working was unattended. `plan_connect` writes the session's working directory into its connection record (`projectKey`, record schema v2); `restart_notice` finds the newest OTHER sessions' records for the same project (at most ten, each within `STALE_RECORD_MS`), resolves each record's own credential, and asks `GET /api/plan-sessions/<predecessor>/restart-notice` which plan it was on and how many operator events wait there (the connect digest's own query, read-only). No arguments. Answers one of:
|
|
@@ -91,10 +89,6 @@ A tool for the danxbot plugin's session-start hook, not for a model, advertised
|
|
|
91
89
|
|
|
92
90
|
ONE deadline of 8 s bounds the whole lookup, every candidate included (each request also keeps its own 5 s bound), so a hung dashboard answers `lookup_timeout` and never wedges a session start. A record from an older server (schema v1) is skipped here and refused, with the fix, everywhere a session reads its OWN record.
|
|
93
91
|
|
|
94
|
-
## `ready-cards` — the plan's cards anyone could pick up now (DX-4534)
|
|
95
|
-
|
|
96
|
-
Run by the danxbot plugin's Stop hook for a plan-connected session. `GET /api/plans/mine` answers the session's plan and the boards its cards live on (a session on no plan is a 409 `session_not_connected`, read as an empty answer); then, for each board, `GET /api/issues?board=` with `filter.plan_id` + `filter.status_derived` returns the plan's ToDo cards, and the ready ones are kept: a Story, Bug or Chore with no assigned agent, no live dispatch, no block, no open problem, no unmet `depends_on` and no active `conflict_on`. No dashboard route was added. Exit 0 prints one JSON line `{"plan_id": <n|null>, "cards": [{"id", "title"}]}`; exit 1 prints one stderr line that starts with its reason (`credential_unavailable`, `no_connection_record`, `http_error`, ...) and nothing on stdout.
|
|
97
|
-
|
|
98
92
|
## `subagents-live` — the plan pane's local live sub-agent feed (DX-4508)
|
|
99
93
|
|
|
100
94
|
`danx-dashboard-mcp subagents-live <absolute path to the session's main transcript .jsonl>`. Spawned by the danxbot plugin's plan pane, which cannot tail files itself; the dashboard's sub-agent read lags the transcript by about 80s, this by about one second. It tails the session's transcript tree (the main file and `<session-id>/subagents/agent-*.jsonl` beside it) with the same `SessionLogWatcher` the relay runs, from byte 0 once and then only appended bytes, polling every second, and computes each sub-agent through `@thehammer/danx-session-telemetry`'s `createSubagentLiveView` (the same observer, ledger counter and pricing rule the dashboard uses). stdout carries only `{"subagents":[...]}` lines: the first, after the catch-up read, holds every sub-agent (an empty array when there are none); each later one holds only the sub-agents a poll changed. No network, no credential. A missing, relative or non-`.jsonl` path exits 2 with one stderr line; SIGTERM, SIGINT or a closed stdout exits 0.
|
package/dist/credential.js
CHANGED
|
@@ -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
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* both go through it, and
|
|
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
|
|
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()`).
|
|
165
|
-
*
|
|
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
|
|
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
|
|
333
|
-
*
|
|
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
|
|
737
|
+
// DX-2862 — the connect is also the moment the session's connection record is written for `restart_notice` and the listener reminder: of the
|
|
741
738
|
// dashboard MCP servers a session may have configured, the one that connected the plan is the one whose credential they
|
|
742
739
|
// must resolve (the relay needs none of this: it lives in each server, with that server's own credential).
|
|
743
740
|
async (args) => {
|
|
@@ -861,40 +858,14 @@ async function main() {
|
|
|
861
858
|
// symlink-aware (DX-1647) so it holds under the symlinked `npx` bin the worker
|
|
862
859
|
// spawns, not just a direct `node dist/index.js`.
|
|
863
860
|
//
|
|
864
|
-
// The
|
|
865
|
-
//
|
|
866
|
-
// session-connection record this server's own `plan_connect` wrote (`session-connection.ts`), never from the subcommand's own
|
|
867
|
-
// environment (DX-2862: trusting an ambient env credential is the exact bug this repo fixed). There is no event-stream
|
|
868
|
-
// subcommand: a session's plan events are served in this process by `plan_events_wait` (DX-4233).
|
|
861
|
+
// The only subcommand is `subagents-live`, which the danxbot plugin's plan pane runs (DX-4508); it does not boot the MCP
|
|
862
|
+
// server and uses no network or credential. A session's plan events are served in this process by `plan_events_wait` (DX-4233).
|
|
869
863
|
//
|
|
870
864
|
// Any OTHER argument is refused rather than ignored, so a typo cannot
|
|
871
865
|
// silently start an MCP server on stdio where a subcommand was meant to run.
|
|
872
866
|
if (isEntrypointModule(import.meta.url, process.argv[1])) {
|
|
873
867
|
const [subcommand, ...rest] = process.argv.slice(2);
|
|
874
|
-
if (subcommand ===
|
|
875
|
-
runBackgroundWorkCommand(rest).then((code) => process.exit(code), (err) => {
|
|
876
|
-
console.error(`[danx-dashboard-mcp] background-work fatal: ${err.message}`);
|
|
877
|
-
process.stdout.write(`${JSON.stringify({ ok: false, reason: "fatal" })}\n`);
|
|
878
|
-
process.exit(0);
|
|
879
|
-
});
|
|
880
|
-
}
|
|
881
|
-
else if (subcommand === ACTIVITY_SUBCOMMAND) {
|
|
882
|
-
// DX-3284 — the same hook-only contract as background-work: a rejection prints one
|
|
883
|
-
// failure line and exits 0, so an async hook never surfaces a stack trace.
|
|
884
|
-
runActivityCommand(rest).then((code) => process.exit(code), (err) => {
|
|
885
|
-
console.error(`[danx-dashboard-mcp] activity fatal: ${err.message}`);
|
|
886
|
-
process.stdout.write(`${JSON.stringify({ ok: false, reason: "fatal" })}\n`);
|
|
887
|
-
process.exit(0);
|
|
888
|
-
});
|
|
889
|
-
}
|
|
890
|
-
else if (subcommand === READY_CARDS_SUBCOMMAND) {
|
|
891
|
-
// DX-4534 — the plugin Stop hook's read; exit 0 (one JSON line) / 1 (read failed).
|
|
892
|
-
runReadyCardsCommand(rest).then((code) => process.exit(code), (err) => {
|
|
893
|
-
console.error(`[danx-dashboard-mcp] ${READY_CARDS_SUBCOMMAND} fatal: ${err.message}`);
|
|
894
|
-
process.exit(1);
|
|
895
|
-
});
|
|
896
|
-
}
|
|
897
|
-
else if (subcommand === SUBAGENTS_LIVE_SUBCOMMAND) {
|
|
868
|
+
if (subcommand === SUBAGENTS_LIVE_SUBCOMMAND) {
|
|
898
869
|
// DX-4508 — the plugin pane's local live sub-agent feed: stdout is its JSON Lines channel, so a fatal goes to
|
|
899
870
|
// stderr only and exits 1; a refused path exits 2 from the command itself.
|
|
900
871
|
runSubagentsLiveCommand(rest).then((code) => process.exit(code), (err) => {
|
|
@@ -903,9 +874,8 @@ if (isEntrypointModule(import.meta.url, process.argv[1])) {
|
|
|
903
874
|
});
|
|
904
875
|
}
|
|
905
876
|
else if (subcommand !== undefined) {
|
|
906
|
-
console.error(`[danx-dashboard-mcp] unknown subcommand "${subcommand}" (the only
|
|
907
|
-
`"${
|
|
908
|
-
`"${READY_CARDS_SUBCOMMAND}", and "${SUBAGENTS_LIVE_SUBCOMMAND}")`);
|
|
877
|
+
console.error(`[danx-dashboard-mcp] unknown subcommand "${subcommand}" (the only one is ` +
|
|
878
|
+
`"${SUBAGENTS_LIVE_SUBCOMMAND}")`);
|
|
909
879
|
process.exit(2);
|
|
910
880
|
}
|
|
911
881
|
else {
|
package/dist/request-bound.js
CHANGED
|
@@ -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` (`
|
|
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) {
|
package/dist/restart-notice.js
CHANGED
|
@@ -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
|
|
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 {
|
|
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 (
|
|
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
|
|
3
|
+
* connection from its MCP server to its other readers (`restart_notice`, the listener reminder).
|
|
4
4
|
*
|
|
5
|
-
* THE PROBLEM THIS SOLVES. A
|
|
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
|
|
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
|
|
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
|
|
150
|
-
*
|
|
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
|
-
"
|
|
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
|
-
"
|
|
175
|
+
"`restart_notice` and the listener reminder will not find this session's connection until it succeeds",
|
|
177
176
|
};
|
|
178
177
|
}
|
|
179
178
|
}
|
package/dist/session-options.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* DX-2862 / DX-4233 - how
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* unless it fingerprints identically.
|
|
6
|
-
* server that holds the credential.
|
|
2
|
+
* DX-2862 / DX-4233 - how `restart_notice` (`restart-notice.ts`, this file's only caller) learns which dashboard to call and
|
|
3
|
+
* as whom when it asks about an earlier session. It reads the connection record that session's own MCP server wrote when it
|
|
4
|
+
* connected a plan (`session-connection.ts`), resolves the credential the record names through the same resolver the server
|
|
5
|
+
* used, and refuses unless it fingerprints identically. The in-process relay behind `plan_events_wait` needs none of this: it
|
|
6
|
+
* lives inside the server that holds the credential.
|
|
7
7
|
*/
|
|
8
8
|
import { homedir } from "node:os";
|
|
9
9
|
import { credentialFingerprint, describeCredentialSource, readCredential } from "./credential.js";
|
|
@@ -52,7 +52,7 @@ export function resolveDashboardAuth(sessionId, env, options = {}) {
|
|
|
52
52
|
const fingerprint = credentialFingerprint(token);
|
|
53
53
|
if (fingerprint !== record.fingerprint) {
|
|
54
54
|
throw new SessionStartError("credential_mismatch", `${describeCredentialSource(record.source)} now yields credential ${fingerprint}, but this session's ` +
|
|
55
|
-
`danx-dashboard MCP server is using ${record.fingerprint} — a
|
|
55
|
+
`danx-dashboard MCP server is using ${record.fingerprint} — a lookup would authenticate as somebody else`, fixForStop("credential_mismatch"));
|
|
56
56
|
}
|
|
57
57
|
return { dashboardUrl: record.dashboardUrl.replace(/\/+$/, ""), token, sessionId };
|
|
58
58
|
}
|
package/dist/stop-fixes.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* DX-4233 - the remedy a session is shown for each way its dashboard event stream can stop, in ONE place.
|
|
3
3
|
*
|
|
4
|
-
* The client that ends in these texts is the in-process relay behind `plan_events_wait` (`plan-events-relay.ts`);
|
|
5
|
-
*
|
|
4
|
+
* The client that ends in these texts is the in-process relay behind `plan_events_wait` (`plan-events-relay.ts`); `session-options.ts` uses
|
|
5
|
+
* `fixForStop` for a start it cannot make. Pure data and one function with no imports.
|
|
6
6
|
*/
|
|
7
7
|
/**
|
|
8
8
|
* The remedy per terminal reason. ONE map, so the message a session is shown and the message a log carries can never drift.
|
|
@@ -15,10 +15,10 @@
|
|
|
15
15
|
const RESTART_NOTICE_STOP_LEAD = "continue without it";
|
|
16
16
|
const RESTART_NOTICE_STOP_TAIL = "call plan_connect to connect this session to a plan";
|
|
17
17
|
export const STOP_FIXES = {
|
|
18
|
-
no_connection_record: "call plan_connect again in this session — its danx-dashboard MCP server records the connection
|
|
18
|
+
no_connection_record: "call plan_connect again in this session — its danx-dashboard MCP server records the connection that restart_notice and the listener reminder read " +
|
|
19
19
|
"(a server older than the one that introduced the record writes none)",
|
|
20
20
|
credential_unavailable: "call plan_connect again to request access: this session's key file is missing or unreadable",
|
|
21
|
-
credential_mismatch: "restart this Claude Code session so its MCP server
|
|
21
|
+
credential_mismatch: "restart this Claude Code session so its MCP server resolves its credential afresh, then call plan_connect again",
|
|
22
22
|
not_connected: "call plan_connect to connect this session to a plan",
|
|
23
23
|
plan_archived: "the plan was archived or deleted, so this session was disconnected; ask the operator to restore it, then call plan_connect again",
|
|
24
24
|
unauthorized: "the dashboard no longer accepts this session's key (it lapsed after a day unused): call plan_connect again to request access",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@thehammer/danx-dashboard-mcp",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.287",
|
|
4
4
|
"description": "Stdio MCP server fronting danxbot's dashboard API for agents (operator sessions and dispatched workers alike): the generic danxbot_api/danxbot_api_list/danxbot_api_spec tools reach any route the caller's credential may call, plus dedicated agent-specific tools (plan_connect, request_permission, attach_file, and danxbot_slack_reply) (DX-3465, PLN-11 R-25).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/dist/activity.js
DELETED
|
@@ -1,260 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `danx-dashboard-mcp activity <SubagentStart|SubagentStop|PostToolUse> <event-at>` — the
|
|
3
|
-
* ONE-SHOT client of `POST /api/plan-sessions/me/activity` (DX-3284).
|
|
4
|
-
*
|
|
5
|
-
* WHO RUNS IT. The danxbot Claude Code plugin's `SubagentStart`, `SubagentStop` and
|
|
6
|
-
* `PostToolUse` (matcher `Bash`) hooks (`danxbot/scripts/activity-report.mjs` in
|
|
7
|
-
* claude-plugins), all registered `async: true`. The plugin runs the INSTALLED package
|
|
8
|
-
* with `node` (DX-3811), never a cold `npx`, and pipes the hook's own JSON envelope on
|
|
9
|
-
* stdin. This module owns what each event MEANS for the session's running set; the plugin
|
|
10
|
-
* decides only whether to call at all (a plan-connected session, a background Bash call).
|
|
11
|
-
*
|
|
12
|
-
* THE THREE EVENTS (the Claude Code hooks reference, `code.claude.com/docs/en/hooks`):
|
|
13
|
-
*
|
|
14
|
-
* | Event | Reports | Key |
|
|
15
|
-
* |----------------|------------------------------------------------------------------|----------------------------|
|
|
16
|
-
* | `SubagentStart`| an `agent` activity, running | `agent-<agent_id>` |
|
|
17
|
-
* | `SubagentStop` | the same activity, FINISHED | `agent-<agent_id>` |
|
|
18
|
-
* | `PostToolUse` | a `bash` activity, running — only for `Bash` with | `tool_use_id` |
|
|
19
|
-
* | | `tool_input.run_in_background: true` | |
|
|
20
|
-
*
|
|
21
|
-
* THE SUB-AGENT KEY. A sub-agent's transcript is `<session>/subagents/agent-<agent_id>.jsonl`,
|
|
22
|
-
* and the transcript producer (`createActivityObserver`) keys its row on that file's
|
|
23
|
-
* basename, `agent-<agent_id>`. Reporting the bare `agent_id` would upsert a SECOND row for
|
|
24
|
-
* the same sub-agent, so the hook-side key is the same basename: the two producers converge
|
|
25
|
-
* on one row. The hooks docs disagree on whether `agent_id` already carries the `agent-`
|
|
26
|
-
* prefix, so {@link subagentActivityId} normalizes both forms to the one key.
|
|
27
|
-
*
|
|
28
|
-
* KNOWN LIMITS (DX-3284, pinned in `plan-session-activity.test.ts`):
|
|
29
|
-
* - a sub-agent RESUMED after its SubagentStop reports a second start, but the server's
|
|
30
|
-
* first finish wins, so the row stays closed;
|
|
31
|
-
* - a SubagentStop with no `agent_id` (Claude Code's internal agents) is refused below, and
|
|
32
|
-
* the plugin leaves a failure trace. One WITH an id but no recorded start is
|
|
33
|
-
* indistinguishable from a stop that beat its (async) start, so it is recorded as an
|
|
34
|
-
* already-finished row — it never counts as running. (`transcript_path` cannot supply it: on these events it is the MAIN session's
|
|
35
|
-
* transcript — only `SubagentStop`'s `agent_transcript_path` names the sub-agent's own, and
|
|
36
|
-
* it is used below to REFUSE a mismatch, never to guess one.)
|
|
37
|
-
*
|
|
38
|
-
* THERE IS NO "BACKGROUND SHELL FINISHED" EVENT. None of the documented hook events fires
|
|
39
|
-
* when a `run_in_background` Bash command ends (verified against the full event list on
|
|
40
|
-
* DX-2954's Step 0 note; `TaskCreated` / `TaskCompleted` are the `TaskCreate` tool, not a
|
|
41
|
-
* shell). A `bash` activity therefore gets a start here and no finish: its end comes from
|
|
42
|
-
* the dispatch's own terminal write (`finishOpenActivities`), never from inferring one out
|
|
43
|
-
* of transcript lines. Until then it reads as running and silent, which is the honest
|
|
44
|
-
* signal. Do not "fix" this with a proxy event.
|
|
45
|
-
*
|
|
46
|
-
* THE EVENT TIME is the hook's own, captured by the plugin when the hook FIRED and passed
|
|
47
|
-
* as `<event-at>` — never this process's clock, which starts only after the (possibly
|
|
48
|
-
* cold) install step. The server's upsert is monotone in every field, so hooks that finish
|
|
49
|
-
* out of order, or one that fires twice, still reach the same row.
|
|
50
|
-
*
|
|
51
|
-
* THE CONTRACT (identical to `background-work`, so the plugin treats both alike):
|
|
52
|
-
* - print EXACTLY ONE JSON line on stdout and ALWAYS exit 0 — a hook has no business
|
|
53
|
-
* spamming the session with a stack trace;
|
|
54
|
-
* - `{"ok":true,"runningActivities":<n>}` on success;
|
|
55
|
-
* - `{"ok":false,"reason":"<why>"}` otherwise. `no_connection_record` and
|
|
56
|
-
* `session_not_connected` are the ordinary "this session is not on a plan" answers;
|
|
57
|
-
* - human-readable diagnostics go to stderr only.
|
|
58
|
-
*
|
|
59
|
-
* THE CREDENTIAL — the SAME resolver `plan_events_wait` uses (`resolveSessionOptions`): the URL,
|
|
60
|
-
* credential source and credential come from the connection record this session's own MCP
|
|
61
|
-
* server wrote on `plan_connect`, never from this process's ambient env (DX-2862).
|
|
62
|
-
*/
|
|
63
|
-
import { win32 } from "node:path";
|
|
64
|
-
import { readFileSync } from "node:fs";
|
|
65
|
-
import { labelOf } from "@thehammer/danx-session-telemetry";
|
|
66
|
-
import { parseEventAtArg } from "./background-work.js";
|
|
67
|
-
import { resolveSessionOptions, sendSessionRequest, tryParseJsonBody } from "./one-shot-session-request.js";
|
|
68
|
-
export const ACTIVITY_SUBCOMMAND = "activity";
|
|
69
|
-
export const ACTIVITY_PATH = "/api/plan-sessions/me/activity";
|
|
70
|
-
/** How long this one-shot call may take before it counts as a timeout. */
|
|
71
|
-
export const ACTIVITY_REQUEST_TIMEOUT_MS = 5_000;
|
|
72
|
-
/** The hook events this subcommand accepts, exactly as Claude Code names them in `hook_event_name`. */
|
|
73
|
-
export const ACTIVITY_HOOK_EVENTS = ["SubagentStart", "SubagentStop", "PostToolUse"];
|
|
74
|
-
const USAGE = `usage: CLAUDE_CODE_SESSION_ID=<session-id> danx-dashboard-mcp ${ACTIVITY_SUBCOMMAND} <${ACTIVITY_HOOK_EVENTS.join("|")}> <event-at ISO-8601 UTC> (the hook's JSON envelope on stdin)`;
|
|
75
|
-
const SUBAGENT_PREFIX = "agent-";
|
|
76
|
-
/**
|
|
77
|
-
* A sub-agent's transcript file basename — the key `createActivityObserver` reports it under.
|
|
78
|
-
*
|
|
79
|
-
* DX-3284: the hooks documentation shows `agent_id` BOTH ways — SubagentStart's example carries
|
|
80
|
-
* the `agent-` prefix (`agent-abc123`), SubagentStop's does not (`def456`, against the file
|
|
81
|
-
* `agent-def456.jsonl`). The key is `agent-` + the id with ONE leading `agent-` stripped, so both
|
|
82
|
-
* forms converge on the transcript producer's row; keying on the raw id would leave a phantom
|
|
83
|
-
* `agent-agent-<id>` row that no stop ever closes.
|
|
84
|
-
*/
|
|
85
|
-
export function subagentActivityId(agentId) {
|
|
86
|
-
const bare = agentId.startsWith(SUBAGENT_PREFIX) ? agentId.slice(SUBAGENT_PREFIX.length) : agentId;
|
|
87
|
-
return `${SUBAGENT_PREFIX}${bare}`;
|
|
88
|
-
}
|
|
89
|
-
/**
|
|
90
|
-
* DX-4498 — the facts only a transcript knows (the spawning sub-agent, effort, current tool call, how a background task
|
|
91
|
-
* ended, the card its spawning `Agent` call names). A hook envelope carries none of them, so it reports each as unknown; the server's merge
|
|
92
|
-
* never lets an unknown erase a value the transcript producer stored. A `SubagentStop` names no status either: its
|
|
93
|
-
* finish reads as `done` unless a task notification says otherwise.
|
|
94
|
-
*/
|
|
95
|
-
const NO_TRANSCRIPT_FACTS = {
|
|
96
|
-
parentActivityId: null,
|
|
97
|
-
effort: null,
|
|
98
|
-
currentActivity: null,
|
|
99
|
-
endStatus: null,
|
|
100
|
-
briefIssueId: null,
|
|
101
|
-
};
|
|
102
|
-
function nonEmptyString(value) {
|
|
103
|
-
return typeof value === "string" && value !== "";
|
|
104
|
-
}
|
|
105
|
-
/**
|
|
106
|
-
* One hook envelope -> the ONE `ActivityChange` it stands for, or the reason it stands for
|
|
107
|
-
* none. Pure: `eventAtMs` is the hook's own event time.
|
|
108
|
-
*/
|
|
109
|
-
export function activityChangeFromHook(event, envelope, eventAtMs) {
|
|
110
|
-
if (typeof envelope !== "object" || envelope === null || Array.isArray(envelope)) {
|
|
111
|
-
return { ok: false, reason: "bad_envelope" };
|
|
112
|
-
}
|
|
113
|
-
const e = envelope;
|
|
114
|
-
// The envelope names the event it was fired for; a mismatch is a wiring bug, not data.
|
|
115
|
-
if (e.hook_event_name !== undefined && e.hook_event_name !== event) {
|
|
116
|
-
return { ok: false, reason: "event_mismatch" };
|
|
117
|
-
}
|
|
118
|
-
if (event === "PostToolUse") {
|
|
119
|
-
const input = e.tool_input;
|
|
120
|
-
if (e.tool_name !== "Bash" || typeof input !== "object" || input === null) {
|
|
121
|
-
return { ok: false, reason: "not_background_bash" };
|
|
122
|
-
}
|
|
123
|
-
const toolInput = input;
|
|
124
|
-
if (toolInput.run_in_background !== true)
|
|
125
|
-
return { ok: false, reason: "not_background_bash" };
|
|
126
|
-
if (!nonEmptyString(e.tool_use_id))
|
|
127
|
-
return { ok: false, reason: "no_tool_use_id" };
|
|
128
|
-
return {
|
|
129
|
-
ok: true,
|
|
130
|
-
change: {
|
|
131
|
-
kind: "bash",
|
|
132
|
-
activityId: e.tool_use_id,
|
|
133
|
-
agentType: null,
|
|
134
|
-
// DX-4498 — agent-written free text, so it is redacted like every label that reaches the running set.
|
|
135
|
-
description: labelOf(toolInput.description),
|
|
136
|
-
startedAt: eventAtMs,
|
|
137
|
-
// No hook ever refreshes or ends a background shell (see the module docblock), so
|
|
138
|
-
// its liveness is its start and `finishedAt` stays null until the dispatch ends.
|
|
139
|
-
lastActivityAt: eventAtMs,
|
|
140
|
-
finishedAt: null,
|
|
141
|
-
...NO_TRANSCRIPT_FACTS,
|
|
142
|
-
},
|
|
143
|
-
};
|
|
144
|
-
}
|
|
145
|
-
// A SubagentStop from one of Claude Code's own internal agents carries no `agent_id`: it names
|
|
146
|
-
// no activity, so it is refused here (the plugin leaves a failure trace) and nothing is
|
|
147
|
-
// written — a stop must never invent a row.
|
|
148
|
-
if (!nonEmptyString(e.agent_id) || e.agent_id === SUBAGENT_PREFIX)
|
|
149
|
-
return { ok: false, reason: "no_agent_id" };
|
|
150
|
-
const activityId = subagentActivityId(e.agent_id);
|
|
151
|
-
if (event === "SubagentStop" && e.agent_transcript_path !== undefined) {
|
|
152
|
-
// The one place the hook names the sub-agent's own file: if it disagrees with the
|
|
153
|
-
// key derived from `agent_id`, this process would upsert a duplicate row. Refuse.
|
|
154
|
-
const file = typeof e.agent_transcript_path === "string" ? win32.basename(e.agent_transcript_path, ".jsonl") : null;
|
|
155
|
-
if (file !== activityId)
|
|
156
|
-
return { ok: false, reason: "agent_id_mismatch" };
|
|
157
|
-
}
|
|
158
|
-
return {
|
|
159
|
-
ok: true,
|
|
160
|
-
change: {
|
|
161
|
-
kind: "agent",
|
|
162
|
-
activityId,
|
|
163
|
-
agentType: nonEmptyString(e.agent_type) ? e.agent_type : null,
|
|
164
|
-
description: null,
|
|
165
|
-
startedAt: eventAtMs,
|
|
166
|
-
lastActivityAt: eventAtMs,
|
|
167
|
-
finishedAt: event === "SubagentStop" ? eventAtMs : null,
|
|
168
|
-
...NO_TRANSCRIPT_FACTS,
|
|
169
|
-
},
|
|
170
|
-
};
|
|
171
|
-
}
|
|
172
|
-
/**
|
|
173
|
-
* One authenticated POST, with a hard timeout, classified into the output shape this
|
|
174
|
-
* subcommand always prints — never a throw. Status is checked before any body is parsed
|
|
175
|
-
* (an HTML 5xx page must read as `http_error`, not `bad_response`); only a 409 needs its
|
|
176
|
-
* body, to tell "no plan" from "a worker's session".
|
|
177
|
-
*/
|
|
178
|
-
export async function postActivity(options, change, deps) {
|
|
179
|
-
const sent = await sendSessionRequest(options, ACTIVITY_PATH, { method: "POST", body: JSON.stringify({ activities: [change] }) }, deps);
|
|
180
|
-
if ("reason" in sent)
|
|
181
|
-
return sent;
|
|
182
|
-
const { status, text } = sent;
|
|
183
|
-
if (status === 401 || status === 403)
|
|
184
|
-
return { ok: false, reason: "unauthorized" };
|
|
185
|
-
if (status === 409) {
|
|
186
|
-
const parsed = tryParseJsonBody(text);
|
|
187
|
-
const error = parsed.ok ? parsed.body?.error : undefined;
|
|
188
|
-
if (error === "session_not_connected" || error === "session_is_worker")
|
|
189
|
-
return { ok: false, reason: error };
|
|
190
|
-
return { ok: false, reason: "http_error" };
|
|
191
|
-
}
|
|
192
|
-
if (status < 200 || status >= 300)
|
|
193
|
-
return { ok: false, reason: "http_error" };
|
|
194
|
-
const parsed = tryParseJsonBody(text);
|
|
195
|
-
if (!parsed.ok)
|
|
196
|
-
return { ok: false, reason: "bad_response" };
|
|
197
|
-
const body = parsed.body;
|
|
198
|
-
if (typeof body?.runningActivities !== "number")
|
|
199
|
-
return { ok: false, reason: "bad_response" };
|
|
200
|
-
return { ok: true, runningActivities: body.runningActivities };
|
|
201
|
-
}
|
|
202
|
-
/** The bin's `activity` subcommand, wired to the real process. Always resolves to exit code 0. */
|
|
203
|
-
export async function runActivityCommand(argv, env = process.env,
|
|
204
|
-
/** Test seam: the home the session's connection record is read from. */
|
|
205
|
-
resolveFrom = {},
|
|
206
|
-
/** Test seam: the hook envelope's text (the real process reads stdin). */
|
|
207
|
-
readEnvelope = () => readFileSync(0, "utf8")) {
|
|
208
|
-
const write = (output) => {
|
|
209
|
-
process.stdout.write(`${JSON.stringify(output)}\n`);
|
|
210
|
-
};
|
|
211
|
-
const usageFailure = (detail) => {
|
|
212
|
-
write({ ok: false, reason: "usage" });
|
|
213
|
-
process.stderr.write(`${detail}\n${USAGE}\n`);
|
|
214
|
-
return 0;
|
|
215
|
-
};
|
|
216
|
-
if (argv.length !== 2)
|
|
217
|
-
return usageFailure("expected exactly two arguments");
|
|
218
|
-
const event = ACTIVITY_HOOK_EVENTS.includes(argv[0]) ? argv[0] : null;
|
|
219
|
-
if (event === null)
|
|
220
|
-
return usageFailure(`unknown hook event "${argv[0]}"`);
|
|
221
|
-
const eventAt = parseEventAtArg(argv[1]);
|
|
222
|
-
if (typeof eventAt === "object")
|
|
223
|
-
return usageFailure(eventAt.error);
|
|
224
|
-
let envelope;
|
|
225
|
-
try {
|
|
226
|
-
envelope = JSON.parse(readEnvelope());
|
|
227
|
-
}
|
|
228
|
-
catch {
|
|
229
|
-
write({ ok: false, reason: "bad_envelope" });
|
|
230
|
-
process.stderr.write("the hook envelope on stdin is not JSON\n");
|
|
231
|
-
return 0;
|
|
232
|
-
}
|
|
233
|
-
const mapped = activityChangeFromHook(event, envelope, Date.parse(eventAt));
|
|
234
|
-
if (!mapped.ok) {
|
|
235
|
-
write(mapped);
|
|
236
|
-
process.stderr.write(`${event} envelope reports no activity: ${mapped.reason}\n`);
|
|
237
|
-
return 0;
|
|
238
|
-
}
|
|
239
|
-
// The connection record is this session's: an envelope for ANOTHER session must never
|
|
240
|
-
// be reported under it.
|
|
241
|
-
const envelopeSession = envelope.session_id;
|
|
242
|
-
if (envelopeSession !== undefined && envelopeSession !== env.CLAUDE_CODE_SESSION_ID) {
|
|
243
|
-
write({ ok: false, reason: "session_mismatch" });
|
|
244
|
-
process.stderr.write("the envelope's session_id is not CLAUDE_CODE_SESSION_ID\n");
|
|
245
|
-
return 0;
|
|
246
|
-
}
|
|
247
|
-
const resolved = await resolveSessionOptions(env, resolveFrom, USAGE);
|
|
248
|
-
if (!resolved.ok) {
|
|
249
|
-
// A session that never `plan_connect`-ed has no connection record: the ordinary
|
|
250
|
-
// "not on a plan" case, silent for the plugin.
|
|
251
|
-
write(resolved.output);
|
|
252
|
-
process.stderr.write(`${resolved.detail}\n`);
|
|
253
|
-
return 0;
|
|
254
|
-
}
|
|
255
|
-
write(await postActivity(resolved.options, mapped.change, {
|
|
256
|
-
fetch: (input, init) => fetch(input, init),
|
|
257
|
-
requestTimeoutMs: ACTIVITY_REQUEST_TIMEOUT_MS,
|
|
258
|
-
}));
|
|
259
|
-
return 0;
|
|
260
|
-
}
|
package/dist/background-work.js
DELETED
|
@@ -1,162 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `danx-dashboard-mcp background-work <count|clear> <event-at>` — the ONE-SHOT client of
|
|
3
|
-
* `PUT /api/plan-sessions/me/background-work` (DX-3367).
|
|
4
|
-
*
|
|
5
|
-
* WHO RUNS IT. The danxbot Claude Code plugin's `Stop` / `SubagentStop` /
|
|
6
|
-
* `SessionStart` / `StopFailure` hooks (`danxbot/hooks/hooks.json` in
|
|
7
|
-
* claude-plugins), via the SAME `npx -y @thehammer/danx-dashboard-mcp`
|
|
8
|
-
* MECHANISM the plugin already uses for `plan_events_wait` — no separate install, no
|
|
9
|
-
* deep import of this package's `dist/` internals.
|
|
10
|
-
* DX-3367 fix-up — that does NOT mean the SAME PINNED VERSION: `npx -y
|
|
11
|
-
* <pkg>` resolves whatever version string that ONE hook's own invocation
|
|
12
|
-
* names, and this plugin's hooks pin each call independently (verified
|
|
13
|
-
* against production drift, comment 7102 on the card: the background-work
|
|
14
|
-
* hooks pinned `0.1.136` while `plan_events_wait` was still pinned `0.1.95`) — a
|
|
15
|
-
* version bump to one hook's pin never moves the others. The plugin decides
|
|
16
|
-
* WHAT to report (parsing `Stop`/`SubagentStop`'s own `background_tasks`
|
|
17
|
-
* snapshot, counting only `shell` / `subagent` / `workflow` entries,
|
|
18
|
-
* sending `clear` on `SessionStart`/`StopFailure`); this module only
|
|
19
|
-
* delivers the report.
|
|
20
|
-
*
|
|
21
|
-
* THE CONTRACT (binding, so a future plugin build against this module can
|
|
22
|
-
* trust it without re-reading this file):
|
|
23
|
-
* - print EXACTLY ONE JSON line on stdout, and ALWAYS exit 0 — success or
|
|
24
|
-
* failure alike, because a hook must stay silent rather than spam the
|
|
25
|
-
* session's own turn with a stack trace;
|
|
26
|
-
* - a session with no connection record (never `plan_connect`-ed, e.g. an
|
|
27
|
-
* operator session that never joined a plan) is a silent, expected
|
|
28
|
-
* no-op — `{"ok":false,"reason":"no_connection_record"}` — the plugin
|
|
29
|
-
* hook simply does not forward anything further in this case, exactly
|
|
30
|
-
* what "no-op when the session isn't plan-connected" means in practice;
|
|
31
|
-
* - on success, `{"ok":true,"count":<number|null>,"applied":<boolean>}`
|
|
32
|
-
* echoes the SUBMITTED `count`; `applied` is the server's answer to
|
|
33
|
-
* whether it was stored (DX-3676: `false` for an out-of-order report
|
|
34
|
-
* older than the one already on file — the caller must not treat it as
|
|
35
|
-
* the dashboard's current count);
|
|
36
|
-
* - DX-3676 — `<event-at>` is the ISO-8601 UTC time the reporting HOOK
|
|
37
|
-
* fired, captured by the caller, never this process's own clock: the
|
|
38
|
-
* hooks are async and can finish out of order, and the server orders
|
|
39
|
-
* reports by this time;
|
|
40
|
-
* - human-readable diagnostics go to stderr only, never stdout.
|
|
41
|
-
*
|
|
42
|
-
* THE CREDENTIAL — the SAME resolver `plan_events_wait` uses, via `resolveDashboardAuth`
|
|
43
|
-
* (the relay): the dashboard URL, credential source, and credential come
|
|
44
|
-
* from the connection record this session's OWN danx-dashboard MCP server
|
|
45
|
-
* wrote on its last successful `plan_connect` (`session-connection.ts`),
|
|
46
|
-
* never from this process's own ambient env (DX-2862).
|
|
47
|
-
*
|
|
48
|
-
* A HARD TIMEOUT — `BACKGROUND_WORK_REQUEST_TIMEOUT_MS`. A `Stop` hook
|
|
49
|
-
* blocks the session's turn boundary, so this call must never hang: a slow
|
|
50
|
-
* or wedged dashboard is `{ok:false, reason:"timeout"}`, not a stuck hook.
|
|
51
|
-
*
|
|
52
|
-
* THE REQUEST/RUN SCAFFOLDING (`one-shot-session-request.ts`, DX-3367
|
|
53
|
-
* fix-up) is shared, general-purpose plumbing for any one-shot subcommand
|
|
54
|
-
* shaped like this one — not maintained as an independent copy of the
|
|
55
|
-
* authenticated-fetch-with-timeout logic or the session/credential
|
|
56
|
-
* resolution boilerplate.
|
|
57
|
-
*/
|
|
58
|
-
import { resolveSessionOptions, sendSessionRequest, tryParseJsonBody } from "./one-shot-session-request.js";
|
|
59
|
-
export const BACKGROUND_WORK_SUBCOMMAND = "background-work";
|
|
60
|
-
export const BACKGROUND_WORK_PATH = "/api/plan-sessions/me/background-work";
|
|
61
|
-
/** How long this one-shot call may take before it counts as a timeout. */
|
|
62
|
-
export const BACKGROUND_WORK_REQUEST_TIMEOUT_MS = 5_000;
|
|
63
|
-
const USAGE = `usage: CLAUDE_CODE_SESSION_ID=<session-id> danx-dashboard-mcp ${BACKGROUND_WORK_SUBCOMMAND} <non-negative-integer|clear> <event-at ISO-8601 UTC>`;
|
|
64
|
-
/**
|
|
65
|
-
* Parse the ONE positional argument: `clear`, or a non-negative integer
|
|
66
|
-
* string. Anything else is a usage error — never guessed or floored.
|
|
67
|
-
*/
|
|
68
|
-
export function parseCountArg(raw) {
|
|
69
|
-
if (raw === "clear")
|
|
70
|
-
return null;
|
|
71
|
-
if (!/^\d+$/.test(raw))
|
|
72
|
-
return { error: `count must be "clear" or a non-negative integer, got "${raw}"` };
|
|
73
|
-
return Number.parseInt(raw, 10);
|
|
74
|
-
}
|
|
75
|
-
/** DX-3676 — an ISO-8601 UTC instant, as `Date#toISOString` writes it (the server accepts exactly this). */
|
|
76
|
-
const ISO_UTC_INSTANT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,3})?Z$/;
|
|
77
|
-
/** Parse the event-time argument (DX-3676). Never substituted with this process's clock. */
|
|
78
|
-
export function parseEventAtArg(raw) {
|
|
79
|
-
if (!ISO_UTC_INSTANT.test(raw) || Number.isNaN(Date.parse(raw))) {
|
|
80
|
-
return { error: `event-at must be an ISO-8601 UTC timestamp (e.g. 2026-09-30T00:00:00.000Z), got "${raw}"` };
|
|
81
|
-
}
|
|
82
|
-
return raw;
|
|
83
|
-
}
|
|
84
|
-
/**
|
|
85
|
-
* One authenticated PUT, with a hard timeout, classified into the output
|
|
86
|
-
* shape this subcommand always prints — never a throw, never a non-2xx
|
|
87
|
-
* bubbling past this function.
|
|
88
|
-
*
|
|
89
|
-
* DX-3367 fix-up — STATUS IS CHECKED BEFORE THE BODY IS EVER PARSED. A 5xx
|
|
90
|
-
* (or any other non-2xx) response can carry an HTML error page instead of
|
|
91
|
-
* JSON; parsing first would misreport that as `bad_response` when the true
|
|
92
|
-
* classification is `http_error`. This subcommand has no status that needs
|
|
93
|
-
* the BODY inspected to classify (no `409 session_not_connected`
|
|
94
|
-
* equivalent), so status alone decides whether a body read is even
|
|
95
|
-
* attempted.
|
|
96
|
-
*/
|
|
97
|
-
export async function putBackgroundWork(options, report, deps) {
|
|
98
|
-
const sent = await sendSessionRequest(options, BACKGROUND_WORK_PATH, { method: "PUT", body: JSON.stringify({ count: report.count, eventAt: report.eventAt }) }, deps);
|
|
99
|
-
if ("reason" in sent)
|
|
100
|
-
return sent;
|
|
101
|
-
const { status, text } = sent;
|
|
102
|
-
if (status === 401 || status === 403) {
|
|
103
|
-
return { ok: false, reason: "unauthorized" };
|
|
104
|
-
}
|
|
105
|
-
if (status < 200 || status >= 300) {
|
|
106
|
-
return { ok: false, reason: "http_error" };
|
|
107
|
-
}
|
|
108
|
-
const parsed = tryParseJsonBody(text);
|
|
109
|
-
if (!parsed.ok)
|
|
110
|
-
return { ok: false, reason: "bad_response" };
|
|
111
|
-
const body = parsed.body;
|
|
112
|
-
if (typeof body !== "object" ||
|
|
113
|
-
body === null ||
|
|
114
|
-
!("count" in body) ||
|
|
115
|
-
(body.count !== null && typeof body.count !== "number") ||
|
|
116
|
-
typeof body.applied !== "boolean") {
|
|
117
|
-
return { ok: false, reason: "bad_response" };
|
|
118
|
-
}
|
|
119
|
-
const shaped = body;
|
|
120
|
-
return { ok: true, count: shaped.count, applied: shaped.applied };
|
|
121
|
-
}
|
|
122
|
-
/** The bin's `background-work` subcommand, wired to the real process. Always resolves to exit code 0. */
|
|
123
|
-
export async function runBackgroundWorkCommand(argv, env = process.env,
|
|
124
|
-
/** Test seam: the home the session's connection record is read from. */
|
|
125
|
-
resolveFrom = {}) {
|
|
126
|
-
const write = (output) => {
|
|
127
|
-
process.stdout.write(`${JSON.stringify(output)}\n`);
|
|
128
|
-
};
|
|
129
|
-
if (argv.length !== 2) {
|
|
130
|
-
write({ ok: false, reason: "usage" });
|
|
131
|
-
process.stderr.write(`${USAGE}\n`);
|
|
132
|
-
return 0;
|
|
133
|
-
}
|
|
134
|
-
const parsed = parseCountArg(argv[0]);
|
|
135
|
-
if (typeof parsed === "object" && parsed !== null && "error" in parsed) {
|
|
136
|
-
write({ ok: false, reason: "usage" });
|
|
137
|
-
process.stderr.write(`${parsed.error}\n${USAGE}\n`);
|
|
138
|
-
return 0;
|
|
139
|
-
}
|
|
140
|
-
const count = parsed;
|
|
141
|
-
const eventAt = parseEventAtArg(argv[1]);
|
|
142
|
-
if (typeof eventAt === "object") {
|
|
143
|
-
write({ ok: false, reason: "usage" });
|
|
144
|
-
process.stderr.write(`${eventAt.error}\n${USAGE}\n`);
|
|
145
|
-
return 0;
|
|
146
|
-
}
|
|
147
|
-
const resolved = await resolveSessionOptions(env, resolveFrom, USAGE);
|
|
148
|
-
if (!resolved.ok) {
|
|
149
|
-
// A session that never `plan_connect`-ed has no connection record — this
|
|
150
|
-
// is the ORDINARY "not plan-connected" case, not an error the plugin
|
|
151
|
-
// should surface: it simply forwards nothing further this turn.
|
|
152
|
-
write(resolved.output);
|
|
153
|
-
process.stderr.write(`${resolved.detail}\n`);
|
|
154
|
-
return 0;
|
|
155
|
-
}
|
|
156
|
-
const output = await putBackgroundWork(resolved.options, { count, eventAt }, {
|
|
157
|
-
fetch: (input, init) => fetch(input, init),
|
|
158
|
-
requestTimeoutMs: BACKGROUND_WORK_REQUEST_TIMEOUT_MS,
|
|
159
|
-
});
|
|
160
|
-
write(output);
|
|
161
|
-
return 0;
|
|
162
|
-
}
|
|
@@ -1,105 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* DX-3367 fix-up — shared scaffolding for a "one-shot session request"
|
|
3
|
-
* subcommand. Extracted when `plan-state` (`GET /api/plan-sessions/me/turn-state`,
|
|
4
|
-
* removed by DX-3689 — it never had a caller) and `background-work`
|
|
5
|
-
* (`PUT /api/plan-sessions/me/background-work`) turned out to duplicate this
|
|
6
|
-
* exact shape independently (`fetchTurnState` / `putBackgroundWork`,
|
|
7
|
-
* `runPlanStateCommand` / `runBackgroundWorkCommand`). This module is the ONE
|
|
8
|
-
* place that shape lives now; a future one-shot subcommand extends it
|
|
9
|
-
* instead of copying `background-work.ts` again.
|
|
10
|
-
*
|
|
11
|
-
* Two pieces:
|
|
12
|
-
* - `sendSessionRequest` — the authenticated HTTP call itself: bearer +
|
|
13
|
-
* session header, a hard abort-timeout, and network-level failure
|
|
14
|
-
* classification (`timeout` / `request_failed`). Returns the raw
|
|
15
|
-
* `{status, text}` UNPARSED — each caller decides for itself which
|
|
16
|
-
* statuses need the body inspected and which don't, so a non-2xx status
|
|
17
|
-
* with an unparseable (e.g. HTML) body is classified from the status
|
|
18
|
-
* alone rather than colliding with "the JSON didn't parse"
|
|
19
|
-
* (`bad_response`).
|
|
20
|
-
* - `resolveSessionOptions` — session-id + credential resolution via
|
|
21
|
-
* `resolveDashboardAuth`, with `SessionStartError` narrowed by
|
|
22
|
-
* `instanceof` rather than an unchecked `as SessionStartError` cast, so
|
|
23
|
-
* an error of some OTHER shape (a bug in the resolver, a thrown
|
|
24
|
-
* non-Error) still comes back as a reported reason instead of a value
|
|
25
|
-
* whose `.reason`/`.message`/`.fix` fields are silently `undefined`.
|
|
26
|
-
*/
|
|
27
|
-
import { boundRequest } from "./request-bound.js";
|
|
28
|
-
import { SESSION_ID_HEADER, resolveDashboardAuth, SessionStartError } from "./session-options.js";
|
|
29
|
-
/**
|
|
30
|
-
* One authenticated HTTP call to this session's dashboard, with a hard
|
|
31
|
-
* timeout. Never throws — a network failure or an abort is reported as
|
|
32
|
-
* `OneShotFailure`, exactly like any other classified failure the caller
|
|
33
|
-
* might produce from the status/body it gets back on success.
|
|
34
|
-
*/
|
|
35
|
-
export async function sendSessionRequest(options, path, init, deps) {
|
|
36
|
-
const bound = boundRequest(deps.requestTimeoutMs, deps.signal);
|
|
37
|
-
try {
|
|
38
|
-
try {
|
|
39
|
-
const response = await deps.fetch(`${options.dashboardUrl}${path}`, {
|
|
40
|
-
method: init.method,
|
|
41
|
-
headers: {
|
|
42
|
-
Authorization: `Bearer ${options.token}`,
|
|
43
|
-
Accept: "application/json",
|
|
44
|
-
...(init.body !== undefined ? { "Content-Type": "application/json" } : {}),
|
|
45
|
-
[SESSION_ID_HEADER]: options.sessionId,
|
|
46
|
-
},
|
|
47
|
-
body: init.body,
|
|
48
|
-
signal: bound.signal,
|
|
49
|
-
});
|
|
50
|
-
const text = await response.text();
|
|
51
|
-
return { status: response.status, text };
|
|
52
|
-
}
|
|
53
|
-
catch {
|
|
54
|
-
// DX-3367 fix-up — the caught value is never inspected (an aborted
|
|
55
|
-
// fetch's own rejection shape varies by runtime), so binding it to a
|
|
56
|
-
// name the code never reads was dead weight the linter never caught.
|
|
57
|
-
return { ok: false, reason: bound.aborted() ? "timeout" : "request_failed" };
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
finally {
|
|
61
|
-
bound.release();
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
/** `JSON.parse`, classified rather than thrown — the ONE shared parse point
|
|
65
|
-
* every caller uses once it has decided (from `status`) that the body is
|
|
66
|
-
* worth reading at all. */
|
|
67
|
-
export function tryParseJsonBody(text) {
|
|
68
|
-
try {
|
|
69
|
-
return { ok: true, body: text === "" ? null : JSON.parse(text) };
|
|
70
|
-
}
|
|
71
|
-
catch {
|
|
72
|
-
return { ok: false };
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
/**
|
|
76
|
-
* Resolve this process's session id + dashboard credential, the identical
|
|
77
|
-
* first step every one-shot subcommand's `runXCommand` takes before it can
|
|
78
|
-
* make its own request. `detail` is the stderr-only human-readable line the
|
|
79
|
-
* caller prints alongside `output` — never part of the stdout JSON contract.
|
|
80
|
-
*/
|
|
81
|
-
export async function resolveSessionOptions(env, resolveFrom, usage) {
|
|
82
|
-
const sessionId = env.CLAUDE_CODE_SESSION_ID;
|
|
83
|
-
if (!sessionId) {
|
|
84
|
-
return { ok: false, output: { ok: false, reason: "no_session_id" }, detail: usage };
|
|
85
|
-
}
|
|
86
|
-
try {
|
|
87
|
-
const options = resolveDashboardAuth(sessionId, env, resolveFrom);
|
|
88
|
-
return { ok: true, options };
|
|
89
|
-
}
|
|
90
|
-
catch (err) {
|
|
91
|
-
// DX-3367 fix-up — `instanceof`, never an unchecked `as SessionStartError`
|
|
92
|
-
// cast: `resolveDashboardAuth` throws exactly `SessionStartError` today,
|
|
93
|
-
// but a cast reports a fabricated reason/message/fix (all `undefined`)
|
|
94
|
-
// for any OTHER thrown shape instead of a real one, silently.
|
|
95
|
-
if (err instanceof SessionStartError) {
|
|
96
|
-
return {
|
|
97
|
-
ok: false,
|
|
98
|
-
output: { ok: false, reason: err.reason },
|
|
99
|
-
detail: `${err.reason}: ${err.message}. Fix: ${err.fix}.`,
|
|
100
|
-
};
|
|
101
|
-
}
|
|
102
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
103
|
-
return { ok: false, output: { ok: false, reason: "start_failed" }, detail: `start_failed: ${message}` };
|
|
104
|
-
}
|
|
105
|
-
}
|
package/dist/ready-cards.js
DELETED
|
@@ -1,176 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `danx-dashboard-mcp ready-cards` — DX-4534. The ONE-SHOT client the claude-plugins `danxbot`
|
|
3
|
-
* plugin's Stop hook runs for a plan-connected session, to learn which of its plan's cards
|
|
4
|
-
* anyone could pick up right now.
|
|
5
|
-
*
|
|
6
|
-
* WHY IT EXISTS. An orchestrating plan session must keep every unblocked card moving, and
|
|
7
|
-
* nothing enforced it: on 2026-10-04 one builder ran while two readied, unblocked cards sat
|
|
8
|
-
* untouched, because the session only looked for work when a builder reported back. The Stop
|
|
9
|
-
* hook turns that check into something the harness runs. This is its read.
|
|
10
|
-
*
|
|
11
|
-
* NO DASHBOARD ROUTE IS ADDED. It composes existing reads on the session's own credential:
|
|
12
|
-
* 1. `GET /api/plans/mine` answers the session's plan (`id`) and the boards its cards live on
|
|
13
|
-
* (`boards`) in one read; a session on no plan is a 409 `session_not_connected`.
|
|
14
|
-
* 2. `GET /api/issues?board=<b>` with `filter.plan_id` + `filter.status_derived=ToDo` and the
|
|
15
|
-
* judged fields, once per board: a plan can span boards (PLAN-11 spans danxbot and
|
|
16
|
-
* gpt-manager), and an issues read without `board` sees only the default one.
|
|
17
|
-
* Server-side filtering narrows the read; `isReadyCard` is the whole definition of "ready".
|
|
18
|
-
*
|
|
19
|
-
* READY means a card a dispatcher or a session could claim this minute: a Story, Bug or Chore
|
|
20
|
-
* (Epic and Feature are containers, a Task is a leaf nobody dispatches), derived status ToDo,
|
|
21
|
-
* nobody holding it (no assigned agent, no live dispatch), and nothing in its way: no hold
|
|
22
|
-
* (`blocked`), no open problem (a human is needed), no unmet `depends_on`, no active
|
|
23
|
-
* `conflict_on`. A card whose reason it cannot run is recorded on it therefore stops counting,
|
|
24
|
-
* which is how the hook's "dispatch it, or record why not" instruction is satisfiable.
|
|
25
|
-
*
|
|
26
|
-
* THE CONTRACT, which the hook reads by exit code:
|
|
27
|
-
* - exit 0, one JSON line on stdout: `{"plan_id": <n|null>, "cards": [{"id","title"}...]}`;
|
|
28
|
-
* `plan_id` null (the dashboard has the session on no plan) comes with an empty list.
|
|
29
|
-
* - exit 1, one reason on stderr, nothing on stdout: credential, network, non-2xx, bad shape.
|
|
30
|
-
*/
|
|
31
|
-
import { resolveSessionOptions, sendSessionRequest, tryParseJsonBody } from "./one-shot-session-request.js";
|
|
32
|
-
export const READY_CARDS_SUBCOMMAND = "ready-cards";
|
|
33
|
-
export const READY_CARDS_REQUEST_TIMEOUT_MS = 5_000;
|
|
34
|
-
/** `GET /api/issues` page size (its maximum): a plan's ToDo cards never approach it. */
|
|
35
|
-
const CARDS_PAGE_SIZE = 1000;
|
|
36
|
-
/** The dispatchable card types: Epic and Feature are containers, a Task is a leaf nobody dispatches. */
|
|
37
|
-
const DISPATCHABLE_TYPES = new Set(["Story", "Bug", "Chore"]);
|
|
38
|
-
/**
|
|
39
|
-
* The fields `isReadyCard` judges beyond the always-returned row, each with the check a row's value
|
|
40
|
-
* must pass: the one list the request's `fields` tree and the row parse both come from.
|
|
41
|
-
*/
|
|
42
|
-
const JUDGED_FIELDS = {
|
|
43
|
-
dispatch: (v) => v !== undefined,
|
|
44
|
-
blocked: (v) => v !== undefined,
|
|
45
|
-
open_problem_count: (v) => typeof v === "number",
|
|
46
|
-
waiting_on: (v) => typeof v === "boolean",
|
|
47
|
-
conflict_on_active_count: (v) => typeof v === "number",
|
|
48
|
-
};
|
|
49
|
-
const CARD_FIELDS = Object.fromEntries(Object.keys(JUDGED_FIELDS).map((name) => [name, true]));
|
|
50
|
-
const USAGE = `usage: CLAUDE_CODE_SESSION_ID=<session-id> danx-dashboard-mcp ${READY_CARDS_SUBCOMMAND}`;
|
|
51
|
-
/** The row, or null when any field `isReadyCard` judges is missing or the wrong type: a card never counts on a guess. */
|
|
52
|
-
function parseCardRow(raw) {
|
|
53
|
-
if (typeof raw !== "object" || raw === null)
|
|
54
|
-
return null;
|
|
55
|
-
const r = raw;
|
|
56
|
-
const assigned = r.assigned_agent;
|
|
57
|
-
if (typeof r.id !== "string" || typeof r.type !== "string" || typeof r.title !== "string")
|
|
58
|
-
return null;
|
|
59
|
-
if (!(assigned === null || typeof assigned === "string"))
|
|
60
|
-
return null;
|
|
61
|
-
if (!Object.entries(JUDGED_FIELDS).every(([name, valid]) => valid(r[name])))
|
|
62
|
-
return null;
|
|
63
|
-
return r;
|
|
64
|
-
}
|
|
65
|
-
/** Whether a ToDo card could be claimed right now (see the module banner). */
|
|
66
|
-
export function isReadyCard(card) {
|
|
67
|
-
return (DISPATCHABLE_TYPES.has(card.type) &&
|
|
68
|
-
card.assigned_agent === null &&
|
|
69
|
-
card.dispatch === null &&
|
|
70
|
-
card.blocked === null &&
|
|
71
|
-
card.open_problem_count === 0 &&
|
|
72
|
-
!card.waiting_on &&
|
|
73
|
-
card.conflict_on_active_count === 0);
|
|
74
|
-
}
|
|
75
|
-
function classifyStatus(status) {
|
|
76
|
-
if (status === 401 || status === 403)
|
|
77
|
-
return "unauthorized";
|
|
78
|
-
if (status === 404)
|
|
79
|
-
return "not_found";
|
|
80
|
-
if (status < 200 || status >= 300)
|
|
81
|
-
return "http_error";
|
|
82
|
-
return null;
|
|
83
|
-
}
|
|
84
|
-
/** The session's plan and its boards, `null` when the dashboard has the session on no plan, or a failure. */
|
|
85
|
-
async function readSessionPlan(options, deps) {
|
|
86
|
-
const sent = await sendSessionRequest(options, "/api/plans/mine", { method: "GET" }, deps);
|
|
87
|
-
if ("ok" in sent)
|
|
88
|
-
return sent;
|
|
89
|
-
const parsed = tryParseJsonBody(sent.text);
|
|
90
|
-
const body = parsed.ok ? parsed.body : null;
|
|
91
|
-
// A session on no plan is the ordinary answer, not a fault: a 409 `session_not_connected`.
|
|
92
|
-
if (sent.status === 409 && body?.error === "session_not_connected")
|
|
93
|
-
return { ok: true, plan: null };
|
|
94
|
-
const failed = classifyStatus(sent.status);
|
|
95
|
-
if (failed !== null)
|
|
96
|
-
return { ok: false, reason: failed };
|
|
97
|
-
if (body === null ||
|
|
98
|
-
typeof body.id !== "number" ||
|
|
99
|
-
!Array.isArray(body.boards) ||
|
|
100
|
-
!body.boards.every((b) => typeof b === "string")) {
|
|
101
|
-
return { ok: false, reason: "bad_response" };
|
|
102
|
-
}
|
|
103
|
-
return { ok: true, plan: { planId: body.id, boards: body.boards } };
|
|
104
|
-
}
|
|
105
|
-
/** The plan's ready cards on one board. */
|
|
106
|
-
async function readBoardReadyCards(options, deps, planId, board) {
|
|
107
|
-
const filter = encodeURIComponent(JSON.stringify({ plan_id: planId, status_derived: "ToDo" }));
|
|
108
|
-
const fields = encodeURIComponent(JSON.stringify(CARD_FIELDS));
|
|
109
|
-
const sent = await sendSessionRequest(options, `/api/issues?board=${encodeURIComponent(board)}&filter=${filter}&fields=${fields}&limit=${CARDS_PAGE_SIZE}&sort=priority`, { method: "GET" }, deps);
|
|
110
|
-
if ("ok" in sent)
|
|
111
|
-
return sent;
|
|
112
|
-
const failed = classifyStatus(sent.status);
|
|
113
|
-
if (failed !== null)
|
|
114
|
-
return { ok: false, reason: failed };
|
|
115
|
-
const parsed = tryParseJsonBody(sent.text);
|
|
116
|
-
const body = parsed.ok ? parsed.body : null;
|
|
117
|
-
if (body === null || !Array.isArray(body.issues) || typeof body.total !== "number")
|
|
118
|
-
return { ok: false, reason: "bad_response" };
|
|
119
|
-
// A truncated read would silently miss ready cards: fail loudly instead.
|
|
120
|
-
if (body.total > body.issues.length)
|
|
121
|
-
return { ok: false, reason: "too_many_cards" };
|
|
122
|
-
const cards = [];
|
|
123
|
-
for (const raw of body.issues) {
|
|
124
|
-
const row = parseCardRow(raw);
|
|
125
|
-
if (row === null)
|
|
126
|
-
return { ok: false, reason: "bad_response" };
|
|
127
|
-
if (isReadyCard(row))
|
|
128
|
-
cards.push({ id: row.id, title: row.title });
|
|
129
|
-
}
|
|
130
|
-
return { ok: true, cards };
|
|
131
|
-
}
|
|
132
|
-
/** One authenticated read of the session's plan's ready cards, across every board the plan spans. Never throws. */
|
|
133
|
-
export async function fetchReadyCards(options, deps) {
|
|
134
|
-
const read = await readSessionPlan(options, deps);
|
|
135
|
-
if (!read.ok)
|
|
136
|
-
return read;
|
|
137
|
-
if (read.plan === null)
|
|
138
|
-
return { ok: true, planId: null, cards: [] };
|
|
139
|
-
const { planId } = read.plan;
|
|
140
|
-
const perBoard = await Promise.all(read.plan.boards.map((board) => readBoardReadyCards(options, deps, planId, board)));
|
|
141
|
-
const cards = [];
|
|
142
|
-
for (const onBoard of perBoard) {
|
|
143
|
-
if (!onBoard.ok)
|
|
144
|
-
return onBoard;
|
|
145
|
-
cards.push(...onBoard.cards);
|
|
146
|
-
}
|
|
147
|
-
return { ok: true, planId: read.plan.planId, cards };
|
|
148
|
-
}
|
|
149
|
-
/** The bin's `ready-cards` subcommand, wired to the real process. */
|
|
150
|
-
export async function runReadyCardsCommand(argv, env = process.env,
|
|
151
|
-
/** Test seams: the home the connection record is read from, and the HTTP client. */
|
|
152
|
-
seams = {}) {
|
|
153
|
-
if (argv.length !== 0) {
|
|
154
|
-
process.stderr.write(`unexpected arguments\n${USAGE}\n`);
|
|
155
|
-
return 1;
|
|
156
|
-
}
|
|
157
|
-
// `resolveSessionOptions` words a missing session id as the usage text alone; every stderr line here starts with its reason.
|
|
158
|
-
if (!env.CLAUDE_CODE_SESSION_ID) {
|
|
159
|
-
process.stderr.write(`no_session_id: CLAUDE_CODE_SESSION_ID is not set\n${USAGE}\n`);
|
|
160
|
-
return 1;
|
|
161
|
-
}
|
|
162
|
-
const resolved = await resolveSessionOptions(env, { home: seams.home }, USAGE);
|
|
163
|
-
if (!resolved.ok) {
|
|
164
|
-
// `detail` names its reason except for a missing session id, where it is only the usage text.
|
|
165
|
-
const named = resolved.detail.startsWith(`${resolved.output.reason}:`);
|
|
166
|
-
process.stderr.write(named ? `${resolved.detail}\n` : `${resolved.output.reason}: CLAUDE_CODE_SESSION_ID is not set\n${resolved.detail}\n`);
|
|
167
|
-
return 1;
|
|
168
|
-
}
|
|
169
|
-
const output = await fetchReadyCards(resolved.options, seams.deps ?? { fetch: (input, init) => fetch(input, init), requestTimeoutMs: READY_CARDS_REQUEST_TIMEOUT_MS });
|
|
170
|
-
if (!output.ok) {
|
|
171
|
-
process.stderr.write(`${output.reason}: could not read the plan's ready cards from the dashboard\n`);
|
|
172
|
-
return 1;
|
|
173
|
-
}
|
|
174
|
-
process.stdout.write(`${JSON.stringify({ plan_id: output.planId, cards: output.cards })}\n`);
|
|
175
|
-
return 0;
|
|
176
|
-
}
|