@thehammer/danx-dashboard-mcp 0.1.286 → 0.1.289

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,9 +1,9 @@
1
1
  /**
2
- * DX-2862 / DX-4233 - how a one-shot client of the dashboard (`activity`, `background-work`, `ready-cards`, and the
3
- * predecessor lookup of `restart_notice`) learns WHICH dashboard to call and AS WHOM: the connection record this session's own MCP server wrote when
4
- * it connected a plan (`session-connection.ts`), resolved through the same credential resolver the server used, and refused
5
- * unless it fingerprints identically. (The in-process relay behind `plan_events_wait` needs none of this: it lives inside the
6
- * server that holds the credential.)
2
+ * DX-2862 / DX-4233 - how `restart_notice` (`restart-notice.ts`, this file's only caller) learns which dashboard to call and
3
+ * as whom when it asks about an earlier session. It reads the connection record that session's own MCP server wrote when it
4
+ * connected a plan (`session-connection.ts`), resolves the credential the record names through the same resolver the server
5
+ * used, and refuses unless it fingerprints identically. The in-process relay behind `plan_events_wait` needs none of this: it
6
+ * lives inside the server that holds the credential.
7
7
  */
8
8
  import { homedir } from "node:os";
9
9
  import { credentialFingerprint, describeCredentialSource, readCredential } from "./credential.js";
@@ -52,7 +52,7 @@ export function resolveDashboardAuth(sessionId, env, options = {}) {
52
52
  const fingerprint = credentialFingerprint(token);
53
53
  if (fingerprint !== record.fingerprint) {
54
54
  throw new SessionStartError("credential_mismatch", `${describeCredentialSource(record.source)} now yields credential ${fingerprint}, but this session's ` +
55
- `danx-dashboard MCP server is using ${record.fingerprint} — a one-shot call would authenticate as somebody else`, fixForStop("credential_mismatch"));
55
+ `danx-dashboard MCP server is using ${record.fingerprint} — a lookup would authenticate as somebody else`, fixForStop("credential_mismatch"));
56
56
  }
57
57
  return { dashboardUrl: record.dashboardUrl.replace(/\/+$/, ""), token, sessionId };
58
58
  }
@@ -133,9 +133,8 @@ export async function mintTicket(options, deps) {
133
133
  if (outcome.kind === "transient")
134
134
  return outcome;
135
135
  const { status, text, body } = outcome;
136
- if (status === 409 && errorCodeOf(body) === "session_not_connected") {
137
- return { kind: "terminal", reason: "not_connected", detail: "the session is not connected to a plan" };
138
- }
136
+ // DX-4882: a session on no plan is minted a ticket like any other (its stream carries its account's pacing and resume messages), so
137
+ // `409 session_not_connected` is no longer an answer the mint gives.
139
138
  // DX-3028 (AC2) — matched BEFORE the generic mint_refused fallback below,
140
139
  // which every other 409 falls into: this session id belongs to a worker
141
140
  // dispatch (`SessionIsWorkerError`, the same shape the heartbeat route
@@ -219,28 +218,6 @@ export async function fetchPlanInventory(options, deps) {
219
218
  const b = body;
220
219
  return { kind: "ok", boards: b.boards, cardCount: b.card_count };
221
220
  }
222
- /**
223
- * DX-2920 — a stream admission refusal (`listen.ts`'s STRUCTURED `refused`
224
- * outcome — `{status, errorCode, body}`, parsed once in `connectOnce`) whose
225
- * shape matches one the mint route already gives its OWN name to: `409
226
- * session_not_connected`, the SAME shape the mint route uses for "no plan
227
- * connected" (`plan-session-stream.ts`, DX-2863 review finding M2). A session
228
- * with no plan connected is the identical terminal state whether the
229
- * dashboard says so at mint or at the stream's own admission check, and the
230
- * session should see ONE remedy either way.
231
- *
232
- * Classifies by STATUS + ERROR CODE, never by re-deriving them from the
233
- * flattened `detail` string — the second-source-of-truth shape this card's fix
234
- * round closed (the regex-based `isSessionNotConnectedRefusal` it replaced).
235
- */
236
- export function classifyAdmissionRefusal(refusal) {
237
- if (refusal === undefined)
238
- return null;
239
- if (refusal.status === 409 && refusal.errorCode === "session_not_connected") {
240
- return { reason: "not_connected", detail: "the session is not connected to a plan" };
241
- }
242
- return null;
243
- }
244
221
  /**
245
222
  * The one real `scheduleInterval`: a `setInterval` and its canceler, for the relay's heartbeat. Unref'd: a heartbeat never keeps
246
223
  * the MCP server alive after its session ended.
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * DX-4233 - the remedy a session is shown for each way its dashboard event stream can stop, in ONE place.
3
3
  *
4
- * The client that ends in these texts is the in-process relay behind `plan_events_wait` (`plan-events-relay.ts`); the one-shot
5
- * subcommands use `fixForStop` for a start they cannot make. Pure data and one function with no imports.
4
+ * The client that ends in these texts is the in-process relay behind `plan_events_wait` (`plan-events-relay.ts`); `session-options.ts` uses
5
+ * `fixForStop` for a start it cannot make. Pure data and one function with no imports.
6
6
  */
7
7
  /**
8
8
  * The remedy per terminal reason. ONE map, so the message a session is shown and the message a log carries can never drift.
@@ -15,16 +15,16 @@
15
15
  const RESTART_NOTICE_STOP_LEAD = "continue without it";
16
16
  const RESTART_NOTICE_STOP_TAIL = "call plan_connect to connect this session to a plan";
17
17
  export const STOP_FIXES = {
18
- no_connection_record: "call plan_connect again in this session — its danx-dashboard MCP server records the connection a hook command needs " +
18
+ no_connection_record: "call plan_connect again in this session — its danx-dashboard MCP server records the connection that restart_notice and the listener reminder read " +
19
19
  "(a server older than the one that introduced the record writes none)",
20
20
  credential_unavailable: "call plan_connect again to request access: this session's key file is missing or unreadable",
21
- credential_mismatch: "restart this Claude Code session so its MCP server and the plugin's hook commands pick up the same credential, then call plan_connect again",
21
+ credential_mismatch: "restart this Claude Code session so its MCP server resolves its credential afresh, then call plan_connect again",
22
22
  not_connected: "call plan_connect to connect this session to a plan",
23
23
  plan_archived: "the plan was archived or deleted, so this session was disconnected; ask the operator to restore it, then call plan_connect again",
24
24
  unauthorized: "the dashboard no longer accepts this session's key (it lapsed after a day unused): call plan_connect again to request access",
25
25
  key_revoked: "stop all work now, commit your work in progress and end this session: the person who owns this session's access revoked it; " +
26
26
  "do not call plan_connect or request access again",
27
- mint_refused: "check that this session is connected to a plan on the dashboard its MCP server points at, then call plan_connect again",
27
+ mint_refused: "check that this session is signed in to the dashboard its MCP server points at, then call plan_connect again",
28
28
  mint_bad_response: "check that DANXBOT_DASHBOARD_URL for this session's MCP server points at a danxbot dashboard",
29
29
  // A newer ticket exists (superseded) or another connection is already using this one (replaced): THIS listener is ending
30
30
  // because the session is already served elsewhere, not because anything is broken.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thehammer/danx-dashboard-mcp",
3
- "version": "0.1.286",
3
+ "version": "0.1.289",
4
4
  "description": "Stdio MCP server fronting danxbot's dashboard API for agents (operator sessions and dispatched workers alike): the generic danxbot_api/danxbot_api_list/danxbot_api_spec tools reach any route the caller's credential may call, plus dedicated agent-specific tools (plan_connect, request_permission, attach_file, and danxbot_slack_reply) (DX-3465, PLN-11 R-25).",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/dist/activity.js DELETED
@@ -1,260 +0,0 @@
1
- /**
2
- * `danx-dashboard-mcp activity <SubagentStart|SubagentStop|PostToolUse> <event-at>` — the
3
- * ONE-SHOT client of `POST /api/plan-sessions/me/activity` (DX-3284).
4
- *
5
- * WHO RUNS IT. The danxbot Claude Code plugin's `SubagentStart`, `SubagentStop` and
6
- * `PostToolUse` (matcher `Bash`) hooks (`danxbot/scripts/activity-report.mjs` in
7
- * claude-plugins), all registered `async: true`. The plugin runs the INSTALLED package
8
- * with `node` (DX-3811), never a cold `npx`, and pipes the hook's own JSON envelope on
9
- * stdin. This module owns what each event MEANS for the session's running set; the plugin
10
- * decides only whether to call at all (a plan-connected session, a background Bash call).
11
- *
12
- * THE THREE EVENTS (the Claude Code hooks reference, `code.claude.com/docs/en/hooks`):
13
- *
14
- * | Event | Reports | Key |
15
- * |----------------|------------------------------------------------------------------|----------------------------|
16
- * | `SubagentStart`| an `agent` activity, running | `agent-<agent_id>` |
17
- * | `SubagentStop` | the same activity, FINISHED | `agent-<agent_id>` |
18
- * | `PostToolUse` | a `bash` activity, running — only for `Bash` with | `tool_use_id` |
19
- * | | `tool_input.run_in_background: true` | |
20
- *
21
- * THE SUB-AGENT KEY. A sub-agent's transcript is `<session>/subagents/agent-<agent_id>.jsonl`,
22
- * and the transcript producer (`createActivityObserver`) keys its row on that file's
23
- * basename, `agent-<agent_id>`. Reporting the bare `agent_id` would upsert a SECOND row for
24
- * the same sub-agent, so the hook-side key is the same basename: the two producers converge
25
- * on one row. The hooks docs disagree on whether `agent_id` already carries the `agent-`
26
- * prefix, so {@link subagentActivityId} normalizes both forms to the one key.
27
- *
28
- * KNOWN LIMITS (DX-3284, pinned in `plan-session-activity.test.ts`):
29
- * - a sub-agent RESUMED after its SubagentStop reports a second start, but the server's
30
- * first finish wins, so the row stays closed;
31
- * - a SubagentStop with no `agent_id` (Claude Code's internal agents) is refused below, and
32
- * the plugin leaves a failure trace. One WITH an id but no recorded start is
33
- * indistinguishable from a stop that beat its (async) start, so it is recorded as an
34
- * already-finished row — it never counts as running. (`transcript_path` cannot supply it: on these events it is the MAIN session's
35
- * transcript — only `SubagentStop`'s `agent_transcript_path` names the sub-agent's own, and
36
- * it is used below to REFUSE a mismatch, never to guess one.)
37
- *
38
- * THERE IS NO "BACKGROUND SHELL FINISHED" EVENT. None of the documented hook events fires
39
- * when a `run_in_background` Bash command ends (verified against the full event list on
40
- * DX-2954's Step 0 note; `TaskCreated` / `TaskCompleted` are the `TaskCreate` tool, not a
41
- * shell). A `bash` activity therefore gets a start here and no finish: its end comes from
42
- * the dispatch's own terminal write (`finishOpenActivities`), never from inferring one out
43
- * of transcript lines. Until then it reads as running and silent, which is the honest
44
- * signal. Do not "fix" this with a proxy event.
45
- *
46
- * THE EVENT TIME is the hook's own, captured by the plugin when the hook FIRED and passed
47
- * as `<event-at>` — never this process's clock, which starts only after the (possibly
48
- * cold) install step. The server's upsert is monotone in every field, so hooks that finish
49
- * out of order, or one that fires twice, still reach the same row.
50
- *
51
- * THE CONTRACT (identical to `background-work`, so the plugin treats both alike):
52
- * - print EXACTLY ONE JSON line on stdout and ALWAYS exit 0 — a hook has no business
53
- * spamming the session with a stack trace;
54
- * - `{"ok":true,"runningActivities":<n>}` on success;
55
- * - `{"ok":false,"reason":"<why>"}` otherwise. `no_connection_record` and
56
- * `session_not_connected` are the ordinary "this session is not on a plan" answers;
57
- * - human-readable diagnostics go to stderr only.
58
- *
59
- * THE CREDENTIAL — the SAME resolver `plan_events_wait` uses (`resolveSessionOptions`): the URL,
60
- * credential source and credential come from the connection record this session's own MCP
61
- * server wrote on `plan_connect`, never from this process's ambient env (DX-2862).
62
- */
63
- import { win32 } from "node:path";
64
- import { readFileSync } from "node:fs";
65
- import { labelOf } from "@thehammer/danx-session-telemetry";
66
- import { parseEventAtArg } from "./background-work.js";
67
- import { resolveSessionOptions, sendSessionRequest, tryParseJsonBody } from "./one-shot-session-request.js";
68
- export const ACTIVITY_SUBCOMMAND = "activity";
69
- export const ACTIVITY_PATH = "/api/plan-sessions/me/activity";
70
- /** How long this one-shot call may take before it counts as a timeout. */
71
- export const ACTIVITY_REQUEST_TIMEOUT_MS = 5_000;
72
- /** The hook events this subcommand accepts, exactly as Claude Code names them in `hook_event_name`. */
73
- export const ACTIVITY_HOOK_EVENTS = ["SubagentStart", "SubagentStop", "PostToolUse"];
74
- const USAGE = `usage: CLAUDE_CODE_SESSION_ID=<session-id> danx-dashboard-mcp ${ACTIVITY_SUBCOMMAND} <${ACTIVITY_HOOK_EVENTS.join("|")}> <event-at ISO-8601 UTC> (the hook's JSON envelope on stdin)`;
75
- const SUBAGENT_PREFIX = "agent-";
76
- /**
77
- * A sub-agent's transcript file basename — the key `createActivityObserver` reports it under.
78
- *
79
- * DX-3284: the hooks documentation shows `agent_id` BOTH ways — SubagentStart's example carries
80
- * the `agent-` prefix (`agent-abc123`), SubagentStop's does not (`def456`, against the file
81
- * `agent-def456.jsonl`). The key is `agent-` + the id with ONE leading `agent-` stripped, so both
82
- * forms converge on the transcript producer's row; keying on the raw id would leave a phantom
83
- * `agent-agent-<id>` row that no stop ever closes.
84
- */
85
- export function subagentActivityId(agentId) {
86
- const bare = agentId.startsWith(SUBAGENT_PREFIX) ? agentId.slice(SUBAGENT_PREFIX.length) : agentId;
87
- return `${SUBAGENT_PREFIX}${bare}`;
88
- }
89
- /**
90
- * DX-4498 — the facts only a transcript knows (the spawning sub-agent, effort, current tool call, how a background task
91
- * ended, the card its spawning `Agent` call names). A hook envelope carries none of them, so it reports each as unknown; the server's merge
92
- * never lets an unknown erase a value the transcript producer stored. A `SubagentStop` names no status either: its
93
- * finish reads as `done` unless a task notification says otherwise.
94
- */
95
- const NO_TRANSCRIPT_FACTS = {
96
- parentActivityId: null,
97
- effort: null,
98
- currentActivity: null,
99
- endStatus: null,
100
- briefIssueId: null,
101
- };
102
- function nonEmptyString(value) {
103
- return typeof value === "string" && value !== "";
104
- }
105
- /**
106
- * One hook envelope -> the ONE `ActivityChange` it stands for, or the reason it stands for
107
- * none. Pure: `eventAtMs` is the hook's own event time.
108
- */
109
- export function activityChangeFromHook(event, envelope, eventAtMs) {
110
- if (typeof envelope !== "object" || envelope === null || Array.isArray(envelope)) {
111
- return { ok: false, reason: "bad_envelope" };
112
- }
113
- const e = envelope;
114
- // The envelope names the event it was fired for; a mismatch is a wiring bug, not data.
115
- if (e.hook_event_name !== undefined && e.hook_event_name !== event) {
116
- return { ok: false, reason: "event_mismatch" };
117
- }
118
- if (event === "PostToolUse") {
119
- const input = e.tool_input;
120
- if (e.tool_name !== "Bash" || typeof input !== "object" || input === null) {
121
- return { ok: false, reason: "not_background_bash" };
122
- }
123
- const toolInput = input;
124
- if (toolInput.run_in_background !== true)
125
- return { ok: false, reason: "not_background_bash" };
126
- if (!nonEmptyString(e.tool_use_id))
127
- return { ok: false, reason: "no_tool_use_id" };
128
- return {
129
- ok: true,
130
- change: {
131
- kind: "bash",
132
- activityId: e.tool_use_id,
133
- agentType: null,
134
- // DX-4498 — agent-written free text, so it is redacted like every label that reaches the running set.
135
- description: labelOf(toolInput.description),
136
- startedAt: eventAtMs,
137
- // No hook ever refreshes or ends a background shell (see the module docblock), so
138
- // its liveness is its start and `finishedAt` stays null until the dispatch ends.
139
- lastActivityAt: eventAtMs,
140
- finishedAt: null,
141
- ...NO_TRANSCRIPT_FACTS,
142
- },
143
- };
144
- }
145
- // A SubagentStop from one of Claude Code's own internal agents carries no `agent_id`: it names
146
- // no activity, so it is refused here (the plugin leaves a failure trace) and nothing is
147
- // written — a stop must never invent a row.
148
- if (!nonEmptyString(e.agent_id) || e.agent_id === SUBAGENT_PREFIX)
149
- return { ok: false, reason: "no_agent_id" };
150
- const activityId = subagentActivityId(e.agent_id);
151
- if (event === "SubagentStop" && e.agent_transcript_path !== undefined) {
152
- // The one place the hook names the sub-agent's own file: if it disagrees with the
153
- // key derived from `agent_id`, this process would upsert a duplicate row. Refuse.
154
- const file = typeof e.agent_transcript_path === "string" ? win32.basename(e.agent_transcript_path, ".jsonl") : null;
155
- if (file !== activityId)
156
- return { ok: false, reason: "agent_id_mismatch" };
157
- }
158
- return {
159
- ok: true,
160
- change: {
161
- kind: "agent",
162
- activityId,
163
- agentType: nonEmptyString(e.agent_type) ? e.agent_type : null,
164
- description: null,
165
- startedAt: eventAtMs,
166
- lastActivityAt: eventAtMs,
167
- finishedAt: event === "SubagentStop" ? eventAtMs : null,
168
- ...NO_TRANSCRIPT_FACTS,
169
- },
170
- };
171
- }
172
- /**
173
- * One authenticated POST, with a hard timeout, classified into the output shape this
174
- * subcommand always prints — never a throw. Status is checked before any body is parsed
175
- * (an HTML 5xx page must read as `http_error`, not `bad_response`); only a 409 needs its
176
- * body, to tell "no plan" from "a worker's session".
177
- */
178
- export async function postActivity(options, change, deps) {
179
- const sent = await sendSessionRequest(options, ACTIVITY_PATH, { method: "POST", body: JSON.stringify({ activities: [change] }) }, deps);
180
- if ("reason" in sent)
181
- return sent;
182
- const { status, text } = sent;
183
- if (status === 401 || status === 403)
184
- return { ok: false, reason: "unauthorized" };
185
- if (status === 409) {
186
- const parsed = tryParseJsonBody(text);
187
- const error = parsed.ok ? parsed.body?.error : undefined;
188
- if (error === "session_not_connected" || error === "session_is_worker")
189
- return { ok: false, reason: error };
190
- return { ok: false, reason: "http_error" };
191
- }
192
- if (status < 200 || status >= 300)
193
- return { ok: false, reason: "http_error" };
194
- const parsed = tryParseJsonBody(text);
195
- if (!parsed.ok)
196
- return { ok: false, reason: "bad_response" };
197
- const body = parsed.body;
198
- if (typeof body?.runningActivities !== "number")
199
- return { ok: false, reason: "bad_response" };
200
- return { ok: true, runningActivities: body.runningActivities };
201
- }
202
- /** The bin's `activity` subcommand, wired to the real process. Always resolves to exit code 0. */
203
- export async function runActivityCommand(argv, env = process.env,
204
- /** Test seam: the home the session's connection record is read from. */
205
- resolveFrom = {},
206
- /** Test seam: the hook envelope's text (the real process reads stdin). */
207
- readEnvelope = () => readFileSync(0, "utf8")) {
208
- const write = (output) => {
209
- process.stdout.write(`${JSON.stringify(output)}\n`);
210
- };
211
- const usageFailure = (detail) => {
212
- write({ ok: false, reason: "usage" });
213
- process.stderr.write(`${detail}\n${USAGE}\n`);
214
- return 0;
215
- };
216
- if (argv.length !== 2)
217
- return usageFailure("expected exactly two arguments");
218
- const event = ACTIVITY_HOOK_EVENTS.includes(argv[0]) ? argv[0] : null;
219
- if (event === null)
220
- return usageFailure(`unknown hook event "${argv[0]}"`);
221
- const eventAt = parseEventAtArg(argv[1]);
222
- if (typeof eventAt === "object")
223
- return usageFailure(eventAt.error);
224
- let envelope;
225
- try {
226
- envelope = JSON.parse(readEnvelope());
227
- }
228
- catch {
229
- write({ ok: false, reason: "bad_envelope" });
230
- process.stderr.write("the hook envelope on stdin is not JSON\n");
231
- return 0;
232
- }
233
- const mapped = activityChangeFromHook(event, envelope, Date.parse(eventAt));
234
- if (!mapped.ok) {
235
- write(mapped);
236
- process.stderr.write(`${event} envelope reports no activity: ${mapped.reason}\n`);
237
- return 0;
238
- }
239
- // The connection record is this session's: an envelope for ANOTHER session must never
240
- // be reported under it.
241
- const envelopeSession = envelope.session_id;
242
- if (envelopeSession !== undefined && envelopeSession !== env.CLAUDE_CODE_SESSION_ID) {
243
- write({ ok: false, reason: "session_mismatch" });
244
- process.stderr.write("the envelope's session_id is not CLAUDE_CODE_SESSION_ID\n");
245
- return 0;
246
- }
247
- const resolved = await resolveSessionOptions(env, resolveFrom, USAGE);
248
- if (!resolved.ok) {
249
- // A session that never `plan_connect`-ed has no connection record: the ordinary
250
- // "not on a plan" case, silent for the plugin.
251
- write(resolved.output);
252
- process.stderr.write(`${resolved.detail}\n`);
253
- return 0;
254
- }
255
- write(await postActivity(resolved.options, mapped.change, {
256
- fetch: (input, init) => fetch(input, init),
257
- requestTimeoutMs: ACTIVITY_REQUEST_TIMEOUT_MS,
258
- }));
259
- return 0;
260
- }
@@ -1,162 +0,0 @@
1
- /**
2
- * `danx-dashboard-mcp background-work <count|clear> <event-at>` — the ONE-SHOT client of
3
- * `PUT /api/plan-sessions/me/background-work` (DX-3367).
4
- *
5
- * WHO RUNS IT. The danxbot Claude Code plugin's `Stop` / `SubagentStop` /
6
- * `SessionStart` / `StopFailure` hooks (`danxbot/hooks/hooks.json` in
7
- * claude-plugins), via the SAME `npx -y @thehammer/danx-dashboard-mcp`
8
- * MECHANISM the plugin already uses for `plan_events_wait` — no separate install, no
9
- * deep import of this package's `dist/` internals.
10
- * DX-3367 fix-up — that does NOT mean the SAME PINNED VERSION: `npx -y
11
- * <pkg>` resolves whatever version string that ONE hook's own invocation
12
- * names, and this plugin's hooks pin each call independently (verified
13
- * against production drift, comment 7102 on the card: the background-work
14
- * hooks pinned `0.1.136` while `plan_events_wait` was still pinned `0.1.95`) — a
15
- * version bump to one hook's pin never moves the others. The plugin decides
16
- * WHAT to report (parsing `Stop`/`SubagentStop`'s own `background_tasks`
17
- * snapshot, counting only `shell` / `subagent` / `workflow` entries,
18
- * sending `clear` on `SessionStart`/`StopFailure`); this module only
19
- * delivers the report.
20
- *
21
- * THE CONTRACT (binding, so a future plugin build against this module can
22
- * trust it without re-reading this file):
23
- * - print EXACTLY ONE JSON line on stdout, and ALWAYS exit 0 — success or
24
- * failure alike, because a hook must stay silent rather than spam the
25
- * session's own turn with a stack trace;
26
- * - a session with no connection record (never `plan_connect`-ed, e.g. an
27
- * operator session that never joined a plan) is a silent, expected
28
- * no-op — `{"ok":false,"reason":"no_connection_record"}` — the plugin
29
- * hook simply does not forward anything further in this case, exactly
30
- * what "no-op when the session isn't plan-connected" means in practice;
31
- * - on success, `{"ok":true,"count":<number|null>,"applied":<boolean>}`
32
- * echoes the SUBMITTED `count`; `applied` is the server's answer to
33
- * whether it was stored (DX-3676: `false` for an out-of-order report
34
- * older than the one already on file — the caller must not treat it as
35
- * the dashboard's current count);
36
- * - DX-3676 — `<event-at>` is the ISO-8601 UTC time the reporting HOOK
37
- * fired, captured by the caller, never this process's own clock: the
38
- * hooks are async and can finish out of order, and the server orders
39
- * reports by this time;
40
- * - human-readable diagnostics go to stderr only, never stdout.
41
- *
42
- * THE CREDENTIAL — the SAME resolver `plan_events_wait` uses, via `resolveDashboardAuth`
43
- * (the relay): the dashboard URL, credential source, and credential come
44
- * from the connection record this session's OWN danx-dashboard MCP server
45
- * wrote on its last successful `plan_connect` (`session-connection.ts`),
46
- * never from this process's own ambient env (DX-2862).
47
- *
48
- * A HARD TIMEOUT — `BACKGROUND_WORK_REQUEST_TIMEOUT_MS`. A `Stop` hook
49
- * blocks the session's turn boundary, so this call must never hang: a slow
50
- * or wedged dashboard is `{ok:false, reason:"timeout"}`, not a stuck hook.
51
- *
52
- * THE REQUEST/RUN SCAFFOLDING (`one-shot-session-request.ts`, DX-3367
53
- * fix-up) is shared, general-purpose plumbing for any one-shot subcommand
54
- * shaped like this one — not maintained as an independent copy of the
55
- * authenticated-fetch-with-timeout logic or the session/credential
56
- * resolution boilerplate.
57
- */
58
- import { resolveSessionOptions, sendSessionRequest, tryParseJsonBody } from "./one-shot-session-request.js";
59
- export const BACKGROUND_WORK_SUBCOMMAND = "background-work";
60
- export const BACKGROUND_WORK_PATH = "/api/plan-sessions/me/background-work";
61
- /** How long this one-shot call may take before it counts as a timeout. */
62
- export const BACKGROUND_WORK_REQUEST_TIMEOUT_MS = 5_000;
63
- const USAGE = `usage: CLAUDE_CODE_SESSION_ID=<session-id> danx-dashboard-mcp ${BACKGROUND_WORK_SUBCOMMAND} <non-negative-integer|clear> <event-at ISO-8601 UTC>`;
64
- /**
65
- * Parse the ONE positional argument: `clear`, or a non-negative integer
66
- * string. Anything else is a usage error — never guessed or floored.
67
- */
68
- export function parseCountArg(raw) {
69
- if (raw === "clear")
70
- return null;
71
- if (!/^\d+$/.test(raw))
72
- return { error: `count must be "clear" or a non-negative integer, got "${raw}"` };
73
- return Number.parseInt(raw, 10);
74
- }
75
- /** DX-3676 — an ISO-8601 UTC instant, as `Date#toISOString` writes it (the server accepts exactly this). */
76
- const ISO_UTC_INSTANT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,3})?Z$/;
77
- /** Parse the event-time argument (DX-3676). Never substituted with this process's clock. */
78
- export function parseEventAtArg(raw) {
79
- if (!ISO_UTC_INSTANT.test(raw) || Number.isNaN(Date.parse(raw))) {
80
- return { error: `event-at must be an ISO-8601 UTC timestamp (e.g. 2026-09-30T00:00:00.000Z), got "${raw}"` };
81
- }
82
- return raw;
83
- }
84
- /**
85
- * One authenticated PUT, with a hard timeout, classified into the output
86
- * shape this subcommand always prints — never a throw, never a non-2xx
87
- * bubbling past this function.
88
- *
89
- * DX-3367 fix-up — STATUS IS CHECKED BEFORE THE BODY IS EVER PARSED. A 5xx
90
- * (or any other non-2xx) response can carry an HTML error page instead of
91
- * JSON; parsing first would misreport that as `bad_response` when the true
92
- * classification is `http_error`. This subcommand has no status that needs
93
- * the BODY inspected to classify (no `409 session_not_connected`
94
- * equivalent), so status alone decides whether a body read is even
95
- * attempted.
96
- */
97
- export async function putBackgroundWork(options, report, deps) {
98
- const sent = await sendSessionRequest(options, BACKGROUND_WORK_PATH, { method: "PUT", body: JSON.stringify({ count: report.count, eventAt: report.eventAt }) }, deps);
99
- if ("reason" in sent)
100
- return sent;
101
- const { status, text } = sent;
102
- if (status === 401 || status === 403) {
103
- return { ok: false, reason: "unauthorized" };
104
- }
105
- if (status < 200 || status >= 300) {
106
- return { ok: false, reason: "http_error" };
107
- }
108
- const parsed = tryParseJsonBody(text);
109
- if (!parsed.ok)
110
- return { ok: false, reason: "bad_response" };
111
- const body = parsed.body;
112
- if (typeof body !== "object" ||
113
- body === null ||
114
- !("count" in body) ||
115
- (body.count !== null && typeof body.count !== "number") ||
116
- typeof body.applied !== "boolean") {
117
- return { ok: false, reason: "bad_response" };
118
- }
119
- const shaped = body;
120
- return { ok: true, count: shaped.count, applied: shaped.applied };
121
- }
122
- /** The bin's `background-work` subcommand, wired to the real process. Always resolves to exit code 0. */
123
- export async function runBackgroundWorkCommand(argv, env = process.env,
124
- /** Test seam: the home the session's connection record is read from. */
125
- resolveFrom = {}) {
126
- const write = (output) => {
127
- process.stdout.write(`${JSON.stringify(output)}\n`);
128
- };
129
- if (argv.length !== 2) {
130
- write({ ok: false, reason: "usage" });
131
- process.stderr.write(`${USAGE}\n`);
132
- return 0;
133
- }
134
- const parsed = parseCountArg(argv[0]);
135
- if (typeof parsed === "object" && parsed !== null && "error" in parsed) {
136
- write({ ok: false, reason: "usage" });
137
- process.stderr.write(`${parsed.error}\n${USAGE}\n`);
138
- return 0;
139
- }
140
- const count = parsed;
141
- const eventAt = parseEventAtArg(argv[1]);
142
- if (typeof eventAt === "object") {
143
- write({ ok: false, reason: "usage" });
144
- process.stderr.write(`${eventAt.error}\n${USAGE}\n`);
145
- return 0;
146
- }
147
- const resolved = await resolveSessionOptions(env, resolveFrom, USAGE);
148
- if (!resolved.ok) {
149
- // A session that never `plan_connect`-ed has no connection record — this
150
- // is the ORDINARY "not plan-connected" case, not an error the plugin
151
- // should surface: it simply forwards nothing further this turn.
152
- write(resolved.output);
153
- process.stderr.write(`${resolved.detail}\n`);
154
- return 0;
155
- }
156
- const output = await putBackgroundWork(resolved.options, { count, eventAt }, {
157
- fetch: (input, init) => fetch(input, init),
158
- requestTimeoutMs: BACKGROUND_WORK_REQUEST_TIMEOUT_MS,
159
- });
160
- write(output);
161
- return 0;
162
- }
@@ -1,105 +0,0 @@
1
- /**
2
- * DX-3367 fix-up — shared scaffolding for a "one-shot session request"
3
- * subcommand. Extracted when `plan-state` (`GET /api/plan-sessions/me/turn-state`,
4
- * removed by DX-3689 — it never had a caller) and `background-work`
5
- * (`PUT /api/plan-sessions/me/background-work`) turned out to duplicate this
6
- * exact shape independently (`fetchTurnState` / `putBackgroundWork`,
7
- * `runPlanStateCommand` / `runBackgroundWorkCommand`). This module is the ONE
8
- * place that shape lives now; a future one-shot subcommand extends it
9
- * instead of copying `background-work.ts` again.
10
- *
11
- * Two pieces:
12
- * - `sendSessionRequest` — the authenticated HTTP call itself: bearer +
13
- * session header, a hard abort-timeout, and network-level failure
14
- * classification (`timeout` / `request_failed`). Returns the raw
15
- * `{status, text}` UNPARSED — each caller decides for itself which
16
- * statuses need the body inspected and which don't, so a non-2xx status
17
- * with an unparseable (e.g. HTML) body is classified from the status
18
- * alone rather than colliding with "the JSON didn't parse"
19
- * (`bad_response`).
20
- * - `resolveSessionOptions` — session-id + credential resolution via
21
- * `resolveDashboardAuth`, with `SessionStartError` narrowed by
22
- * `instanceof` rather than an unchecked `as SessionStartError` cast, so
23
- * an error of some OTHER shape (a bug in the resolver, a thrown
24
- * non-Error) still comes back as a reported reason instead of a value
25
- * whose `.reason`/`.message`/`.fix` fields are silently `undefined`.
26
- */
27
- import { boundRequest } from "./request-bound.js";
28
- import { SESSION_ID_HEADER, resolveDashboardAuth, SessionStartError } from "./session-options.js";
29
- /**
30
- * One authenticated HTTP call to this session's dashboard, with a hard
31
- * timeout. Never throws — a network failure or an abort is reported as
32
- * `OneShotFailure`, exactly like any other classified failure the caller
33
- * might produce from the status/body it gets back on success.
34
- */
35
- export async function sendSessionRequest(options, path, init, deps) {
36
- const bound = boundRequest(deps.requestTimeoutMs, deps.signal);
37
- try {
38
- try {
39
- const response = await deps.fetch(`${options.dashboardUrl}${path}`, {
40
- method: init.method,
41
- headers: {
42
- Authorization: `Bearer ${options.token}`,
43
- Accept: "application/json",
44
- ...(init.body !== undefined ? { "Content-Type": "application/json" } : {}),
45
- [SESSION_ID_HEADER]: options.sessionId,
46
- },
47
- body: init.body,
48
- signal: bound.signal,
49
- });
50
- const text = await response.text();
51
- return { status: response.status, text };
52
- }
53
- catch {
54
- // DX-3367 fix-up — the caught value is never inspected (an aborted
55
- // fetch's own rejection shape varies by runtime), so binding it to a
56
- // name the code never reads was dead weight the linter never caught.
57
- return { ok: false, reason: bound.aborted() ? "timeout" : "request_failed" };
58
- }
59
- }
60
- finally {
61
- bound.release();
62
- }
63
- }
64
- /** `JSON.parse`, classified rather than thrown — the ONE shared parse point
65
- * every caller uses once it has decided (from `status`) that the body is
66
- * worth reading at all. */
67
- export function tryParseJsonBody(text) {
68
- try {
69
- return { ok: true, body: text === "" ? null : JSON.parse(text) };
70
- }
71
- catch {
72
- return { ok: false };
73
- }
74
- }
75
- /**
76
- * Resolve this process's session id + dashboard credential, the identical
77
- * first step every one-shot subcommand's `runXCommand` takes before it can
78
- * make its own request. `detail` is the stderr-only human-readable line the
79
- * caller prints alongside `output` — never part of the stdout JSON contract.
80
- */
81
- export async function resolveSessionOptions(env, resolveFrom, usage) {
82
- const sessionId = env.CLAUDE_CODE_SESSION_ID;
83
- if (!sessionId) {
84
- return { ok: false, output: { ok: false, reason: "no_session_id" }, detail: usage };
85
- }
86
- try {
87
- const options = resolveDashboardAuth(sessionId, env, resolveFrom);
88
- return { ok: true, options };
89
- }
90
- catch (err) {
91
- // DX-3367 fix-up — `instanceof`, never an unchecked `as SessionStartError`
92
- // cast: `resolveDashboardAuth` throws exactly `SessionStartError` today,
93
- // but a cast reports a fabricated reason/message/fix (all `undefined`)
94
- // for any OTHER thrown shape instead of a real one, silently.
95
- if (err instanceof SessionStartError) {
96
- return {
97
- ok: false,
98
- output: { ok: false, reason: err.reason },
99
- detail: `${err.reason}: ${err.message}. Fix: ${err.fix}.`,
100
- };
101
- }
102
- const message = err instanceof Error ? err.message : String(err);
103
- return { ok: false, output: { ok: false, reason: "start_failed" }, detail: `start_failed: ${message}` };
104
- }
105
- }