@thehammer/danx-dashboard-mcp 0.1.285 → 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/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
- }