@thehammer/danx-dashboard-mcp 0.1.185 → 0.1.187
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 +243 -0
- package/dist/index.js +11 -1
- package/package.json +2 -2
package/dist/activity.js
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
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 `bridge` 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 { parseEventAtArg } from "./background-work.js";
|
|
66
|
+
import { resolveSessionOptions, sendSessionRequest, tryParseJsonBody } from "./one-shot-session-request.js";
|
|
67
|
+
export const ACTIVITY_SUBCOMMAND = "activity";
|
|
68
|
+
export const ACTIVITY_PATH = "/api/plan-sessions/me/activity";
|
|
69
|
+
/** How long this one-shot call may take before it counts as a timeout. */
|
|
70
|
+
export const ACTIVITY_REQUEST_TIMEOUT_MS = 5_000;
|
|
71
|
+
/** The hook events this subcommand accepts, exactly as Claude Code names them in `hook_event_name`. */
|
|
72
|
+
export const ACTIVITY_HOOK_EVENTS = ["SubagentStart", "SubagentStop", "PostToolUse"];
|
|
73
|
+
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)`;
|
|
74
|
+
const SUBAGENT_PREFIX = "agent-";
|
|
75
|
+
/**
|
|
76
|
+
* A sub-agent's transcript file basename — the key `createActivityObserver` reports it under.
|
|
77
|
+
*
|
|
78
|
+
* DX-3284: the hooks documentation shows `agent_id` BOTH ways — SubagentStart's example carries
|
|
79
|
+
* the `agent-` prefix (`agent-abc123`), SubagentStop's does not (`def456`, against the file
|
|
80
|
+
* `agent-def456.jsonl`). The key is `agent-` + the id with ONE leading `agent-` stripped, so both
|
|
81
|
+
* forms converge on the transcript producer's row; keying on the raw id would leave a phantom
|
|
82
|
+
* `agent-agent-<id>` row that no stop ever closes.
|
|
83
|
+
*/
|
|
84
|
+
export function subagentActivityId(agentId) {
|
|
85
|
+
const bare = agentId.startsWith(SUBAGENT_PREFIX) ? agentId.slice(SUBAGENT_PREFIX.length) : agentId;
|
|
86
|
+
return `${SUBAGENT_PREFIX}${bare}`;
|
|
87
|
+
}
|
|
88
|
+
function nonEmptyString(value) {
|
|
89
|
+
return typeof value === "string" && value !== "";
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* One hook envelope -> the ONE `ActivityChange` it stands for, or the reason it stands for
|
|
93
|
+
* none. Pure: `eventAtMs` is the hook's own event time.
|
|
94
|
+
*/
|
|
95
|
+
export function activityChangeFromHook(event, envelope, eventAtMs) {
|
|
96
|
+
if (typeof envelope !== "object" || envelope === null || Array.isArray(envelope)) {
|
|
97
|
+
return { ok: false, reason: "bad_envelope" };
|
|
98
|
+
}
|
|
99
|
+
const e = envelope;
|
|
100
|
+
// The envelope names the event it was fired for; a mismatch is a wiring bug, not data.
|
|
101
|
+
if (e.hook_event_name !== undefined && e.hook_event_name !== event) {
|
|
102
|
+
return { ok: false, reason: "event_mismatch" };
|
|
103
|
+
}
|
|
104
|
+
if (event === "PostToolUse") {
|
|
105
|
+
const input = e.tool_input;
|
|
106
|
+
if (e.tool_name !== "Bash" || typeof input !== "object" || input === null) {
|
|
107
|
+
return { ok: false, reason: "not_background_bash" };
|
|
108
|
+
}
|
|
109
|
+
const toolInput = input;
|
|
110
|
+
if (toolInput.run_in_background !== true)
|
|
111
|
+
return { ok: false, reason: "not_background_bash" };
|
|
112
|
+
if (!nonEmptyString(e.tool_use_id))
|
|
113
|
+
return { ok: false, reason: "no_tool_use_id" };
|
|
114
|
+
return {
|
|
115
|
+
ok: true,
|
|
116
|
+
change: {
|
|
117
|
+
kind: "bash",
|
|
118
|
+
activityId: e.tool_use_id,
|
|
119
|
+
agentType: null,
|
|
120
|
+
description: nonEmptyString(toolInput.description) ? toolInput.description : null,
|
|
121
|
+
startedAt: eventAtMs,
|
|
122
|
+
// No hook ever refreshes or ends a background shell (see the module docblock), so
|
|
123
|
+
// its liveness is its start and `finishedAt` stays null until the dispatch ends.
|
|
124
|
+
lastActivityAt: eventAtMs,
|
|
125
|
+
finishedAt: null,
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
// A SubagentStop from one of Claude Code's own internal agents carries no `agent_id`: it names
|
|
130
|
+
// no activity, so it is refused here (the plugin leaves a failure trace) and nothing is
|
|
131
|
+
// written — a stop must never invent a row.
|
|
132
|
+
if (!nonEmptyString(e.agent_id) || e.agent_id === SUBAGENT_PREFIX)
|
|
133
|
+
return { ok: false, reason: "no_agent_id" };
|
|
134
|
+
const activityId = subagentActivityId(e.agent_id);
|
|
135
|
+
if (event === "SubagentStop" && e.agent_transcript_path !== undefined) {
|
|
136
|
+
// The one place the hook names the sub-agent's own file: if it disagrees with the
|
|
137
|
+
// key derived from `agent_id`, this process would upsert a duplicate row. Refuse.
|
|
138
|
+
const file = typeof e.agent_transcript_path === "string" ? win32.basename(e.agent_transcript_path, ".jsonl") : null;
|
|
139
|
+
if (file !== activityId)
|
|
140
|
+
return { ok: false, reason: "agent_id_mismatch" };
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
ok: true,
|
|
144
|
+
change: {
|
|
145
|
+
kind: "agent",
|
|
146
|
+
activityId,
|
|
147
|
+
agentType: nonEmptyString(e.agent_type) ? e.agent_type : null,
|
|
148
|
+
description: null,
|
|
149
|
+
startedAt: eventAtMs,
|
|
150
|
+
lastActivityAt: eventAtMs,
|
|
151
|
+
finishedAt: event === "SubagentStop" ? eventAtMs : null,
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* One authenticated POST, with a hard timeout, classified into the output shape this
|
|
157
|
+
* subcommand always prints — never a throw. Status is checked before any body is parsed
|
|
158
|
+
* (an HTML 5xx page must read as `http_error`, not `bad_response`); only a 409 needs its
|
|
159
|
+
* body, to tell "no plan" from "a worker's session".
|
|
160
|
+
*/
|
|
161
|
+
export async function postActivity(options, change, deps) {
|
|
162
|
+
const sent = await sendSessionRequest(options, ACTIVITY_PATH, { method: "POST", body: JSON.stringify({ activities: [change] }) }, deps);
|
|
163
|
+
if ("reason" in sent)
|
|
164
|
+
return sent;
|
|
165
|
+
const { status, text } = sent;
|
|
166
|
+
if (status === 401 || status === 403)
|
|
167
|
+
return { ok: false, reason: "unauthorized" };
|
|
168
|
+
if (status === 409) {
|
|
169
|
+
const parsed = tryParseJsonBody(text);
|
|
170
|
+
const error = parsed.ok ? parsed.body?.error : undefined;
|
|
171
|
+
if (error === "session_not_connected" || error === "session_is_worker")
|
|
172
|
+
return { ok: false, reason: error };
|
|
173
|
+
return { ok: false, reason: "http_error" };
|
|
174
|
+
}
|
|
175
|
+
if (status < 200 || status >= 300)
|
|
176
|
+
return { ok: false, reason: "http_error" };
|
|
177
|
+
const parsed = tryParseJsonBody(text);
|
|
178
|
+
if (!parsed.ok)
|
|
179
|
+
return { ok: false, reason: "bad_response" };
|
|
180
|
+
const body = parsed.body;
|
|
181
|
+
if (typeof body?.runningActivities !== "number")
|
|
182
|
+
return { ok: false, reason: "bad_response" };
|
|
183
|
+
return { ok: true, runningActivities: body.runningActivities };
|
|
184
|
+
}
|
|
185
|
+
/** The bin's `activity` subcommand, wired to the real process. Always resolves to exit code 0. */
|
|
186
|
+
export async function runActivityCommand(argv, env = process.env,
|
|
187
|
+
/** Test seam: the home the session's connection record is read from. */
|
|
188
|
+
resolveFrom = {},
|
|
189
|
+
/** Test seam: the hook envelope's text (the real process reads stdin). */
|
|
190
|
+
readEnvelope = () => readFileSync(0, "utf8")) {
|
|
191
|
+
const write = (output) => {
|
|
192
|
+
process.stdout.write(`${JSON.stringify(output)}\n`);
|
|
193
|
+
};
|
|
194
|
+
const usageFailure = (detail) => {
|
|
195
|
+
write({ ok: false, reason: "usage" });
|
|
196
|
+
process.stderr.write(`${detail}\n${USAGE}\n`);
|
|
197
|
+
return 0;
|
|
198
|
+
};
|
|
199
|
+
if (argv.length !== 2)
|
|
200
|
+
return usageFailure("expected exactly two arguments");
|
|
201
|
+
const event = ACTIVITY_HOOK_EVENTS.includes(argv[0]) ? argv[0] : null;
|
|
202
|
+
if (event === null)
|
|
203
|
+
return usageFailure(`unknown hook event "${argv[0]}"`);
|
|
204
|
+
const eventAt = parseEventAtArg(argv[1]);
|
|
205
|
+
if (typeof eventAt === "object")
|
|
206
|
+
return usageFailure(eventAt.error);
|
|
207
|
+
let envelope;
|
|
208
|
+
try {
|
|
209
|
+
envelope = JSON.parse(readEnvelope());
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
write({ ok: false, reason: "bad_envelope" });
|
|
213
|
+
process.stderr.write("the hook envelope on stdin is not JSON\n");
|
|
214
|
+
return 0;
|
|
215
|
+
}
|
|
216
|
+
const mapped = activityChangeFromHook(event, envelope, Date.parse(eventAt));
|
|
217
|
+
if (!mapped.ok) {
|
|
218
|
+
write(mapped);
|
|
219
|
+
process.stderr.write(`${event} envelope reports no activity: ${mapped.reason}\n`);
|
|
220
|
+
return 0;
|
|
221
|
+
}
|
|
222
|
+
// The connection record is this session's: an envelope for ANOTHER session must never
|
|
223
|
+
// be reported under it.
|
|
224
|
+
const envelopeSession = envelope.session_id;
|
|
225
|
+
if (envelopeSession !== undefined && envelopeSession !== env.CLAUDE_CODE_SESSION_ID) {
|
|
226
|
+
write({ ok: false, reason: "session_mismatch" });
|
|
227
|
+
process.stderr.write("the envelope's session_id is not CLAUDE_CODE_SESSION_ID\n");
|
|
228
|
+
return 0;
|
|
229
|
+
}
|
|
230
|
+
const resolved = await resolveSessionOptions(env, resolveFrom, USAGE);
|
|
231
|
+
if (!resolved.ok) {
|
|
232
|
+
// A session that never `plan_connect`-ed has no connection record: the ordinary
|
|
233
|
+
// "not on a plan" case, silent for the plugin.
|
|
234
|
+
write(resolved.output);
|
|
235
|
+
process.stderr.write(`${resolved.detail}\n`);
|
|
236
|
+
return 0;
|
|
237
|
+
}
|
|
238
|
+
write(await postActivity(resolved.options, mapped.change, {
|
|
239
|
+
fetch: (input, init) => fetch(input, init),
|
|
240
|
+
requestTimeoutMs: ACTIVITY_REQUEST_TIMEOUT_MS,
|
|
241
|
+
}));
|
|
242
|
+
return 0;
|
|
243
|
+
}
|
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.
|
|
3
|
+
"version": "0.1.187",
|
|
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.
|
|
34
|
+
"@thehammer/danx-session-telemetry": "0.1.19",
|
|
35
35
|
"zod": "^3.25.76"
|
|
36
36
|
},
|
|
37
37
|
"devDependencies": {
|