@thehammer/danx-dashboard-mcp 0.1.246 → 0.1.248

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
@@ -67,6 +67,10 @@ CLAUDE_CODE_SESSION_ID=<session> npx -y @thehammer/danx-dashboard-mcp@<version>
67
67
 
68
68
  Run by the danxbot plugin's SessionStart hook for a session that is **not** plan-connected. A restarted Claude Code session gets a new session id and no plan binding, so nothing told it that the plan it had been working was unattended. `plan_connect` writes the session's working directory into its connection record (`projectKey`, record schema v2); this subcommand finds the newest OTHER session's record for the same project within `STALE_RECORD_MS`, resolves that record's own credential, and asks `GET /api/plan-sessions/<predecessor>/restart-notice` which plan it was on and how many operator events wait there (the connect digest's own query, read-only). Exit 0 with text on stdout = say it (the registry's `plan_restart.waiting_events` text — this package carries no wording); exit 0 with nothing = nothing to say (never connected from this project, plan archived or deleted, record stale); exit 1 = the lookup failed. A record from an older server (schema v1) is skipped here and refused, with the fix, everywhere a session reads its OWN record.
69
69
 
70
+ ## `ready-cards` — the plan's cards anyone could pick up now (DX-4534)
71
+
72
+ Run by the danxbot plugin's Stop hook for a plan-connected session. `GET /api/plans/mine` answers the session's plan and the boards its cards live on (a session on no plan is a 409 `session_not_connected`, read as an empty answer); then, for each board, `GET /api/issues?board=` with `filter.plan_id` + `filter.status_derived` returns the plan's ToDo cards, and the ready ones are kept: a Story, Bug or Chore with no assigned agent, no live dispatch, no block, no open problem, no unmet `depends_on` and no active `conflict_on`. No dashboard route was added. Exit 0 prints one JSON line `{"plan_id": <n|null>, "cards": [{"id", "title"}]}`; exit 1 prints one stderr line that starts with its reason (`credential_unavailable`, `no_connection_record`, `http_error`, ...) and nothing on stdout.
73
+
70
74
  ## `subagents-live` — the plan pane's local live sub-agent feed (DX-4508)
71
75
 
72
76
  `danx-dashboard-mcp subagents-live <absolute path to the session's main transcript .jsonl>`. Spawned by the danxbot plugin's plan pane, which cannot tail files itself; the dashboard's sub-agent read lags the transcript by about 80s, this by about one second. It tails the session's transcript tree (the main file and `<session-id>/subagents/agent-*.jsonl` beside it) with the same `SessionLogWatcher` the bridge runs, from byte 0 once and then only appended bytes, polling every second, and computes each sub-agent through `@thehammer/danx-session-telemetry`'s `createSubagentLiveView` (the same observer, ledger counter and pricing rule the dashboard uses). stdout carries only `{"subagents":[...]}` lines: the first, after the catch-up read, holds every sub-agent (an empty array when there are none); each later one holds only the sub-agents a poll changed. No network, no credential. A missing, relative or non-`.jsonl` path exits 2 with one stderr line; SIGTERM, SIGINT or a closed stdout exits 0.
@@ -54,8 +54,6 @@ function isCreated(value) {
54
54
  typeof v.secret === "string" &&
55
55
  typeof v.confirmCode === "string" &&
56
56
  typeof v.approvalUrl === "string" &&
57
- typeof v.expiresAt === "string" &&
58
- Number.isFinite(Date.parse(v.expiresAt)) &&
59
57
  typeof v.pollMs === "number");
60
58
  }
61
59
  export async function createAccessRequest(fetchImpl, dashboardUrl, input) {
package/dist/index.js CHANGED
@@ -87,6 +87,7 @@ import { BACKGROUND_WORK_SUBCOMMAND, runBackgroundWorkCommand } from "./backgrou
87
87
  import { ACTIVITY_SUBCOMMAND, runActivityCommand } from "./activity.js";
88
88
  import { EVENT_TEXT_SUBCOMMAND, runEventTextCommand } from "./event-text.js";
89
89
  import { RESTART_NOTICE_SUBCOMMAND, runRestartNoticeCommand } from "./restart-notice.js";
90
+ import { READY_CARDS_SUBCOMMAND, runReadyCardsCommand } from "./ready-cards.js";
90
91
  import { SUBAGENTS_LIVE_SUBCOMMAND, runSubagentsLiveCommand } from "./subagents-live.js";
91
92
  import { resolveBootCredential } from "./credential.js";
92
93
  import { SessionKeyUnusableError, deleteSessionKey } from "./session-key.js";
@@ -514,7 +515,8 @@ strictTool("attach_file", "Attach a LOCAL file to a card. This server reads and
514
515
  // ---------------- request_permission (DX-4434, parent DX-4432) ----------------
515
516
  strictTool("request_permission", "Ask a person to grant this key ONE MORE permission. Call it when a call was refused `403 forbidden` naming a " +
516
517
  "`permission`, or when you know you need one more. It returns AT ONCE with `{state, approvalUrl, confirmCode, " +
517
- "expiresAt, instruction}`: show the person the code and URL, and keep working on anything else. If they approve, a " +
518
+ "instruction}`: show the person the code and URL, and keep working on anything else. It waits until this session " +
519
+ "ends; if expired, reconnect and ask again. If they approve, a " +
518
520
  "later call simply succeeds with the same key (no new key, no re-auth). The same permissions asked again while " +
519
521
  "pending return the pending request. A refusal (unknown permission, already held, rate limited) comes back as " +
520
522
  "`{ok:false, status, body}`. If you are blocked without it, raise a problem on the card.", {
@@ -550,7 +552,8 @@ strictTool("plan_connect",
550
552
  // DX-4390 — signed out, this is also how the session asks for access.
551
553
  "Connect THIS session to a plan; one call is enough to start. Signed out, it requests access instead: " +
552
554
  "the reply is `state: approval_required` or `approval_pending` with an `approvalUrl` and `confirmCode` " +
553
- "(show the code, open the URL, then call again). Signed in, the reply carries `missedEvents` (operator " +
555
+ "(show the code, open the URL, then call again; it waits until this session ends; a call re-asks an expired one). " +
556
+ "Signed in, the reply carries `missedEvents` (operator " +
554
557
  "events on the plan that no session had been told about, oldest first: read them first), " +
555
558
  "`browserInstruction` (do it now), `briefing` (the plan's goals, rules, caveats, architecture section " +
556
559
  "titles and a first page of open cards) and `listenerHealth` (when its `state` is not `healthy`, do its " +
@@ -687,6 +690,13 @@ if (isEntrypointModule(import.meta.url, process.argv[1])) {
687
690
  process.exit(1);
688
691
  });
689
692
  }
693
+ else if (subcommand === READY_CARDS_SUBCOMMAND) {
694
+ // DX-4534 — the plugin Stop hook's read; the event-text exit-code contract: 0 (one JSON line) / 1 (read failed).
695
+ runReadyCardsCommand(rest).then((code) => process.exit(code), (err) => {
696
+ console.error(`[danx-dashboard-mcp] ${READY_CARDS_SUBCOMMAND} fatal: ${err.message}`);
697
+ process.exit(1);
698
+ });
699
+ }
690
700
  else if (subcommand === SUBAGENTS_LIVE_SUBCOMMAND) {
691
701
  // DX-4508 — the plugin pane's local live sub-agent feed: stdout is its JSON Lines channel, so a fatal goes to
692
702
  // stderr only and exits 1; a refused path exits 2 from the command itself.
@@ -698,7 +708,7 @@ if (isEntrypointModule(import.meta.url, process.argv[1])) {
698
708
  else if (subcommand !== undefined) {
699
709
  console.error(`[danx-dashboard-mcp] unknown subcommand "${subcommand}" (the only ones are ` +
700
710
  `"${BRIDGE_SUBCOMMAND}", "${BACKGROUND_WORK_SUBCOMMAND}", "${ACTIVITY_SUBCOMMAND}", "${EVENT_TEXT_SUBCOMMAND}", ` +
701
- `"${RESTART_NOTICE_SUBCOMMAND}", and "${SUBAGENTS_LIVE_SUBCOMMAND}")`);
711
+ `"${RESTART_NOTICE_SUBCOMMAND}", "${READY_CARDS_SUBCOMMAND}", and "${SUBAGENTS_LIVE_SUBCOMMAND}")`);
702
712
  process.exit(2);
703
713
  }
704
714
  else {
@@ -1,13 +1,13 @@
1
- import { POLL_GRACE_MS, defaultSleep } from "./session-access.js";
1
+ import { defaultSleep } from "./session-access.js";
2
2
  import { McpOutdatedError } from "./version-floor.js";
3
3
  const PERMISSION_REQUESTS_BASE_PATH = "/api/permission-requests";
4
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.";
5
+ "else: this call has returned and nothing waits. The request stays open until this session ends, however long they take. If " +
6
+ "they approve, this process picks the grant up in the background and a later call simply succeeds with the same key (no new " +
7
+ "key, no re-auth). If you are blocked without it, raise a problem on the card; a person sees problems in Needs You.";
8
+ const APPROVAL_PENDING_INSTRUCTION = "This permission is already asked for and not yet decided; the request stays open until this session ends. Remind the person " +
9
+ "of the confirm code and the approval URL; keep working on anything else. A grant is picked up in the background and a later " +
10
+ "call simply succeeds with the same key. If you are blocked without it, raise a problem on the card.";
11
11
  /** The dedupe set: sorted and de-duplicated, so the order the agent listed them in never matters. */
12
12
  function setOf(permissions) {
13
13
  return [...new Set(permissions)].sort();
@@ -19,8 +19,6 @@ function isCreated(value) {
19
19
  return (typeof v.publicId === "string" &&
20
20
  typeof v.confirmCode === "string" &&
21
21
  typeof v.approvalUrl === "string" &&
22
- typeof v.expiresAt === "string" &&
23
- Number.isFinite(Date.parse(v.expiresAt)) &&
24
22
  typeof v.pollMs === "number");
25
23
  }
26
24
  /** A 429's own `retryAfterMs`, else 0 (the poll then waits its normal interval). */
@@ -32,11 +30,9 @@ export class PermissionRequests {
32
30
  deps;
33
31
  pending = new Map();
34
32
  queue = Promise.resolve();
35
- now;
36
33
  sleep;
37
34
  constructor(deps) {
38
35
  this.deps = deps;
39
- this.now = deps.now ?? Date.now;
40
36
  this.sleep = deps.sleep ?? defaultSleep;
41
37
  }
42
38
  ask(args) {
@@ -72,12 +68,11 @@ export class PermissionRequests {
72
68
  state,
73
69
  approvalUrl: request.approvalUrl,
74
70
  confirmCode: request.confirmCode,
75
- expiresAt: request.expiresAt,
76
71
  instruction: state === "approval_required" ? APPROVAL_REQUIRED_INSTRUCTION : APPROVAL_PENDING_INSTRUCTION,
77
72
  };
78
73
  }
79
74
  /**
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
75
+ * Polls until the request is decided or expired, then frees its set so a later call may ask again. Every ending is final: a
81
76
  * halt (revoked, signed out, key lapsed) stops it silently, since the session no longer holds a key that could be granted.
82
77
  */
83
78
  async pollUntilDecided(key, request) {
@@ -92,21 +87,19 @@ export class PermissionRequests {
92
87
  this.pending.delete(key);
93
88
  }
94
89
  }
90
+ // DX-4530: no give-up time - the request is open until it is decided or the key dies (the `guard` halt).
95
91
  async poll(request) {
96
- const giveUpAt = Date.parse(request.expiresAt) + POLL_GRACE_MS;
97
92
  let waitMs = request.pollMs;
98
93
  for (;;) {
99
94
  await this.sleep(waitMs);
100
95
  waitMs = request.pollMs;
101
- if (this.now() > giveUpAt)
102
- return;
103
96
  let guarded;
104
97
  try {
105
98
  // `guard` runs before EVERY claim (DX-4418): a revoked or signed-out session answers its halt without a request.
106
99
  guarded = await this.deps.access.guard(() => this.deps.request({ method: "POST", basePath: PERMISSION_REQUESTS_BASE_PATH, path: `/${request.publicId}/claim` }));
107
100
  }
108
101
  catch (err) {
109
- // An outdated MCP will never get a different answer; any other failure is retried until the request lapses.
102
+ // An outdated MCP will never get a different answer; any other failure is retried until the request ends.
110
103
  if (err instanceof McpOutdatedError)
111
104
  throw err;
112
105
  this.deps.log?.(`[danx-dashboard-mcp] permission request poll failed, retrying: ${err.message}`);
@@ -120,7 +113,7 @@ export class PermissionRequests {
120
113
  continue;
121
114
  }
122
115
  if (claim.status === 404)
123
- return; // unknown or already reaped: nothing left to wait for
116
+ return; // unknown or long since deleted: nothing left to wait for
124
117
  if (!claim.ok) {
125
118
  throw new Error(`the dashboard refused the claim (${claim.status}): ${JSON.stringify(claim.body)}`);
126
119
  }
@@ -0,0 +1,176 @@
1
+ /**
2
+ * `danx-dashboard-mcp ready-cards` — DX-4534. The ONE-SHOT client the claude-plugins `danxbot`
3
+ * plugin's Stop hook runs for a plan-connected session, to learn which of its plan's cards
4
+ * anyone could pick up right now.
5
+ *
6
+ * WHY IT EXISTS. An orchestrating plan session must keep every unblocked card moving, and
7
+ * nothing enforced it: on 2026-10-04 one builder ran while two readied, unblocked cards sat
8
+ * untouched, because the session only looked for work when a builder reported back. The Stop
9
+ * hook turns that check into something the harness runs. This is its read.
10
+ *
11
+ * NO DASHBOARD ROUTE IS ADDED. It composes existing reads on the session's own credential:
12
+ * 1. `GET /api/plans/mine` answers the session's plan (`id`) and the boards its cards live on
13
+ * (`boards`) in one read; a session on no plan is a 409 `session_not_connected`.
14
+ * 2. `GET /api/issues?board=<b>` with `filter.plan_id` + `filter.status_derived=ToDo` and the
15
+ * judged fields, once per board: a plan can span boards (PLAN-11 spans danxbot and
16
+ * gpt-manager), and an issues read without `board` sees only the default one.
17
+ * Server-side filtering narrows the read; `isReadyCard` is the whole definition of "ready".
18
+ *
19
+ * READY means a card a dispatcher or a session could claim this minute: a Story, Bug or Chore
20
+ * (Epic and Feature are containers, a Task is a leaf nobody dispatches), derived status ToDo,
21
+ * nobody holding it (no assigned agent, no live dispatch), and nothing in its way: no hold
22
+ * (`blocked`), no open problem (a human is needed), no unmet `depends_on`, no active
23
+ * `conflict_on`. A card whose reason it cannot run is recorded on it therefore stops counting,
24
+ * which is how the hook's "dispatch it, or record why not" instruction is satisfiable.
25
+ *
26
+ * THE CONTRACT, which the hook reads by exit code:
27
+ * - exit 0, one JSON line on stdout: `{"plan_id": <n|null>, "cards": [{"id","title"}...]}`;
28
+ * `plan_id` null (the dashboard has the session on no plan) comes with an empty list.
29
+ * - exit 1, one reason on stderr, nothing on stdout: credential, network, non-2xx, bad shape.
30
+ */
31
+ import { resolveSessionOptions, sendSessionRequest, tryParseJsonBody } from "./one-shot-session-request.js";
32
+ export const READY_CARDS_SUBCOMMAND = "ready-cards";
33
+ export const READY_CARDS_REQUEST_TIMEOUT_MS = 5_000;
34
+ /** `GET /api/issues` page size (its maximum): a plan's ToDo cards never approach it. */
35
+ const CARDS_PAGE_SIZE = 1000;
36
+ /** The dispatchable card types: Epic and Feature are containers, a Task is a leaf nobody dispatches. */
37
+ const DISPATCHABLE_TYPES = new Set(["Story", "Bug", "Chore"]);
38
+ /**
39
+ * The fields `isReadyCard` judges beyond the always-returned row, each with the check a row's value
40
+ * must pass: the one list the request's `fields` tree and the row parse both come from.
41
+ */
42
+ const JUDGED_FIELDS = {
43
+ dispatch: (v) => v !== undefined,
44
+ blocked: (v) => v !== undefined,
45
+ open_problem_count: (v) => typeof v === "number",
46
+ waiting_on: (v) => typeof v === "boolean",
47
+ conflict_on_active_count: (v) => typeof v === "number",
48
+ };
49
+ const CARD_FIELDS = Object.fromEntries(Object.keys(JUDGED_FIELDS).map((name) => [name, true]));
50
+ const USAGE = `usage: CLAUDE_CODE_SESSION_ID=<session-id> danx-dashboard-mcp ${READY_CARDS_SUBCOMMAND}`;
51
+ /** The row, or null when any field `isReadyCard` judges is missing or the wrong type: a card never counts on a guess. */
52
+ function parseCardRow(raw) {
53
+ if (typeof raw !== "object" || raw === null)
54
+ return null;
55
+ const r = raw;
56
+ const assigned = r.assigned_agent;
57
+ if (typeof r.id !== "string" || typeof r.type !== "string" || typeof r.title !== "string")
58
+ return null;
59
+ if (!(assigned === null || typeof assigned === "string"))
60
+ return null;
61
+ if (!Object.entries(JUDGED_FIELDS).every(([name, valid]) => valid(r[name])))
62
+ return null;
63
+ return r;
64
+ }
65
+ /** Whether a ToDo card could be claimed right now (see the module banner). */
66
+ export function isReadyCard(card) {
67
+ return (DISPATCHABLE_TYPES.has(card.type) &&
68
+ card.assigned_agent === null &&
69
+ card.dispatch === null &&
70
+ card.blocked === null &&
71
+ card.open_problem_count === 0 &&
72
+ !card.waiting_on &&
73
+ card.conflict_on_active_count === 0);
74
+ }
75
+ function classifyStatus(status) {
76
+ if (status === 401 || status === 403)
77
+ return "unauthorized";
78
+ if (status === 404)
79
+ return "not_found";
80
+ if (status < 200 || status >= 300)
81
+ return "http_error";
82
+ return null;
83
+ }
84
+ /** The session's plan and its boards, `null` when the dashboard has the session on no plan, or a failure. */
85
+ async function readSessionPlan(options, deps) {
86
+ const sent = await sendSessionRequest(options, "/api/plans/mine", { method: "GET" }, deps);
87
+ if ("ok" in sent)
88
+ return sent;
89
+ const parsed = tryParseJsonBody(sent.text);
90
+ const body = parsed.ok ? parsed.body : null;
91
+ // A session on no plan is the ordinary answer, not a fault: a 409 `session_not_connected`.
92
+ if (sent.status === 409 && body?.error === "session_not_connected")
93
+ return { ok: true, plan: null };
94
+ const failed = classifyStatus(sent.status);
95
+ if (failed !== null)
96
+ return { ok: false, reason: failed };
97
+ if (body === null ||
98
+ typeof body.id !== "number" ||
99
+ !Array.isArray(body.boards) ||
100
+ !body.boards.every((b) => typeof b === "string")) {
101
+ return { ok: false, reason: "bad_response" };
102
+ }
103
+ return { ok: true, plan: { planId: body.id, boards: body.boards } };
104
+ }
105
+ /** The plan's ready cards on one board. */
106
+ async function readBoardReadyCards(options, deps, planId, board) {
107
+ const filter = encodeURIComponent(JSON.stringify({ plan_id: planId, status_derived: "ToDo" }));
108
+ const fields = encodeURIComponent(JSON.stringify(CARD_FIELDS));
109
+ const sent = await sendSessionRequest(options, `/api/issues?board=${encodeURIComponent(board)}&filter=${filter}&fields=${fields}&limit=${CARDS_PAGE_SIZE}&sort=priority`, { method: "GET" }, deps);
110
+ if ("ok" in sent)
111
+ return sent;
112
+ const failed = classifyStatus(sent.status);
113
+ if (failed !== null)
114
+ return { ok: false, reason: failed };
115
+ const parsed = tryParseJsonBody(sent.text);
116
+ const body = parsed.ok ? parsed.body : null;
117
+ if (body === null || !Array.isArray(body.issues) || typeof body.total !== "number")
118
+ return { ok: false, reason: "bad_response" };
119
+ // A truncated read would silently miss ready cards: fail loudly instead.
120
+ if (body.total > body.issues.length)
121
+ return { ok: false, reason: "too_many_cards" };
122
+ const cards = [];
123
+ for (const raw of body.issues) {
124
+ const row = parseCardRow(raw);
125
+ if (row === null)
126
+ return { ok: false, reason: "bad_response" };
127
+ if (isReadyCard(row))
128
+ cards.push({ id: row.id, title: row.title });
129
+ }
130
+ return { ok: true, cards };
131
+ }
132
+ /** One authenticated read of the session's plan's ready cards, across every board the plan spans. Never throws. */
133
+ export async function fetchReadyCards(options, deps) {
134
+ const read = await readSessionPlan(options, deps);
135
+ if (!read.ok)
136
+ return read;
137
+ if (read.plan === null)
138
+ return { ok: true, planId: null, cards: [] };
139
+ const { planId } = read.plan;
140
+ const perBoard = await Promise.all(read.plan.boards.map((board) => readBoardReadyCards(options, deps, planId, board)));
141
+ const cards = [];
142
+ for (const onBoard of perBoard) {
143
+ if (!onBoard.ok)
144
+ return onBoard;
145
+ cards.push(...onBoard.cards);
146
+ }
147
+ return { ok: true, planId: read.plan.planId, cards };
148
+ }
149
+ /** The bin's `ready-cards` subcommand, wired to the real process. */
150
+ export async function runReadyCardsCommand(argv, env = process.env,
151
+ /** Test seams: the home the connection record is read from, and the HTTP client. */
152
+ seams = {}) {
153
+ if (argv.length !== 0) {
154
+ process.stderr.write(`unexpected arguments\n${USAGE}\n`);
155
+ return 1;
156
+ }
157
+ // `resolveSessionOptions` words a missing session id as the usage text alone; every stderr line here starts with its reason.
158
+ if (!env.CLAUDE_CODE_SESSION_ID) {
159
+ process.stderr.write(`no_session_id: CLAUDE_CODE_SESSION_ID is not set\n${USAGE}\n`);
160
+ return 1;
161
+ }
162
+ const resolved = await resolveSessionOptions(env, { home: seams.home }, USAGE);
163
+ if (!resolved.ok) {
164
+ // `detail` names its reason except for a missing session id, where it is only the usage text.
165
+ const named = resolved.detail.startsWith(`${resolved.output.reason}:`);
166
+ process.stderr.write(named ? `${resolved.detail}\n` : `${resolved.output.reason}: CLAUDE_CODE_SESSION_ID is not set\n${resolved.detail}\n`);
167
+ return 1;
168
+ }
169
+ const output = await fetchReadyCards(resolved.options, seams.deps ?? { fetch: (input, init) => fetch(input, init), requestTimeoutMs: READY_CARDS_REQUEST_TIMEOUT_MS });
170
+ if (!output.ok) {
171
+ process.stderr.write(`${output.reason}: could not read the plan's ready cards from the dashboard\n`);
172
+ return 1;
173
+ }
174
+ process.stdout.write(`${JSON.stringify({ plan_id: output.planId, cards: output.cards })}\n`);
175
+ return 0;
176
+ }
@@ -18,6 +18,10 @@
18
18
  * the background, so the key is stored the moment the user approves, and the next `plan_connect`
19
19
  * waits at most `awaitMs` (about 45s) for it before answering `approval_pending`. The secret the
20
20
  * poll needs lives only in this process's memory; a restarted session asks again.
21
+ *
22
+ * DX-4530: a request has no deadline. It stays open for as long as this process keeps polling it, so it waits for a person
23
+ * however long they take, and it expires once the session ends (its polls stop). A poll that cannot reach the dashboard is
24
+ * retried; the dashboard answers `expired` if they stopped arriving for longer than its requester-gone window.
21
25
  */
22
26
  import { existsSync } from "node:fs";
23
27
  import { hostname } from "node:os";
@@ -28,8 +32,6 @@ import { deleteSessionKey, writeSessionKey } from "./session-key.js";
28
32
  import { McpOutdatedError } from "./version-floor.js";
29
33
  /** Longest a `plan_connect` call waits for an approval already asked for. */
30
34
  export const AWAIT_APPROVAL_MS = 45_000;
31
- /** The server reaps a lapsed request about a minute after expiry; polling past that finds nothing. */
32
- export const POLL_GRACE_MS = 90_000;
33
35
  /** A live server rewrites its key file this often, so an in-use key never ages out of the 7-day prune. */
34
36
  const KEEP_KEY_FILE_EVERY_MS = 60 * 60 * 1000;
35
37
  /** The request's title when the agent gave none: the session id's first characters. */
@@ -39,9 +41,10 @@ export const SIGN_IN_HALT = "Not signed in to the danxbot dashboard. Call `plan_
39
41
  export const KEY_LAPSED_HALT = "The dashboard no longer accepts this session's key (it lapsed after a day unused), so this session is signed out. " +
40
42
  SIGN_IN_HALT;
41
43
  const APPROVAL_REQUIRED_INSTRUCTION = "Show the user the confirm code and open the approval URL in their browser; they check the code " +
42
- "matches and approve. Then call `plan_connect` again: it waits up to about 45 seconds for the approval.";
43
- const APPROVAL_PENDING_INSTRUCTION = "Still waiting for the user to approve. Remind them of the confirm code and the URL, then call " +
44
- "`plan_connect` again.";
44
+ "matches and approve. Then call `plan_connect` again: it waits up to about 45 seconds for the approval. " +
45
+ "The request stays open until this session ends, however long the user takes.";
46
+ const APPROVAL_PENDING_INSTRUCTION = "Still waiting for the user to approve; the request stays open until this session ends. Remind them of the " +
47
+ "confirm code and the URL, then call `plan_connect` again.";
45
48
  const DENIED_INSTRUCTION = "The user denied the request. Do not retry unless they ask; a new `plan_connect` makes a new request.";
46
49
  const DASHBOARD_OUTDATED_ERROR = "this dashboard does not support request-and-approve yet (POST /api/access-requests answered 404): " +
47
50
  "it must be updated before this session can sign in";
@@ -50,7 +53,8 @@ const unknownTeamError = (team) => `the dashboard has no team "${team}" (DANXBOT
50
53
  const NO_SESSION_ID_ERROR = "this server has no CLAUDE_CODE_SESSION_ID, so it cannot request a session key";
51
54
  const CLAIMED_LOST_ERROR = "the dashboard says this request was already claimed, but this process never received the key; " +
52
55
  "start a new request";
53
- const EXPIRED_NOTE = "The previous request expired before it was approved.";
56
+ const EXPIRED_NOTE = "The previous request expired before it was approved: the dashboard stopped hearing from this session. This is a new " +
57
+ "request: show the user its new confirm code and URL.";
54
58
  export const defaultSleep = (ms) => new Promise((resolve) => {
55
59
  setTimeout(resolve, ms).unref();
56
60
  });
@@ -233,24 +237,21 @@ export class SessionAccess {
233
237
  state,
234
238
  approvalUrl: request.approvalUrl,
235
239
  confirmCode: request.confirmCode,
236
- expiresAt: request.expiresAt,
237
240
  instruction: state === "approval_required" ? APPROVAL_REQUIRED_INSTRUCTION : APPROVAL_PENDING_INSTRUCTION,
238
241
  };
239
242
  }
243
+ // DX-4530: no give-up time - the polls are what keep the request open, until the dashboard answers how it ended.
240
244
  async pollUntilSettled(request) {
241
- const giveUpAt = Date.parse(request.expiresAt) + POLL_GRACE_MS;
242
245
  let waitMs = request.pollMs;
243
246
  for (;;) {
244
247
  await this.sleep(waitMs);
245
248
  waitMs = request.pollMs;
246
- if (this.now() > giveUpAt)
247
- return { kind: "expired" };
248
249
  let claim;
249
250
  try {
250
251
  claim = await claimAccessRequest(this.fetchImpl, this.deps.dashboardUrl, request.secret);
251
252
  }
252
253
  catch (err) {
253
- // A claim the dashboard could not answer is retried until the request lapses; an outdated
254
+ // A claim the dashboard could not answer is retried until it answers how the request ended; an outdated
254
255
  // MCP will never get a different answer, so that one ends it.
255
256
  if (err instanceof McpOutdatedError)
256
257
  return { kind: "failed", error: err.message };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thehammer/danx-dashboard-mcp",
3
- "version": "0.1.246",
3
+ "version": "0.1.248",
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",