@thehammer/danx-dashboard-mcp 0.1.282 → 0.1.284

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 CHANGED
@@ -50,7 +50,7 @@ All exposed as `mcp__danx-dashboard__<name>` once wired through the workspace `m
50
50
  | `danxbot_api_list` | No args. Lists the routes this caller's credential may reach as `{routes: ["METHOD /path"]}` (one bare string per route, exactly what `danxbot_api_spec` takes; a dispatch's own callbacks are in it) — call this first when you don't know the route for what you need to do. |
51
51
  | `danxbot_api_spec` | `{endpoints}` (comma-joined `METHOD /path` list, required, min 1). Returns the exact OpenAPI contract for those routes — call this when you know the route but not its exact request/response shape. |
52
52
  | `attach_file` | `{card_id, path, replaces?, primary?, filename?, content_type?}` — the server reads the LOCAL file at `path` (absolute, `/tmp/a.png` or `C:\x\a.png`) and uploads it, so its bytes never pass through the agent's context. `replaces: <attachment id>` swaps that attachment's file in place (`PUT /api/issues/:id/attachments/:aid`: same id, name and cover flag) instead of adding one; `primary: true` makes it the card's single cover, atomically with the write. Reply: `{issue: <compact row>, attachment_id, url, thumbnail_url, is_primary}`. Shown to a key holding `cards.edit` (DX-4523); a dispatch profile still receives it only when its catalog selection names it (`slack-agent` and `work`, DX-4386, through `extraTools`). A screenshot of browser-visible work: first `primary: true`, then `replaces` at each milestone. |
53
- | `design_publish` | `{target, dir, card_id?, board?, title?, summary?, description?}` — builds a design with the TARGET repo's own Vite (resolved from the target's root at run time: this package depends on neither `vite` nor `playwright`) and publishes it as a revision of a Design card (DX-4077). `target` names one of the repo's design targets, read from the dashboard (`GET /api/repos/<repo>`, edited in Settings > Board > General), never from a file; `dir` holds `Design.tsx` under the target's root. The repo is the board's `repo_name` (`GET /api/boards/<board>`). The build runs in `<root>/.danx-design/<build-id>/` (removed afterwards), the page is written to `<tmpdir>/danx-design/<build-id>.html` before the upload, and a 1280x800 screenshot is taken when Playwright resolves from the target or this server's cwd (`screenshot: "none: <reason>"` otherwise, never a failed publish). Without `card_id` it creates the Design card first; a refused revision comes back verbatim with that `card_id`. Shown to a key holding `cards.edit`; opt-in, so a dispatch profile receives it only when its catalog selection names it (none does yet). |
53
+ | `design_publish` | `{target, dir, card_id?, board?, title?, summary?, description?}` — builds a design with the TARGET repo's own Vite (resolved from the target's root at run time: this package depends on neither `vite` nor `playwright`) and publishes it as a revision of a Design card (DX-4077). `target` names one of the repo's design targets, read from the dashboard (`GET /api/repos/<repo>`, edited in Settings > Board > General), never from a file; `dir` holds `Design.tsx` under the target's root. The repo is the board's `repo_name` (`GET /api/boards/<board>`). The build runs in `<root>/.danx-design/<build-id>/` (removed afterwards), in a short-lived `node` child process that exits when the build ends, so the server never holds the design's worktree files open and no esbuild outlives the publish (DX-4727), the page is written to `<tmpdir>/danx-design/<build-id>.html` before the upload, and a 1280x800 screenshot is taken when Playwright resolves from the target or this server's cwd (`screenshot: "none: <reason>"` otherwise, never a failed publish). Without `card_id` it creates the Design card first; a refused revision comes back verbatim with that `card_id`. Shown to a key holding `cards.edit`; opt-in, so a dispatch profile receives it only when its catalog selection names it (none does yet). |
54
54
  | `request_permission` | `{permissions, reason}` — asks a person to grant this key ONE MORE permission (see "Asking for one more permission" above). Answers at once with the approval URL and confirm code; the grant is picked up in the background. |
55
55
 
56
56
  There is no dedicated `resource_fields` tool (removed DX-3465 — it was a thin wrapper `danxbot_api` already reaches with no agent-specific behavior earning it its own tool). What an opt-in field TREE may name for one resource (`{resource, description, always, hashes, fields, relations}`) is `danxbot_api({method:"GET", path:"/api/resources/<resource>/fields"})` — install-global, no `board`; `resource: "issue"` is the root, and a relation's own `resource` in the response is what to call this again with, one level deeper.
@@ -61,7 +61,7 @@ Card and plan reads/writes go through `danxbot_api` against the issue/plan route
61
61
 
62
62
  ## `plan_events_wait` — a working session's plan events (DX-4233)
63
63
 
64
- A program calls this, not a person: the danxbot Claude Code plugin's relay loop, inside the session, calls it over and over on the session's own danx-dashboard MCP server and hands every event it gets to the session. The `bridge` subcommand is gone: no process is spawned, no `npx` cold start, and nothing outlives the session, because the stream lives in the MCP server, which ends with its session.
64
+ A program calls this, not a person: the danxbot Claude Code plugin's relay loop, inside the session, calls it over and over on the session's own danx-dashboard MCP server and hands every event it gets to the session. A plan-orchestrator dispatch (a headless session that works one plan) has no plugin relay, so it keeps ONE background waiter sub-agent that loops on this tool until events exist and returns them with the last cursor, and the orchestrator passes that cursor to the next waiter it arms (DX-4667); the `plan-orchestrator` profile alone is granted the tool on a dispatch (it is an opt-in tool). The `bridge` subcommand is gone: no process is spawned, no `npx` cold start, and nothing outlives the session, because the stream lives in the MCP server, which ends with its session.
65
65
 
66
66
  Input `{plan_id: number, cursor: string | null, timeout_ms: 1000..30000, transcript_path?: string}`. Answer, one of:
67
67
 
@@ -0,0 +1,17 @@
1
+ /**
2
+ * DX-4727 - the short-lived process `buildDesign` runs the target's Vite build in. Its one argument is a JSON `ViteBuildInput`.
3
+ * Everything Vite loads (native modules, the esbuild service) belongs to this process and is released when it exits, so the
4
+ * long-lived MCP server never holds a file of the design's worktree. A failure writes its message to stderr and exits 1; the
5
+ * parent turns that text into the tool's error.
6
+ */
7
+ import { runViteBuild } from "./design-build.js";
8
+ try {
9
+ await runViteBuild(JSON.parse(process.argv[2]));
10
+ // An explicit exit: Vite's esbuild service would otherwise keep the loop alive until it is stopped. The empty write's callback
11
+ // runs once earlier stderr output (Vite's warnings) has been flushed, so the exit cannot cut it off.
12
+ process.stderr.write("", () => process.exit(0));
13
+ }
14
+ catch (error) {
15
+ // Vite's build() rethrows without logging (only its CLI logs), so the message is written once, here.
16
+ process.stderr.write(error instanceof Error ? error.message : String(error), () => process.exit(1));
17
+ }
@@ -9,12 +9,13 @@
9
9
  * This module carries no validator for the document it produces: that the document is self-contained is checked by exactly ONE
10
10
  * validator, the dashboard's own parse5 check on the revision route, whose 400 comes back to the caller verbatim.
11
11
  */
12
+ import { spawn } from "node:child_process";
12
13
  import { randomBytes } from "node:crypto";
13
14
  import { mkdir, readdir, readFile, rm, rmdir, writeFile } from "node:fs/promises";
14
15
  import { createRequire } from "node:module";
15
16
  import os from "node:os";
16
17
  import path from "node:path";
17
- import { pathToFileURL } from "node:url";
18
+ import { fileURLToPath, pathToFileURL } from "node:url";
18
19
  import { isStrictlyInside, repoRelative } from "./design-checkout.js";
19
20
  /** The directory (inside the target root) every build gets its own subdirectory of; the target's `.gitignore` names it. */
20
21
  export const BUILD_ROOT_DIR = ".danx-design";
@@ -187,9 +188,97 @@ export function assertConfigContained(merged, rootAbs, buildDir, indexHtml) {
187
188
  }
188
189
  }
189
190
  /**
190
- * Build the design: generate the three files in a fresh `<root>/.danx-design/<build-id>/`, load the target's Vite and its config,
191
- * merge the overrides, guard, build, read the output, fold it into one document. The build dir is removed in a `finally`; a failed
192
- * cleanup never replaces the error that came first.
191
+ * Run the target's Vite build over the generated files: load its config, merge the overrides, guard, build. This runs ONLY inside the
192
+ * short-lived build child (`design-build-child.ts`), never in the MCP server: Vite loads native modules (rollup, lightningcss,
193
+ * @tailwindcss/oxide) and starts an esbuild service, and in the long-lived server those stayed locked / running after the publish and
194
+ * stranded the design's worktree on Windows (DX-4727).
195
+ */
196
+ export async function runViteBuild(input) {
197
+ const { rootAbs, buildDir, indexHtml } = input;
198
+ const vite = await loadTargetVite(rootAbs);
199
+ // "warn": Vite logs info to stdout; warnings and errors go to stderr, which the parent relays.
200
+ const loaded = await vite.loadConfigFromFile({ command: "build", mode: "production" }, input.viteConfigAbs, rootAbs, "warn");
201
+ if (loaded === null)
202
+ throw new Error(`design_publish: Vite found no config at ${input.viteConfigAbs}`);
203
+ const merged = vite.mergeConfig(loaded.config, {
204
+ // The repo's config is already loaded: the build must not read it a second time.
205
+ configFile: false,
206
+ root: rootAbs,
207
+ base: "./",
208
+ logLevel: "warn",
209
+ cacheDir: path.join(buildDir, VITE_CACHE_DIR),
210
+ build: {
211
+ outDir: path.join(buildDir, OUT_DIR),
212
+ assetsInlineLimit: () => true,
213
+ cssCodeSplit: false,
214
+ modulePreload: false,
215
+ emptyOutDir: false,
216
+ reportCompressedSize: false,
217
+ rollupOptions: { input: indexHtml, output: { inlineDynamicImports: true } },
218
+ },
219
+ });
220
+ assertConfigContained(merged, rootAbs, buildDir, indexHtml);
221
+ await vite.build(merged);
222
+ }
223
+ /** The build child's entry: `design-build-child.js` once compiled, `.ts` when the package runs from source (tests, `npm run dev`). */
224
+ function childEntry() {
225
+ const here = fileURLToPath(import.meta.url);
226
+ const ext = path.extname(here);
227
+ const file = path.join(path.dirname(here), `design-build-child${ext}`);
228
+ // Source runs need tsx to load the .ts child; the compiled package has no such dependency.
229
+ return { file, execArgv: ext === ".ts" ? ["--import", pathToFileURL(createRequire(import.meta.url).resolve("tsx/esm")).href] : [] };
230
+ }
231
+ /** How long the build child may run before it is killed: a hung Vite must not hold the tool call (and the design's files) forever. */
232
+ export const BUILD_TIMEOUT_MS = 5 * 60 * 1000;
233
+ /**
234
+ * Run the Vite build in a fresh `node` child and wait for it to exit. When the child exits, the OS releases every module it loaded
235
+ * and its esbuild service ends with it, so nothing of the build outlives the publish (DX-4727). A failed build rejects with everything
236
+ * the child wrote to stdout and stderr, verbatim; a child still running after `timeoutMs` is killed and rejects with the output so far;
237
+ * warnings from a good build are relayed to this process's stderr.
238
+ */
239
+ export async function runViteBuildInChild(input, timeoutMs = BUILD_TIMEOUT_MS) {
240
+ const { file, execArgv } = childEntry();
241
+ const done = await new Promise((resolve, reject) => {
242
+ const child = spawn(process.execPath, [...execArgv, file, JSON.stringify(input)], {
243
+ cwd: input.rootAbs,
244
+ stdio: ["ignore", "pipe", "pipe"],
245
+ windowsHide: true,
246
+ });
247
+ let text = "";
248
+ let timedOut = false;
249
+ for (const stream of [child.stdout, child.stderr]) {
250
+ stream.setEncoding("utf8");
251
+ stream.on("data", (chunk) => void (text += chunk));
252
+ }
253
+ const timer = setTimeout(() => {
254
+ timedOut = true;
255
+ child.kill();
256
+ }, timeoutMs);
257
+ child.once("error", (err) => {
258
+ clearTimeout(timer);
259
+ reject(err);
260
+ });
261
+ child.once("close", (code, signal) => {
262
+ clearTimeout(timer);
263
+ resolve({ code, signal, text, timedOut });
264
+ });
265
+ });
266
+ const output = done.text.trim();
267
+ if (done.timedOut) {
268
+ throw new Error(`design_publish: the build did not finish within ${Math.round(timeoutMs / 1000)}s and was killed${output === "" ? "" : `; its output so far:
269
+ ${output}`}`);
270
+ }
271
+ if (done.code !== 0) {
272
+ const ended = done.signal !== null ? `was killed by ${done.signal}` : `exited with code ${String(done.code)}`;
273
+ throw new Error(output || `design_publish: the build process ${ended} and wrote nothing`);
274
+ }
275
+ if (done.text !== "")
276
+ process.stderr.write(done.text);
277
+ }
278
+ /**
279
+ * Build the design: generate the three files in a fresh `<root>/.danx-design/<build-id>/`, run the target's Vite over them in a
280
+ * child process, read the output, fold it into one document. The build dir is removed in a `finally`; a failed cleanup never
281
+ * replaces the error that came first.
193
282
  */
194
283
  export async function buildDesign(input) {
195
284
  const buildDir = path.join(input.rootAbs, BUILD_ROOT_DIR, input.buildId);
@@ -201,32 +290,8 @@ export async function buildDesign(input) {
201
290
  await writeFile(indexHtml, files.indexHtml, "utf8");
202
291
  await writeFile(path.join(buildDir, ENTRY_FILE), files.entry, "utf8");
203
292
  await writeFile(path.join(buildDir, CSS_FILE), files.css, "utf8");
204
- const vite = await loadTargetVite(input.rootAbs);
205
- // "warn": Vite logs info to stdout, which on this stdio MCP server is the protocol channel; warnings and errors go to stderr.
206
- const loaded = await vite.loadConfigFromFile({ command: "build", mode: "production" }, input.viteConfigAbs, input.rootAbs, "warn");
207
- if (loaded === null)
208
- throw new Error(`design_publish: Vite found no config at ${input.viteConfigAbs}`);
209
- const outDir = path.join(buildDir, OUT_DIR);
210
- const merged = vite.mergeConfig(loaded.config, {
211
- // The repo's config is already loaded: the build must not read it a second time.
212
- configFile: false,
213
- root: input.rootAbs,
214
- base: "./",
215
- logLevel: "warn",
216
- cacheDir: path.join(buildDir, VITE_CACHE_DIR),
217
- build: {
218
- outDir,
219
- assetsInlineLimit: () => true,
220
- cssCodeSplit: false,
221
- modulePreload: false,
222
- emptyOutDir: false,
223
- reportCompressedSize: false,
224
- rollupOptions: { input: indexHtml, output: { inlineDynamicImports: true } },
225
- },
226
- });
227
- assertConfigContained(merged, input.rootAbs, buildDir, indexHtml);
228
- await vite.build(merged);
229
- const built = await readBuildOutput(outDir);
293
+ await runViteBuildInChild({ rootAbs: input.rootAbs, viteConfigAbs: input.viteConfigAbs, buildDir, indexHtml });
294
+ const built = await readBuildOutput(path.join(buildDir, OUT_DIR));
230
295
  return { html: inlineAssets(built.html.text, built.js, built.css), buildId: input.buildId };
231
296
  }
232
297
  catch (error) {
package/dist/index.js CHANGED
@@ -172,7 +172,7 @@ let permissionRequests;
172
172
  // DX-4523 - late-bound with `access` and `client`; see `tool-surface.ts` and `permission-watch.ts`.
173
173
  let toolAccessSync;
174
174
  let permissionWatch;
175
- // DX-4233 - late-bound with `access`; see `plan-events-relay.ts`. The plugin's relay loop calls `plan_events_wait` on it.
175
+ // DX-4233 - late-bound with `access`; see `plan-events-relay.ts`. The plugin's relay loop (a person's session) and a plan orchestrator's waiter sub-agent (DX-4667) call `plan_events_wait` on it.
176
176
  let planEventRelay;
177
177
  /**
178
178
  * Compose the dispatch's qualified board id (`<repo>:<slug>`) from the two env
@@ -788,15 +788,16 @@ async (args) => {
788
788
  });
789
789
  // ---------------- plan_events_wait (DX-4233) ----------------
790
790
  strictTool("plan_events_wait",
791
- // DX-4233 - for the danxbot plugin's relay loop, not for a model to call. It answers `events` (each `{cursor, text}`; empty after
792
- // `timeout_ms`), the cursor to send being the last delivered event's, or `stopped` with the remedy to tell the session. General-audience text
793
- // (DX-4550): nothing here names our own setup.
794
- "Waits for events on the plan this session is connected to. For the danxbot plugin's relay, which calls it in a loop " +
795
- "and hands every event to the session: a model never calls it. `plan_id` is the plan the session is on (a different " +
796
- "plan than the last call's starts clean). `cursor` is the `cursor` of the last event delivered, as answered (null " +
791
+ // DX-4233 - for the danxbot plugin's relay loop. DX-4667 - and for a background waiter sub-agent of a plan orchestrator, which
792
+ // loops on it until events exist and returns them (and the cursor) to the orchestrator it wakes. It answers `events` (each
793
+ // `{cursor, text}`; empty after `timeout_ms`), the cursor to send being the last delivered event's, or `stopped` with the remedy
794
+ // to tell the session. General-audience text (DX-4550): nothing here names our own setup.
795
+ "Waits for events on the plan this session is connected to. For a relay or a background waiter that calls it in a loop " +
796
+ "and hands every event to the session; do not call it for any other reason. `plan_id` is the plan the session is on (a " +
797
+ "different plan than the last call's starts clean). `cursor` is the `cursor` of the last event delivered, as answered (null " +
797
798
  "when none). Answers `{events: [{cursor, text}]}`, with `events` empty once `timeout_ms` passes, or `{stopped: " +
798
799
  "{reason, detail, fix}}` when the stream ended: tell the session the `fix`. An event stays until a later call's " +
799
- "`cursor` passes it. " +
800
+ "`cursor` passes it. Only one wait is pending per session: a newer call ends the older. " +
800
801
  "`transcript_path` is the session's transcript file, for its usage and title.", {
801
802
  plan_id: z.number().int().positive().describe("Id of the plan this session is connected to."),
802
803
  cursor: z
@@ -1,8 +1,10 @@
1
1
  /**
2
2
  * DX-4233 - the IN-PROCESS relay of a session's plan events: what the `plan_events_wait` tool serves.
3
3
  *
4
- * WHO CALLS IT. The danxbot plugin's relay loop, inside the Claude Code session, calls `plan_events_wait` over and over on
5
- * this session's own danx-dashboard MCP server and hands every record it gets to the session. This class is everything on the
4
+ * WHO CALLS IT. In a person's session, the danxbot plugin's relay loop calls `plan_events_wait` over and over on this
5
+ * session's own danx-dashboard MCP server and hands every record it gets to the session. In a plan orchestrator's dispatch
6
+ * (DX-4667), which loads no relay, a background waiter sub-agent calls it the same way and returns the records and the last
7
+ * cursor to the orchestrator. This class is everything on the
6
8
  * server side of that call: it opens the session's dashboard event stream (the mint / stream / heartbeat contract of
7
9
  * `session-stream.ts`), buffers records for the caller and reports the session's listener health. There is no process to start,
8
10
  * watch or kill: the stream lives exactly as long as the MCP server, which lives exactly as long as its session.
@@ -137,7 +139,7 @@ export class PlanEventRelay {
137
139
  this.pendingStop = null;
138
140
  return { stopped };
139
141
  }
140
- // DX-4233: a server holding no key answers a stop without calling the dashboard (a dispatch never reaches here).
142
+ // DX-4233: a server holding no key answers a stop without calling the dashboard (a dispatch always holds its env key, so it never takes this branch).
141
143
  if (this.deps.auth() === null) {
142
144
  return { stopped: this.stopRecord("credential_unavailable", "this session holds no dashboard key") };
143
145
  }
@@ -29,7 +29,6 @@ const BOOTSTRAP = { permission: null, callback: null };
29
29
  const needs = (permission) => ({ permission, callback: null });
30
30
  /** DX-4632: usable with no key (a session that has not signed in yet) and only on a person's session. */
31
31
  const OPERATOR_SESSION_BOOTSTRAP = { permission: null, callback: null, operatorSessionOnly: true };
32
- const operatorSessionNeeds = (permission) => ({ permission, callback: null, operatorSessionOnly: true });
33
32
  const needsAndPostsTo = (permission, callback) => ({ permission, callback });
34
33
  /**
35
34
  * Every tool this server registers, with what makes it usable. `index.ts` refuses to register a tool that is not
@@ -49,10 +48,14 @@ export const TOOL_ACCESS = {
49
48
  design_publish: needs("cards.edit"),
50
49
  // DX-4233 - the plugin's relay waits on the session's plan events here: it opens the listener ticket
51
50
  // (`POST /api/plan-sessions/me/stream-ticket`) and heartbeats (`.../me/heartbeat`), both `plans.view`.
52
- plan_events_wait: operatorSessionNeeds("plans.view"),
51
+ // DX-4667 - not operator-session-only: a plan orchestrator's dispatch calls it too, through a background waiter sub-agent that
52
+ // wakes the orchestrator when a person answers. What keeps it off every other dispatch is the opt-in (`MCP_SERVER_OPT_IN_TOOLS`,
53
+ // `src/tokens/tool-defs.ts`) granted to the `plan-orchestrator` profile alone, not this table.
54
+ plan_events_wait: needs("plans.view"),
53
55
  // DX-4632 - the plugin's session-start hook asks which plan the session this one replaced was working. A restarted session boots
54
- // signed out, so it holds no permission: like `plan_connect` it is a bootstrap tool, and (like `plan_events_wait`) a person's
55
- // session's only. The lookup asks the dashboard as the predecessor, with the credential the predecessor's record names.
56
+ // signed out, so it holds no permission: like `plan_connect` it is a bootstrap tool, and a person's session's only (a dispatch
57
+ // never loads the plugin that calls it). The lookup asks the dashboard as the predecessor, with the credential the predecessor's
58
+ // record names.
56
59
  restart_notice: OPERATOR_SESSION_BOOTSTRAP,
57
60
  // The one dispatch callback that stays a tool (DX-4525): it reads local files. Its dashboard route's own policy names the
58
61
  // permission (`route-policies/index.ts`); every other callback is a `danxbot_api` call to a route under `callback-routes.ts`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thehammer/danx-dashboard-mcp",
3
- "version": "0.1.282",
3
+ "version": "0.1.284",
4
4
  "description": "Stdio MCP server fronting danxbot's dashboard API for agents (operator sessions and dispatched workers alike): the generic danxbot_api/danxbot_api_list/danxbot_api_spec tools reach any route the caller's credential may call, plus dedicated agent-specific tools (plan_connect, request_permission, attach_file, and danxbot_slack_reply) (DX-3465, PLN-11 R-25).",
5
5
  "license": "MIT",
6
6
  "type": "module",