@thehammer/danx-dashboard-mcp 0.1.185 → 0.1.186

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.
@@ -0,0 +1,221 @@
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. (`transcript_path` cannot supply it: on these events it is the MAIN session's
26
+ * transcript — only `SubagentStop`'s `agent_transcript_path` names the sub-agent's own, and
27
+ * it is used below to REFUSE a mismatch, never to guess one.)
28
+ *
29
+ * THERE IS NO "BACKGROUND SHELL FINISHED" EVENT. None of the documented hook events fires
30
+ * when a `run_in_background` Bash command ends (verified against the full event list on
31
+ * DX-2954's Step 0 note; `TaskCreated` / `TaskCompleted` are the `TaskCreate` tool, not a
32
+ * shell). A `bash` activity therefore gets a start here and no finish: its end comes from
33
+ * the dispatch's own terminal write (`finishOpenActivities`), never from inferring one out
34
+ * of transcript lines. Until then it reads as running and silent, which is the honest
35
+ * signal. Do not "fix" this with a proxy event.
36
+ *
37
+ * THE EVENT TIME is the hook's own, captured by the plugin when the hook FIRED and passed
38
+ * as `<event-at>` — never this process's clock, which starts only after the (possibly
39
+ * cold) install step. The server's upsert is monotone in every field, so hooks that finish
40
+ * out of order, or one that fires twice, still reach the same row.
41
+ *
42
+ * THE CONTRACT (identical to `background-work`, so the plugin treats both alike):
43
+ * - print EXACTLY ONE JSON line on stdout and ALWAYS exit 0 — a hook has no business
44
+ * spamming the session with a stack trace;
45
+ * - `{"ok":true,"runningActivities":<n>}` on success;
46
+ * - `{"ok":false,"reason":"<why>"}` otherwise. `no_connection_record` and
47
+ * `session_not_connected` are the ordinary "this session is not on a plan" answers;
48
+ * - human-readable diagnostics go to stderr only.
49
+ *
50
+ * THE CREDENTIAL — the SAME resolver `bridge` uses (`resolveSessionOptions`): the URL,
51
+ * credential source and credential come from the connection record this session's own MCP
52
+ * server wrote on `plan_connect`, never from this process's ambient env (DX-2862).
53
+ */
54
+ import { win32 } from "node:path";
55
+ import { readFileSync } from "node:fs";
56
+ import { parseEventAtArg } from "./background-work.js";
57
+ import { resolveSessionOptions, sendSessionRequest, tryParseJsonBody } from "./one-shot-session-request.js";
58
+ export const ACTIVITY_SUBCOMMAND = "activity";
59
+ export const ACTIVITY_PATH = "/api/plan-sessions/me/activity";
60
+ /** How long this one-shot call may take before it counts as a timeout. */
61
+ export const ACTIVITY_REQUEST_TIMEOUT_MS = 5_000;
62
+ /** The hook events this subcommand accepts, exactly as Claude Code names them in `hook_event_name`. */
63
+ export const ACTIVITY_HOOK_EVENTS = ["SubagentStart", "SubagentStop", "PostToolUse"];
64
+ 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)`;
65
+ /** A sub-agent's transcript file basename — the key `createActivityObserver` reports it under. */
66
+ export function subagentActivityId(agentId) {
67
+ return `agent-${agentId}`;
68
+ }
69
+ function nonEmptyString(value) {
70
+ return typeof value === "string" && value !== "";
71
+ }
72
+ /**
73
+ * One hook envelope -> the ONE `ActivityChange` it stands for, or the reason it stands for
74
+ * none. Pure: `eventAtMs` is the hook's own event time.
75
+ */
76
+ export function activityChangeFromHook(event, envelope, eventAtMs) {
77
+ if (typeof envelope !== "object" || envelope === null || Array.isArray(envelope)) {
78
+ return { ok: false, reason: "bad_envelope" };
79
+ }
80
+ const e = envelope;
81
+ // The envelope names the event it was fired for; a mismatch is a wiring bug, not data.
82
+ if (e.hook_event_name !== undefined && e.hook_event_name !== event) {
83
+ return { ok: false, reason: "event_mismatch" };
84
+ }
85
+ if (event === "PostToolUse") {
86
+ const input = e.tool_input;
87
+ if (e.tool_name !== "Bash" || typeof input !== "object" || input === null) {
88
+ return { ok: false, reason: "not_background_bash" };
89
+ }
90
+ const toolInput = input;
91
+ if (toolInput.run_in_background !== true)
92
+ return { ok: false, reason: "not_background_bash" };
93
+ if (!nonEmptyString(e.tool_use_id))
94
+ return { ok: false, reason: "no_tool_use_id" };
95
+ return {
96
+ ok: true,
97
+ change: {
98
+ kind: "bash",
99
+ activityId: e.tool_use_id,
100
+ agentType: null,
101
+ description: nonEmptyString(toolInput.description) ? toolInput.description : null,
102
+ startedAt: eventAtMs,
103
+ // No hook ever refreshes or ends a background shell (see the module docblock), so
104
+ // its liveness is its start and `finishedAt` stays null until the dispatch ends.
105
+ lastActivityAt: eventAtMs,
106
+ finishedAt: null,
107
+ },
108
+ };
109
+ }
110
+ if (!nonEmptyString(e.agent_id))
111
+ return { ok: false, reason: "no_agent_id" };
112
+ const activityId = subagentActivityId(e.agent_id);
113
+ if (event === "SubagentStop" && e.agent_transcript_path !== undefined) {
114
+ // The one place the hook names the sub-agent's own file: if it disagrees with the
115
+ // key derived from `agent_id`, this process would upsert a duplicate row. Refuse.
116
+ const file = typeof e.agent_transcript_path === "string" ? win32.basename(e.agent_transcript_path, ".jsonl") : null;
117
+ if (file !== activityId)
118
+ return { ok: false, reason: "agent_id_mismatch" };
119
+ }
120
+ return {
121
+ ok: true,
122
+ change: {
123
+ kind: "agent",
124
+ activityId,
125
+ agentType: nonEmptyString(e.agent_type) ? e.agent_type : null,
126
+ description: null,
127
+ startedAt: eventAtMs,
128
+ lastActivityAt: eventAtMs,
129
+ finishedAt: event === "SubagentStop" ? eventAtMs : null,
130
+ },
131
+ };
132
+ }
133
+ /**
134
+ * One authenticated POST, with a hard timeout, classified into the output shape this
135
+ * subcommand always prints — never a throw. Status is checked before any body is parsed
136
+ * (an HTML 5xx page must read as `http_error`, not `bad_response`); only a 409 needs its
137
+ * body, to tell "no plan" from "a worker's session".
138
+ */
139
+ export async function postActivity(options, change, deps) {
140
+ const sent = await sendSessionRequest(options, ACTIVITY_PATH, { method: "POST", body: JSON.stringify({ activities: [change] }) }, deps);
141
+ if ("reason" in sent)
142
+ return sent;
143
+ const { status, text } = sent;
144
+ if (status === 401 || status === 403)
145
+ return { ok: false, reason: "unauthorized" };
146
+ if (status === 409) {
147
+ const parsed = tryParseJsonBody(text);
148
+ const error = parsed.ok ? parsed.body?.error : undefined;
149
+ if (error === "session_not_connected" || error === "session_is_worker")
150
+ return { ok: false, reason: error };
151
+ return { ok: false, reason: "http_error" };
152
+ }
153
+ if (status < 200 || status >= 300)
154
+ return { ok: false, reason: "http_error" };
155
+ const parsed = tryParseJsonBody(text);
156
+ if (!parsed.ok)
157
+ return { ok: false, reason: "bad_response" };
158
+ const body = parsed.body;
159
+ if (typeof body?.runningActivities !== "number")
160
+ return { ok: false, reason: "bad_response" };
161
+ return { ok: true, runningActivities: body.runningActivities };
162
+ }
163
+ /** The bin's `activity` subcommand, wired to the real process. Always resolves to exit code 0. */
164
+ export async function runActivityCommand(argv, env = process.env,
165
+ /** Test seam: the home the session's connection record is read from. */
166
+ resolveFrom = {},
167
+ /** Test seam: the hook envelope's text (the real process reads stdin). */
168
+ readEnvelope = () => readFileSync(0, "utf8")) {
169
+ const write = (output) => {
170
+ process.stdout.write(`${JSON.stringify(output)}\n`);
171
+ };
172
+ const usageFailure = (detail) => {
173
+ write({ ok: false, reason: "usage" });
174
+ process.stderr.write(`${detail}\n${USAGE}\n`);
175
+ return 0;
176
+ };
177
+ if (argv.length !== 2)
178
+ return usageFailure("expected exactly two arguments");
179
+ const event = ACTIVITY_HOOK_EVENTS.includes(argv[0]) ? argv[0] : null;
180
+ if (event === null)
181
+ return usageFailure(`unknown hook event "${argv[0]}"`);
182
+ const eventAt = parseEventAtArg(argv[1]);
183
+ if (typeof eventAt === "object")
184
+ return usageFailure(eventAt.error);
185
+ let envelope;
186
+ try {
187
+ envelope = JSON.parse(readEnvelope());
188
+ }
189
+ catch {
190
+ write({ ok: false, reason: "bad_envelope" });
191
+ process.stderr.write("the hook envelope on stdin is not JSON\n");
192
+ return 0;
193
+ }
194
+ const mapped = activityChangeFromHook(event, envelope, Date.parse(eventAt));
195
+ if (!mapped.ok) {
196
+ write(mapped);
197
+ process.stderr.write(`${event} envelope reports no activity: ${mapped.reason}\n`);
198
+ return 0;
199
+ }
200
+ // The connection record is this session's: an envelope for ANOTHER session must never
201
+ // be reported under it.
202
+ const envelopeSession = envelope.session_id;
203
+ if (envelopeSession !== undefined && envelopeSession !== env.CLAUDE_CODE_SESSION_ID) {
204
+ write({ ok: false, reason: "session_mismatch" });
205
+ process.stderr.write("the envelope's session_id is not CLAUDE_CODE_SESSION_ID\n");
206
+ return 0;
207
+ }
208
+ const resolved = await resolveSessionOptions(env, resolveFrom, USAGE);
209
+ if (!resolved.ok) {
210
+ // A session that never `plan_connect`-ed has no connection record: the ordinary
211
+ // "not on a plan" case, silent for the plugin.
212
+ write(resolved.output);
213
+ process.stderr.write(`${resolved.detail}\n`);
214
+ return 0;
215
+ }
216
+ write(await postActivity(resolved.options, mapped.change, {
217
+ fetch: (input, init) => fetch(input, init),
218
+ requestTimeoutMs: ACTIVITY_REQUEST_TIMEOUT_MS,
219
+ }));
220
+ return 0;
221
+ }
package/dist/index.js CHANGED
@@ -75,6 +75,7 @@
75
75
  import { isEntrypointModule } from "./entrypoint.js";
76
76
  import { BRIDGE_SUBCOMMAND, runBridgeCommand } from "./bridge.js";
77
77
  import { BACKGROUND_WORK_SUBCOMMAND, runBackgroundWorkCommand } from "./background-work.js";
78
+ import { ACTIVITY_SUBCOMMAND, runActivityCommand } from "./activity.js";
78
79
  import { EVENT_TEXT_SUBCOMMAND, runEventTextCommand } from "./event-text.js";
79
80
  import { resolveDeclaredCredential } from "./credential.js";
80
81
  import { recordSessionConnectionAfterConnect } from "./session-connection.js";
@@ -485,6 +486,15 @@ if (isEntrypointModule(import.meta.url, process.argv[1])) {
485
486
  process.exit(0);
486
487
  });
487
488
  }
489
+ else if (subcommand === ACTIVITY_SUBCOMMAND) {
490
+ // DX-3284 — the same hook-only contract as background-work: a rejection prints one
491
+ // failure line and exits 0, so an async hook never surfaces a stack trace.
492
+ runActivityCommand(rest).then((code) => process.exit(code), (err) => {
493
+ console.error(`[danx-dashboard-mcp] activity fatal: ${err.message}`);
494
+ process.stdout.write(`${JSON.stringify({ ok: false, reason: "fatal" })}\n`);
495
+ process.exit(0);
496
+ });
497
+ }
488
498
  else if (subcommand === EVENT_TEXT_SUBCOMMAND) {
489
499
  // DX-3421 (was DX-3366's `mantra` subcommand) — unlike background-work, a
490
500
  // rejection here is a genuine fatal (never expected: runEventTextCommand
@@ -498,7 +508,7 @@ if (isEntrypointModule(import.meta.url, process.argv[1])) {
498
508
  }
499
509
  else if (subcommand !== undefined) {
500
510
  console.error(`[danx-dashboard-mcp] unknown subcommand "${subcommand}" (the only ones are ` +
501
- `"${BRIDGE_SUBCOMMAND}", "${BACKGROUND_WORK_SUBCOMMAND}", and "${EVENT_TEXT_SUBCOMMAND}")`);
511
+ `"${BRIDGE_SUBCOMMAND}", "${BACKGROUND_WORK_SUBCOMMAND}", "${ACTIVITY_SUBCOMMAND}", and "${EVENT_TEXT_SUBCOMMAND}")`);
502
512
  process.exit(2);
503
513
  }
504
514
  else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thehammer/danx-dashboard-mcp",
3
- "version": "0.1.185",
3
+ "version": "0.1.186",
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, danxbot_complete, and other per-dispatch lifecycle callbacks) (DX-3465, PLN-11 R-25).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -31,7 +31,7 @@
31
31
  },
32
32
  "dependencies": {
33
33
  "@modelcontextprotocol/sdk": "1.29.0",
34
- "@thehammer/danx-session-telemetry": "0.1.17",
34
+ "@thehammer/danx-session-telemetry": "0.1.18",
35
35
  "zod": "^3.25.76"
36
36
  },
37
37
  "devDependencies": {