@thehammer/danx-dashboard-mcp 0.1.228 → 0.1.230

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
@@ -23,19 +23,26 @@ An operator session has no stored credential. Signed out, `plan_connect` (option
23
23
  - **A 401 on the stored key** deletes the file and returns the sign-in halt (in `plan_connect`: a new request in the same call). That is a key that lapsed. A key a PERSON revoked is answered `401 key_revoked` (DX-4418): the key file is deleted and EVERY dashboard tool, `plan_connect` included, returns one stop halt (who revoked it and when; commit work in progress and end; a dispatched agent ends with `danxbot_complete`), for the rest of the process. No access request is made. `danxbot_complete` and the other worker lifecycle tools do not use the dashboard key, so a dispatched agent can still end. The session's event bridge ends with the stop reason `key_revoked` the same way. **A 404 on create** means the dashboard predates request-and-approve and says so.
24
24
  - A dispatch's `DANX_DASHBOARD_CREDENTIAL` skips all of this. There is no token-file credential source.
25
25
 
26
+ ### Asking for one more permission (DX-4434)
27
+
28
+ A call the dashboard refuses `403 {error: "forbidden", permission}` means the key lacks that one permission. `request_permission({permissions, reason})` asks the key's owner to grant it: it posts `POST /api/permission-requests` (the key's own bearer, through `SessionAccess.guard`, so a revoked or signed-out session never asks) and answers at once `{state: "approval_required", approvalUrl, confirmCode, expiresAt, instruction}`. The agent shows the person the code and URL and keeps working; this process polls `POST /api/permission-requests/:publicId/claim` in the background (`src/permission-request.ts`, the same poll shape as above) and a grant is live on the key's very next call, with the same key. The dashboard does not dedupe, so this server keeps one in-flight request per permission set (sorted, de-duplicated): the same set again while pending answers `approval_pending` with the original code and URL and posts nothing. A decided, expired or reaped request frees its set so a later call asks again. A dashboard 4xx on the create (`unknown_permission`, `already_held`, `owner_lacks_permission`, `key_not_live`, 429) is returned verbatim as `{ok: false, status, body}`; 5xx throws.
29
+
30
+ The hint: every dashboard result that is `403 {error: "forbidden", permission}` (and only that) gains one `reminders[]` entry naming the permission, `request_permission`, and saying to raise a problem on the card if blocked (`src/forbidden-hint.ts`, applied once in `guardedCall` in `src/index.ts`).
31
+
26
32
  ## Tool surface (DX-3465 — one generic tool, not one per operation)
27
33
 
28
- All exposed as `mcp__danx-dashboard__<name>` once wired through the workspace `mcp.template.json`. There is no per-operation tool any more (no `issue_get`, `issue_create`, `plan_add_note`, …) — three tools cover the whole dashboard API:
34
+ All exposed as `mcp__danx-dashboard__<name>` once wired through the workspace `mcp.template.json`. There is no per-operation tool any more (no `issue_get`, `issue_create`, `plan_add_note`, …) — three tools cover the whole dashboard API (a fourth, `request_permission`, asks for more access):
29
35
 
30
36
  | Tool | Purpose |
31
37
  |---|---|
32
38
  | `danxbot_api` | `{method, path, query?, body?}` — calls any dashboard route the caller's credential may reach (card/plan CRUD, comments, dependencies, problems/solutions, quality gates, retro, triage, transitions, …). Returns `{ok, status, body}`; `body` carries the route's own response, or its structured error verbatim when `ok:false`. While this session is plan-connected, the dashboard's own reminders for it (today: `plan_list.listener_unattached`, attached by the server to `GET /api/plans` when the event listener is unattached past the startup grace, DX-3997) are relayed into the body's `reminders[]`; the MCP carries no reminder text or state of its own and re-reads at most every 30s. |
33
39
  | `danxbot_api_list` | No args. Lists the routes this caller's credential may reach, one line each (`{method, path, summary, permission}`) — call this first when you don't know the route for what you need to do. |
34
40
  | `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. |
41
+ | `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. |
35
42
 
36
43
  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.
37
44
 
38
- A dedicated tool survives only for something about the agent itself, never a card/plan operation: `plan_connect` (binds the session to a plan; the event-bridge hook keys on it) and the per-dispatch worker callbacks (`danxbot_complete`, `danxbot_slack_reply`, `danxbot_slack_post_update`, `load_template_app`/`save_template_app`, `quality_gate_complete`, `link_thread_to_issue`, `attach_file`, the `slack_recovery_*` trio, `danxbot_set_evaluator_summary`) — see `src/lifecycle.ts`. The live list is always `tool-defs.json` in this package, regenerated by `npm run gen-tool-defs` — read it rather than trusting a list copied into prose.
45
+ A dedicated tool survives only for something about the agent itself, never a card/plan operation: `request_permission` (asks for a permission its key lacks), `plan_connect` (binds the session to a plan; the event-bridge hook keys on it) and the per-dispatch worker callbacks (`danxbot_complete`, `danxbot_slack_reply`, `danxbot_slack_post_update`, `load_template_app`/`save_template_app`, `quality_gate_complete`, `link_thread_to_issue`, `attach_file`, the `slack_recovery_*` trio, `danxbot_set_evaluator_summary`) — see `src/lifecycle.ts`. The live list is always `tool-defs.json` in this package, regenerated by `npm run gen-tool-defs` — read it rather than trusting a list copied into prose.
39
46
 
40
47
  Card and plan reads/writes go through `danxbot_api` against the issue/plan route families documented in `GET /api/openapi` (via `danxbot_api_spec`) — `POST /api/issues`, `GET /api/issues/:id` (or `/batch` for many at once, DX-2727), `PATCH /api/issues/:id/edit`, `POST /api/issues/:id/transition`, the problems/solutions/dependencies/comments/quality-gates/retro sub-resource routes, and the equivalent `/api/plans/*` family (`GET /api/plans/:planId` — cheap scalars by default, `fields` opts into `cards`/`records`/`architecture`/`session_history`/`worker_dispatches`; `GET /api/plans` for the list, with an optional `status` filter over the same computed `complete`/`awaiting-session`/`building`/`planning` value `GET /api/plans/:planId` always returns ungated).
41
48
 
package/dist/bridge.js CHANGED
@@ -681,8 +681,9 @@ export async function runBridge(options, deps) {
681
681
  ? { state: "stopped", reason: stoppedReason ?? undefined }
682
682
  : { state: "reconnecting" };
683
683
  // DX-4461 - a rename reaches the dashboard without the agent: the title in the session's transcript rides the heartbeat
684
- // when it differs from the one the dashboard last answered with. Until an answer carries a title (an older dashboard
685
- // never does) nothing is sent, since that dashboard refuses the `title` key and a refused heartbeat is a dead listener.
684
+ // when it differs from the one the dashboard last answered with. Only the LATEST landed answer counts: one without a
685
+ // title (an older dashboard, including one rolled back to mid-session, or a refusal) stops the title until an answer
686
+ // carries one again, since an older dashboard refuses the `title` key and a refused heartbeat is a dead listener.
686
687
  const titleReader = options.transcriptPath !== undefined ? new TranscriptTitleReader(options.transcriptPath) : null;
687
688
  let dashboardTitle;
688
689
  let lastTitleError = null;
@@ -710,8 +711,8 @@ export async function runBridge(options, deps) {
710
711
  const title = titleToReport();
711
712
  void sessionStats.tick(async (stats) => {
712
713
  const answer = await sendHeartbeat(options, deps, connectionNow(), stats, title);
713
- if (answer.title !== null)
714
- dashboardTitle = answer.title;
714
+ if (answer.landed)
715
+ dashboardTitle = answer.title ?? undefined;
715
716
  // DX-4418 - a person revoked this session's key: tell the session to stop, at once and only once, as an ordinary event
716
717
  // line (the same delivery the silent-failure notice below uses). Its next dashboard call says the same.
717
718
  if (answer.revocation !== null && !revocationAnnounced) {
@@ -0,0 +1,23 @@
1
+ export const FORBIDDEN_HINT_KEY = "forbidden.request_permission";
2
+ function hintText(permission) {
3
+ return (`Refused: your key lacks the permission \`${permission}\`. Call \`request_permission({permissions: ["${permission}"], reason})\` ` +
4
+ "to ask a person to grant it (it answers at once; keep working on anything else). If you are blocked without it, " +
5
+ "raise a problem on the card: a person sees problems in Needs You.");
6
+ }
7
+ /** `result` with the hint appended to its `reminders[]` when it is a `403 forbidden` naming a permission, else `result` itself. */
8
+ export function withForbiddenHint(result) {
9
+ if (result.ok || result.status !== 403)
10
+ return result;
11
+ const body = result.body;
12
+ if (typeof body !== "object" || body === null || Array.isArray(body))
13
+ return result;
14
+ const { error, permission, reminders } = body;
15
+ if (error !== "forbidden" || typeof permission !== "string" || permission === "")
16
+ return result;
17
+ if (reminders !== undefined && !Array.isArray(reminders))
18
+ return result;
19
+ const existing = (reminders ?? []);
20
+ if (existing.some((r) => r?.key === FORBIDDEN_HINT_KEY))
21
+ return result;
22
+ return { ...result, body: { ...body, reminders: [...existing, { key: FORBIDDEN_HINT_KEY, text: hintText(permission) }] } };
23
+ }
package/dist/index.js CHANGED
@@ -23,6 +23,11 @@
23
23
  * first to find the route for something you need to do.
24
24
  * - danxbot_api_spec discovers the exact request/response shape of any route
25
25
  * `danxbot_api` can call, before calling it.
26
+ * - request_permission POST /api/permission-requests (DX-4434) — asks a person to grant
27
+ * this key ONE MORE permission while the agent works; answers at once
28
+ * with the approval URL and confirm code, and the grant is picked up in
29
+ * the background (`permission-request.ts`). A dashboard call refused
30
+ * `403 forbidden` carries a hint to call it (`forbidden-hint.ts`).
26
31
  * - plan_connect POST /api/plan-sessions/me/plan — SESSION-BOUND (kept as a
27
32
  * dedicated tool; every plan READ or WRITE operation goes
28
33
  * through danxbot_api now, e.g. GET /api/plans/mine or
@@ -79,6 +84,8 @@ import { RESTART_NOTICE_SUBCOMMAND, runRestartNoticeCommand } from "./restart-no
79
84
  import { resolveBootCredential } from "./credential.js";
80
85
  import { SessionKeyUnusableError, deleteSessionKey } from "./session-key.js";
81
86
  import { SessionAccess } from "./session-access.js";
87
+ import { PermissionRequests } from "./permission-request.js";
88
+ import { withForbiddenHint } from "./forbidden-hint.js";
82
89
  import { readOwnVersion } from "./version-floor.js";
83
90
  import { projectKeyOf, recordSessionConnectionAfterConnect, removeSessionConnectionAfterLeave } from "./session-connection.js";
84
91
  import { createListenerReminder, isPlanConnectedOnThisMachine, } from "./listener-reminder.js";
@@ -129,6 +136,8 @@ let listenerReminder;
129
136
  * credential itself), written to the session's connection record on a successful `plan_connect`.
130
137
  */
131
138
  let access;
139
+ // DX-4434 — late-bound with `access` and `client`; see `permission-request.ts`.
140
+ let permissionRequests;
132
141
  /**
133
142
  * Compose the dispatch's qualified board id (`<repo>:<slug>`) from the two env
134
143
  * halves the worker injects and build the HTTP client. DANXBOT_BOARD_NAME
@@ -163,6 +172,11 @@ function boot() {
163
172
  initial: resolveInitialCredential(dashboardUrl, session?.id),
164
173
  log: (line) => console.error(line),
165
174
  });
175
+ permissionRequests = new PermissionRequests({
176
+ access,
177
+ request: (args) => dashboardClient.request(args),
178
+ log: (line) => console.error(line),
179
+ });
166
180
  listenerReminder = createListenerReminder({
167
181
  sessionId: config.session?.id,
168
182
  fetchHealth: () => dashboardClient.request({ method: "GET", basePath: "", path: "/api/plans", query: { limit: 1 } }),
@@ -290,14 +304,22 @@ function accessReply(ready, note) {
290
304
  const answer = note ? { ...ready.answer, note } : ready.answer;
291
305
  return ready.isError ? haltResult(JSON.stringify(answer)) : jsonResult(answer);
292
306
  }
307
+ /**
308
+ * DX-4390 — a dashboard call behind the sign-in halt (and the 401 sign-out). DX-4434: the ONE place a refused call's
309
+ * `403 forbidden` gains its `request_permission` hint, so every tool that returns a dashboard result carries it.
310
+ */
311
+ async function guardedCall(run) {
312
+ const guarded = await access.guard(run);
313
+ return "halt" in guarded ? guarded : { result: withForbiddenHint(guarded.result) };
314
+ }
293
315
  /** DX-4390 — a tool's dashboard call behind the sign-in halt (and the 401 sign-out). */
294
316
  async function guardedJson(run) {
295
- const guarded = await access.guard(run);
317
+ const guarded = await guardedCall(run);
296
318
  return "halt" in guarded ? haltResult(guarded.halt) : jsonResult(guarded.result);
297
319
  }
298
320
  /** DX-4400 — `guardedJson` for the read tools whose answer can be a whole document: bounded. */
299
321
  async function guardedBoundedJson(run) {
300
- const guarded = await access.guard(run);
322
+ const guarded = await guardedCall(run);
301
323
  return "halt" in guarded ? haltResult(guarded.halt) : boundedJsonResult(guarded.result);
302
324
  }
303
325
  function jsonResult(value) {
@@ -415,7 +437,7 @@ async (args) => {
415
437
  const refused = termsAcceptanceCallHalt(args, config.baseUrl);
416
438
  if (refused)
417
439
  return haltResult(refused);
418
- const guarded = await access.guard(() => danxbotApi(client, args));
440
+ const guarded = await guardedCall(() => danxbotApi(client, args));
419
441
  if ("halt" in guarded)
420
442
  return haltResult(guarded.halt);
421
443
  const result = guarded.result;
@@ -463,6 +485,29 @@ strictTool("attach_file", "Attach a LOCAL file to a card. This server reads and
463
485
  content_type: z.string().min(1).optional().describe("Override for the stored MIME type; default: an extension guess."),
464
486
  ...boardField,
465
487
  }, async (args) => guardedJson(() => attachFile(client, args)));
488
+ // ---------------- request_permission (DX-4434, parent DX-4432) ----------------
489
+ strictTool("request_permission", "Ask a person to grant this key ONE MORE permission. Call it when a call was refused `403 forbidden` naming a " +
490
+ "`permission`, or when you know you need one more. It returns AT ONCE with `{state, approvalUrl, confirmCode, " +
491
+ "expiresAt, instruction}`: show the person the code and URL, and keep working on anything else. If they approve, a " +
492
+ "later call simply succeeds with the same key (no new key, no re-auth). The same permissions asked again while " +
493
+ "pending return the pending request. A refusal (unknown permission, already held, rate limited) comes back as " +
494
+ "`{ok:false, status, body}`. If you are blocked without it, raise a problem on the card.", {
495
+ permissions: z
496
+ .array(z.string().min(1))
497
+ .min(1)
498
+ .describe("The permission keys to ask for: the `permission` a `403 forbidden` named."),
499
+ reason: z.string().min(1).describe("Why you need them; shown to the person who approves."),
500
+ }, async (args) => {
501
+ const asked = await permissionRequests.ask(args);
502
+ if ("halt" in asked)
503
+ return haltResult(asked.halt);
504
+ if ("result" in asked) {
505
+ // DX-4148 — a terms refusal halts here as on every dashboard call.
506
+ const halt = termsHaltFor(asked.result);
507
+ return halt ? haltResult(halt) : jsonResult(asked.result);
508
+ }
509
+ return jsonResult(asked.answer);
510
+ });
466
511
  // ---------------- plans (DX-2683) ----------------
467
512
  // THE BINDING ASYMMETRY IS IN THE SCHEMAS, NOT IN PROSE. `GET /api/plans/:planId`
468
513
  // takes a plan id in its path; the plan records, plan cards and
@@ -0,0 +1,141 @@
1
+ import { POLL_GRACE_MS, defaultSleep } from "./session-access.js";
2
+ import { McpOutdatedError } from "./version-floor.js";
3
+ const PERMISSION_REQUESTS_BASE_PATH = "/api/permission-requests";
4
+ const APPROVAL_REQUIRED_INSTRUCTION = "Show the person the approval URL and the confirm code (they check the code matches, then approve). Keep working on anything " +
5
+ "else: this call has returned and nothing waits. If they approve, this process picks the grant up in the background and a " +
6
+ "later call simply succeeds with the same key (no new key, no re-auth). If you are blocked without it, raise a problem on " +
7
+ "the card; a person sees problems in Needs You.";
8
+ const APPROVAL_PENDING_INSTRUCTION = "This permission is already asked for and not yet decided. Remind the person of the confirm code and the approval URL; keep " +
9
+ "working on anything else. A grant is picked up in the background and a later call simply succeeds with the same key. If you " +
10
+ "are blocked without it, raise a problem on the card.";
11
+ /** The dedupe set: sorted and de-duplicated, so the order the agent listed them in never matters. */
12
+ function setOf(permissions) {
13
+ return [...new Set(permissions)].sort();
14
+ }
15
+ function isCreated(value) {
16
+ if (typeof value !== "object" || value === null)
17
+ return false;
18
+ const v = value;
19
+ return (typeof v.publicId === "string" &&
20
+ typeof v.confirmCode === "string" &&
21
+ typeof v.approvalUrl === "string" &&
22
+ typeof v.expiresAt === "string" &&
23
+ Number.isFinite(Date.parse(v.expiresAt)) &&
24
+ typeof v.pollMs === "number");
25
+ }
26
+ /** A 429's own `retryAfterMs`, else 0 (the poll then waits its normal interval). */
27
+ function retryAfterMsOf(body) {
28
+ const ms = body?.retryAfterMs;
29
+ return typeof ms === "number" && Number.isFinite(ms) && ms > 0 ? ms : 0;
30
+ }
31
+ export class PermissionRequests {
32
+ deps;
33
+ pending = new Map();
34
+ queue = Promise.resolve();
35
+ now;
36
+ sleep;
37
+ constructor(deps) {
38
+ this.deps = deps;
39
+ this.now = deps.now ?? Date.now;
40
+ this.sleep = deps.sleep ?? defaultSleep;
41
+ }
42
+ ask(args) {
43
+ const run = this.queue.then(() => this.askNow(args));
44
+ this.queue = run.catch(() => undefined);
45
+ return run;
46
+ }
47
+ async askNow(args) {
48
+ const permissions = setOf(args.permissions);
49
+ const key = JSON.stringify(permissions);
50
+ const existing = this.pending.get(key);
51
+ // DX-4418: a revoked or signed-out session never asks and never reads as pending; `guard` below answers its halt.
52
+ if (existing !== undefined && this.deps.access.isSignedIn()) {
53
+ return { answer: this.answer("approval_pending", existing) };
54
+ }
55
+ const guarded = await this.deps.access.guard(() => this.deps.request({ method: "POST", basePath: PERMISSION_REQUESTS_BASE_PATH, path: "", body: { permissions, reason: args.reason } }));
56
+ if ("halt" in guarded)
57
+ return { halt: guarded.halt };
58
+ const result = guarded.result;
59
+ // The dashboard's own 4xx (400, 409, 429 ...) goes back as it came: no retry, no default.
60
+ if (!result.ok)
61
+ return { result };
62
+ if (!isCreated(result.body)) {
63
+ throw new Error(`[danx-dashboard-mcp] POST ${PERMISSION_REQUESTS_BASE_PATH} answered without the request fields: ${JSON.stringify(result.body)}`);
64
+ }
65
+ const created = result.body;
66
+ this.pending.set(key, created);
67
+ void this.pollUntilDecided(key, created);
68
+ return { answer: this.answer("approval_required", created) };
69
+ }
70
+ answer(state, request) {
71
+ return {
72
+ state,
73
+ approvalUrl: request.approvalUrl,
74
+ confirmCode: request.confirmCode,
75
+ expiresAt: request.expiresAt,
76
+ instruction: state === "approval_required" ? APPROVAL_REQUIRED_INSTRUCTION : APPROVAL_PENDING_INSTRUCTION,
77
+ };
78
+ }
79
+ /**
80
+ * Polls until the request is decided or lapsed, then frees its set so a later call may ask again. Every ending is final: a
81
+ * halt (revoked, signed out, key lapsed) stops it silently, since the session no longer holds a key that could be granted.
82
+ */
83
+ async pollUntilDecided(key, request) {
84
+ try {
85
+ await this.poll(request);
86
+ }
87
+ catch (err) {
88
+ this.deps.log?.(`[danx-dashboard-mcp] permission request poll ended: ${err.message}`);
89
+ }
90
+ finally {
91
+ if (this.pending.get(key) === request)
92
+ this.pending.delete(key);
93
+ }
94
+ }
95
+ async poll(request) {
96
+ const giveUpAt = Date.parse(request.expiresAt) + POLL_GRACE_MS;
97
+ let waitMs = request.pollMs;
98
+ for (;;) {
99
+ await this.sleep(waitMs);
100
+ waitMs = request.pollMs;
101
+ if (this.now() > giveUpAt)
102
+ return;
103
+ let guarded;
104
+ try {
105
+ // `guard` runs before EVERY claim (DX-4418): a revoked or signed-out session answers its halt without a request.
106
+ guarded = await this.deps.access.guard(() => this.deps.request({ method: "POST", basePath: PERMISSION_REQUESTS_BASE_PATH, path: `/${request.publicId}/claim` }));
107
+ }
108
+ catch (err) {
109
+ // An outdated MCP will never get a different answer; any other failure is retried until the request lapses.
110
+ if (err instanceof McpOutdatedError)
111
+ throw err;
112
+ this.deps.log?.(`[danx-dashboard-mcp] permission request poll failed, retrying: ${err.message}`);
113
+ continue;
114
+ }
115
+ if ("halt" in guarded)
116
+ return;
117
+ const claim = guarded.result;
118
+ if (claim.status === 429) {
119
+ waitMs = Math.max(request.pollMs, retryAfterMsOf(claim.body));
120
+ continue;
121
+ }
122
+ if (claim.status === 404)
123
+ return; // unknown or already reaped: nothing left to wait for
124
+ if (!claim.ok) {
125
+ throw new Error(`the dashboard refused the claim (${claim.status}): ${JSON.stringify(claim.body)}`);
126
+ }
127
+ const status = claim.body?.status;
128
+ switch (status) {
129
+ case "pending":
130
+ continue;
131
+ case "approved":
132
+ case "claimed":
133
+ case "denied":
134
+ case "expired":
135
+ return;
136
+ default:
137
+ throw new Error(`the dashboard answered the claim with an unknown status: ${JSON.stringify(claim.body)}`);
138
+ }
139
+ }
140
+ }
141
+ }
@@ -29,7 +29,7 @@ import { McpOutdatedError } from "./version-floor.js";
29
29
  /** Longest a `plan_connect` call waits for an approval already asked for. */
30
30
  export const AWAIT_APPROVAL_MS = 45_000;
31
31
  /** The server reaps a lapsed request about a minute after expiry; polling past that finds nothing. */
32
- const POLL_GRACE_MS = 90_000;
32
+ export const POLL_GRACE_MS = 90_000;
33
33
  /** A live server rewrites its key file this often, so an in-use key never ages out of the 7-day prune. */
34
34
  const KEEP_KEY_FILE_EVERY_MS = 60 * 60 * 1000;
35
35
  /** The request's title when the agent gave none: the session id's first characters. */
@@ -51,7 +51,7 @@ const NO_SESSION_ID_ERROR = "this server has no CLAUDE_CODE_SESSION_ID, so it ca
51
51
  const CLAIMED_LOST_ERROR = "the dashboard says this request was already claimed, but this process never received the key; " +
52
52
  "start a new request";
53
53
  const EXPIRED_NOTE = "The previous request expired before it was approved.";
54
- const defaultSleep = (ms) => new Promise((resolve) => {
54
+ export const defaultSleep = (ms) => new Promise((resolve) => {
55
55
  setTimeout(resolve, ms).unref();
56
56
  });
57
57
  const notReady = (answer, isError, halt) => ({
@@ -109,7 +109,11 @@ function parseTitleRecord(line, path) {
109
109
  const trimmed = customTitle.trim();
110
110
  return trimmed === "" ? null : boundTitle(trimmed);
111
111
  }
112
- /** At most {@link SESSION_TITLE_MAX_CHARS} UTF-16 units, cut on a code-point boundary. */
112
+ /**
113
+ * At most {@link SESSION_TITLE_MAX_CHARS} UTF-16 units, cut on a code-point boundary. A cut that lands after a space is
114
+ * trimmed again: the dashboard stores the trimmed title, so an untrimmed one would never equal it and the bridge would
115
+ * resend it on every heartbeat.
116
+ */
113
117
  function boundTitle(title) {
114
118
  if (title.length <= SESSION_TITLE_MAX_CHARS)
115
119
  return title;
@@ -119,5 +123,5 @@ function boundTitle(title) {
119
123
  break;
120
124
  out += point;
121
125
  }
122
- return out;
126
+ return out.trimEnd();
123
127
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thehammer/danx-dashboard-mcp",
3
- "version": "0.1.228",
3
+ "version": "0.1.230",
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.23",
34
+ "@thehammer/danx-session-telemetry": "0.1.25",
35
35
  "zod": "^3.25.76"
36
36
  },
37
37
  "devDependencies": {