@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/README.md +3 -9
- package/dist/credential.js +5 -5
- package/dist/digest-hold.js +2 -1
- package/dist/heartbeat-reporter.js +2 -2
- package/dist/index.js +16 -44
- package/dist/listen.js +11 -6
- package/dist/plan-events-relay.js +4 -3
- 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/dist/urgent-records.js +34 -0
- 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/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
|
-
}
|