@50arms/platform-mcp 0.0.0-stage → 0.1.437

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.
Files changed (75) hide show
  1. package/README.md +114 -2
  2. package/dist/access-request.js +101 -0
  3. package/dist/agent-gate.js +69 -0
  4. package/dist/board-id.js +27 -0
  5. package/dist/bounded-retry.js +25 -0
  6. package/dist/checkout-docs-root.js +54 -0
  7. package/dist/clear-control.js +77 -0
  8. package/dist/compaction-control.js +142 -0
  9. package/dist/credential.js +104 -0
  10. package/dist/danxbot-api-examples.js +97 -0
  11. package/dist/dashboard-identity.js +14 -0
  12. package/dist/design-screenshot.js +138 -0
  13. package/dist/digest-hold.js +67 -0
  14. package/dist/docs-tools.js +140 -0
  15. package/dist/durable-write.js +56 -0
  16. package/dist/entrypoint.js +35 -0
  17. package/dist/forbidden-hint.js +23 -0
  18. package/dist/guarded-calls.js +21 -0
  19. package/dist/handlers.js +385 -0
  20. package/dist/heartbeat-reporter.js +156 -0
  21. package/dist/http-client.js +190 -0
  22. package/dist/index.js +1128 -0
  23. package/dist/json-body.js +12 -0
  24. package/dist/key-revoked-halt.js +30 -0
  25. package/dist/lifecycle.js +113 -0
  26. package/dist/listen.js +999 -0
  27. package/dist/listener-reminder.js +119 -0
  28. package/dist/machine-daemon/autostart.js +160 -0
  29. package/dist/machine-daemon/beat.js +77 -0
  30. package/dist/machine-daemon/cli.js +42 -0
  31. package/dist/machine-daemon/install.js +168 -0
  32. package/dist/machine-daemon/state.js +64 -0
  33. package/dist/machine-daemon/version.js +13 -0
  34. package/dist/machine-daemon.js +553 -0
  35. package/dist/machine-toolkit.js +460 -0
  36. package/dist/mini-app-build.js +159 -0
  37. package/dist/mini-app-checkout.js +215 -0
  38. package/dist/mini-app-output.js +111 -0
  39. package/dist/mini-app-publish.js +154 -0
  40. package/dist/mini-app-restore.js +175 -0
  41. package/dist/one-line.js +25 -0
  42. package/dist/pacing-relay.js +16 -0
  43. package/dist/permission-request.js +134 -0
  44. package/dist/permission-watch.js +145 -0
  45. package/dist/plan-events-relay.js +572 -0
  46. package/dist/plan-events-tool.js +9 -0
  47. package/dist/plugin-publish.js +38 -0
  48. package/dist/plugin-screenshot.js +81 -0
  49. package/dist/request-bound.js +21 -0
  50. package/dist/restart-notice.js +271 -0
  51. package/dist/result-limit.js +31 -0
  52. package/dist/role-instruction-file.js +36 -0
  53. package/dist/route-board-scope.generated.js +95 -0
  54. package/dist/route-board-scope.js +71 -0
  55. package/dist/session-access.js +322 -0
  56. package/dist/session-connection.js +264 -0
  57. package/dist/session-control-file.js +58 -0
  58. package/dist/session-key.js +125 -0
  59. package/dist/session-options.js +60 -0
  60. package/dist/session-stats.js +235 -0
  61. package/dist/session-stream.js +345 -0
  62. package/dist/session-title.js +129 -0
  63. package/dist/shutdown.js +38 -0
  64. package/dist/stop-fixes.js +50 -0
  65. package/dist/subagents-live.js +162 -0
  66. package/dist/terms-halt.js +39 -0
  67. package/dist/text-limit.js +24 -0
  68. package/dist/tool-access.js +114 -0
  69. package/dist/tool-defs.js +58 -0
  70. package/dist/tool-surface.js +59 -0
  71. package/dist/transcript-lineage.js +105 -0
  72. package/dist/types.js +45 -0
  73. package/dist/urgent-records.js +47 -0
  74. package/dist/version-floor.js +146 -0
  75. package/package.json +51 -4
@@ -0,0 +1,81 @@
1
+ import { captureWith } from "./design-screenshot.js";
2
+ /** The route the app draws the plugin on, the prefix its files are requested under, the window global it is handed the manifest on, and the attribute the page reports through: restated from `frontend/src/lib/plugin-preview-contract.ts` (this package cannot import the app); `src/dispatch/design-publish-restated-constants.test.ts` ties them. */
3
+ export const PLUGIN_PREVIEW_ROUTE = "/plugin-preview";
4
+ export const PLUGIN_PREVIEW_FILES = "/__plugin-preview__/";
5
+ export const PLUGIN_PREVIEW_ATTRIBUTE = "data-plugin-preview";
6
+ export const PLUGIN_PREVIEW_MANIFEST_GLOBAL = "__DANX_PLUGIN_PREVIEW_MANIFEST__";
7
+ // Restated from `src/plugins/plugin-manifest.ts` (`CARD_VIEW_EXPORT`, `CARD_LIST_VIEW_EXPORT`): this package cannot import the app's source.
8
+ const CARD_VIEW_EXPORT = "CardView";
9
+ const CARD_LIST_VIEW_EXPORT = "CardListView";
10
+ export function previewModeOf(exports) {
11
+ if (exports.includes(CARD_VIEW_EXPORT))
12
+ return "card";
13
+ if (exports.includes(CARD_LIST_VIEW_EXPORT))
14
+ return "list";
15
+ throw new Error(`plugin preview: the revision exports neither ${CARD_VIEW_EXPORT} nor ${CARD_LIST_VIEW_EXPORT}, so there is no view to draw`);
16
+ }
17
+ /** The ceiling on one render, from page load to the page saying `ready` or `failed`. A page that reports is waited on only as long as it takes. */
18
+ export const RENDER_WAIT_MS = 30_000;
19
+ /** The content types of the files a plugin may carry: restated from the server's `PLUGIN_FILE_TYPES` (`src/plugins/manifest-validation.ts`, minus its charset suffix); `src/dispatch/design-publish-restated-constants.test.ts` ties them. */
20
+ export const CONTENT_TYPES = {
21
+ js: "text/javascript",
22
+ css: "text/css",
23
+ map: "application/json",
24
+ json: "application/json",
25
+ svg: "image/svg+xml",
26
+ png: "image/png",
27
+ webp: "image/webp",
28
+ woff2: "font/woff2",
29
+ };
30
+ function contentTypeOf(filePath) {
31
+ return CONTENT_TYPES[filePath.slice(filePath.lastIndexOf(".") + 1).toLowerCase()] ?? "application/octet-stream";
32
+ }
33
+ /** The build's files by path. Only what the build listed can be requested. */
34
+ function servedFiles(built) {
35
+ return new Map(built.files.map((file) => [file.path, file.bytes]));
36
+ }
37
+ async function answer(route, prefix, served) {
38
+ const { pathname } = new URL(route.request().url());
39
+ let requested;
40
+ try {
41
+ requested = decodeURIComponent(pathname.slice(prefix.length));
42
+ }
43
+ catch {
44
+ requested = ""; // a malformed percent-escape names no file
45
+ }
46
+ const bytes = served.get(requested);
47
+ if (bytes === undefined) {
48
+ await route.fulfill({ status: 404, contentType: "text/plain", body: `no such plugin file: ${pathname}` });
49
+ return;
50
+ }
51
+ await route.fulfill({ status: 200, contentType: contentTypeOf(pathname), body: bytes });
52
+ }
53
+ const REPORT_EXPRESSION = `(() => { const el = document.querySelector("[${PLUGIN_PREVIEW_ATTRIBUTE}]"); return { state: el ? el.getAttribute("${PLUGIN_PREVIEW_ATTRIBUTE}") : null, text: el ? el.textContent : "" }; })()`;
54
+ /** Settled: the page said `ready` or `failed`, and nothing on it is still loading (`aria-busy`). */
55
+ const SETTLED_EXPRESSION = `(() => { const el = document.querySelector("[${PLUGIN_PREVIEW_ATTRIBUTE}]"); const state = el && el.getAttribute("${PLUGIN_PREVIEW_ATTRIBUTE}"); return (state === "ready" || state === "failed") && !document.querySelector('[aria-busy="true"]'); })()`;
56
+ /**
57
+ * Renders the built plugin on the dashboard's preview page and screenshots it.
58
+ * @param dashboardUrl the dashboard's base URL (`ClientConfig.baseUrl`).
59
+ */
60
+ export async function capturePluginRender(built, dashboardUrl, where) {
61
+ const origin = new URL(dashboardUrl).origin;
62
+ const served = servedFiles(built);
63
+ return captureWith(where, false, async (page) => {
64
+ await page.addInitScript(`window[${JSON.stringify(PLUGIN_PREVIEW_MANIFEST_GLOBAL)}] = ${JSON.stringify({ entry: built.manifest.entry, styles: built.manifest.styles, runtime: built.manifest.runtime, mode: previewModeOf(built.manifest.exports) })};`);
65
+ await page.route(`${origin}${PLUGIN_PREVIEW_FILES}**`, (route) => answer(route, PLUGIN_PREVIEW_FILES, served));
66
+ const response = await page.goto(`${origin}${PLUGIN_PREVIEW_ROUTE}`, { waitUntil: "load" });
67
+ const status = response?.status();
68
+ if (status !== undefined && (status < 200 || status > 299))
69
+ return `the dashboard answered ${status} for its ${PLUGIN_PREVIEW_ROUTE} page; a dashboard older than this tool does not serve it`;
70
+ try {
71
+ await page.waitForFunction(SETTLED_EXPRESSION, undefined, { timeout: RENDER_WAIT_MS });
72
+ }
73
+ catch {
74
+ const report = await page.evaluate(REPORT_EXPRESSION);
75
+ return `the dashboard's ${PLUGIN_PREVIEW_ROUTE} page did not say ready within ${RENDER_WAIT_MS} ms (it said ${report.state ?? "nothing"}); the page may be missing, a plugin file may not have loaded, or something on it is still loading`;
76
+ }
77
+ const report = await page.evaluate(REPORT_EXPRESSION);
78
+ if (report.state === "failed")
79
+ return `the plugin did not render: ${report.text.trim().slice(0, 300)}`;
80
+ });
81
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * DX-4632 - the ONE bound every dashboard request of this package shares: a timeout of its own, plus an OUTER signal that ends
3
+ * the request early (a lookup's one deadline, a closed relay). Used by `sendSessionRequest` (`restart-notice.ts`) and by `request` in `session-stream.ts` (the relay's mint, inventory and
4
+ * heartbeat), so the controller, the timer, the link to the outer signal and the cleanup exist once.
5
+ */
6
+ export function boundRequest(timeoutMs, outer) {
7
+ const controller = new AbortController();
8
+ const abort = () => controller.abort();
9
+ const timer = setTimeout(abort, timeoutMs);
10
+ if (outer?.aborted === true)
11
+ abort();
12
+ outer?.addEventListener("abort", abort, { once: true });
13
+ return {
14
+ signal: controller.signal,
15
+ aborted: () => controller.signal.aborted,
16
+ release: () => {
17
+ clearTimeout(timer);
18
+ outer?.removeEventListener("abort", abort);
19
+ },
20
+ };
21
+ }
@@ -0,0 +1,271 @@
1
+ /**
2
+ * The `restart_notice` tool - DX-3928, DX-4632. How a RESTARTED Claude Code session learns which plan the session it replaced was
3
+ * working, so it can say so to its agent and reconnect.
4
+ *
5
+ * WHY IT EXISTS. A restarted session gets a NEW session id, no plan binding and, until a person approves its first call, no
6
+ * dashboard key of its own: its MCP server boots signed out. Nothing told it (or the operator) that the plan it had been
7
+ * working was unattended: six operator answers sat unread for an hour on 2026-10-01. The session's own connection record cannot
8
+ * help (it does not exist yet); the PREVIOUS session's record can, found by the project key `plan_connect` wrote into it
9
+ * (`findPredecessorCandidates`). So this asks the dashboard about each predecessor as the predecessor, with the credential
10
+ * that predecessor's record names (`resolveDashboardAuth`, the resolver `session-options.ts` holds), which a signed-out
11
+ * session needs no key of its own to do. The tool is therefore usable signed out: it is declared like `plan_connect` (no
12
+ * permission) and only on a person's session (`tool-access.ts`).
13
+ *
14
+ * THE ANSWER (the plugin's hooks module reads it through `$.mcp.call`):
15
+ * - `{notice: "<text>"}` - say this to the agent (the registry's `plan_restart.waiting_events` text; this module carries no
16
+ * wording of its own).
17
+ * - `{notice: null}` - nothing to say: no earlier session of this project connected a plan, or the plan is archived or
18
+ * deleted, or the record is stale, or every predecessor is still live. The plugin stays silent, as PLN-11 R-10 requires of
19
+ * a project that never connected a plan.
20
+ * - `{stopped: {reason, detail, fix}}` - the lookup could not answer: no session id, an unreadable transcript, a named predecessor
21
+ * with no usable record, NO candidate answered (credential, network, non-2xx, bad shape, for every one), or the one deadline
22
+ * passed. Never a silent null. (A failure that is not
23
+ * one of these, a bug in the credential resolver, is rethrown: fail loud, never dressed as a lookup failure.)
24
+ *
25
+ * WHO THE PREDECESSOR IS (DX-4234). The project scan is a GUESS (the newest non-self record of the project): another session
26
+ * of the same checkout can win it, so a forked session was told about a stranger's plan. The caller can name the predecessor
27
+ * instead, in this precedence (`choosePredecessors`):
28
+ * 1. `predecessor_id` (a Claude Code session id): ask ONLY that session's record. No usable record for it is a `stopped`
29
+ * (`no_predecessor_record`), never a fall back to the guess.
30
+ * 2. `transcript_path` (this session's own transcript, absolute `.jsonl`): the transcript says whether this session was forked
31
+ * and from which session (`readTranscriptLineage`: the nearest ancestor, which for a fork of a fork is not the first row's
32
+ * session). A fork's parent is asked as in (1), except that a parent with no record (it never connected a plan) is
33
+ * `{notice: null}`. A transcript that proves this is NOT a fork (a plain resume) is `{notice: null}` too. One that cannot be
34
+ * read (a bad path, a missing or malformed file, an over-cap row) is a `stopped` (`transcript_unreadable`): never the guess.
35
+ * 3. neither argument: the project scan (a bare startup).
36
+ * A predecessor the caller NAMED is asked with `?named=1`, which tells the dashboard to skip its "that session's stream is still
37
+ * open, so it is a live sibling" check: a fork's parent is usually still live, and the fork still needs to hear its plan.
38
+ *
39
+ * ONE DEADLINE over the whole lookup (`RESTART_NOTICE_DEADLINE_MS`): the transcript read and every candidate's request share it
40
+ * (each request keeps its own shorter bound), so a hung dashboard or a stalled disk cannot wedge a session start. READ-ONLY: it advances
41
+ * no cursor on the server.
42
+ */
43
+ import { z } from "zod";
44
+ import { boundRequest } from "./request-bound.js";
45
+ import { PLUGIN_BACKGROUND_HEADER, resolveDashboardAuth, SESSION_ID_HEADER, SessionStartError } from "./session-options.js";
46
+ import { findPredecessorCandidates, projectKeyOf, readSessionConnection } from "./session-connection.js";
47
+ import { checkedTranscriptPath } from "./session-stats.js";
48
+ import { readTranscriptLineage, TranscriptReadAborted, TranscriptUnreadable } from "./transcript-lineage.js";
49
+ import { fixForStop } from "./stop-fixes.js";
50
+ /**
51
+ * One authenticated HTTP call to this session's dashboard, with a hard
52
+ * timeout. Never throws — a network failure or an abort is reported as
53
+ * `RequestFailure`, exactly like any other classified failure the caller
54
+ * might produce from the status/body it gets back on success.
55
+ */
56
+ export async function sendSessionRequest(options, path, init, deps) {
57
+ const bound = boundRequest(deps.requestTimeoutMs, deps.signal);
58
+ try {
59
+ try {
60
+ const response = await deps.fetch(`${options.dashboardUrl}${path}`, {
61
+ method: init.method,
62
+ headers: {
63
+ Authorization: `Bearer ${options.token}`,
64
+ Accept: "application/json",
65
+ ...(init.body !== undefined ? { "Content-Type": "application/json" } : {}),
66
+ [SESSION_ID_HEADER]: options.sessionId,
67
+ // DX-5354: the session-start hook's lookup is the plugin's own work, never the agent acting, so it does not extend the session's key.
68
+ [PLUGIN_BACKGROUND_HEADER]: "1",
69
+ },
70
+ body: init.body,
71
+ signal: bound.signal,
72
+ });
73
+ const text = await response.text();
74
+ return { status: response.status, text };
75
+ }
76
+ catch {
77
+ // DX-3367 fix-up — the caught value is never inspected (an aborted
78
+ // fetch's own rejection shape varies by runtime), so binding it to a
79
+ // name the code never reads was dead weight the linter never caught.
80
+ return { ok: false, reason: bound.aborted() ? "timeout" : "request_failed" };
81
+ }
82
+ }
83
+ finally {
84
+ bound.release();
85
+ }
86
+ }
87
+ /** `JSON.parse`, classified rather than thrown — the ONE shared parse point
88
+ * every caller uses once it has decided (from `status`) that the body is
89
+ * worth reading at all. */
90
+ export function tryParseJsonBody(text) {
91
+ try {
92
+ return { ok: true, body: text === "" ? null : JSON.parse(text) };
93
+ }
94
+ catch {
95
+ return { ok: false };
96
+ }
97
+ }
98
+ export const RESTART_NOTICE_REQUEST_TIMEOUT_MS = 5_000;
99
+ /** DX-4632: the whole lookup, every candidate included (the old hook's `timeout 8s`). */
100
+ export const RESTART_NOTICE_DEADLINE_MS = 8_000;
101
+ /**
102
+ * How many earlier sessions of the project one lookup asks about, newest first. Many
103
+ * sessions share one checkout and the dashboard answers "nothing" for a LIVE sibling
104
+ * (it is not a predecessor), so the newest candidate is often not the one.
105
+ */
106
+ export const RESTART_NOTICE_MAX_CANDIDATES = 10;
107
+ /** The registry row whose text is the notice (`src/issues/reminders/seed.yaml`). */
108
+ export const RESTART_NOTICE_REMINDER_KEY = "plan_restart.waiting_events";
109
+ /** One authenticated GET (`sendSessionRequest` above, so its bound and its failure names are the same), answering the notice text or `null`. */
110
+ export async function fetchRestartNotice(target, deps) {
111
+ // The bearer is the predecessor's; the session id header names the ASKING session.
112
+ const auth = { dashboardUrl: target.dashboardUrl, token: target.token, sessionId: target.askingSessionId };
113
+ const sent = await sendSessionRequest(auth, `/api/plan-sessions/${encodeURIComponent(target.predecessorId)}/restart-notice${target.named === true ? "?named=1" : ""}`, { method: "GET" }, deps);
114
+ if ("ok" in sent)
115
+ return { ok: false, reason: sent.reason };
116
+ const { status, text } = sent;
117
+ if (status === 401 || status === 403)
118
+ return { ok: false, reason: "unauthorized" };
119
+ if (status === 404)
120
+ return { ok: false, reason: "not_found" };
121
+ if (status < 200 || status >= 300)
122
+ return { ok: false, reason: "http_error" };
123
+ const parsed = tryParseJsonBody(text);
124
+ const body = parsed.ok ? parsed.body : null;
125
+ if (typeof body !== "object" || body === null || !("restart" in body))
126
+ return { ok: false, reason: "bad_response" };
127
+ if (body.restart === null)
128
+ return { ok: true, text: null };
129
+ // A notice exists, so the registry row MUST have answered with its text: a
130
+ // missing one is a server/registry fault, reported rather than papered over.
131
+ const reminders = Array.isArray(body.reminders) ? body.reminders : [];
132
+ const notice = reminders.find((r) => r.key === RESTART_NOTICE_REMINDER_KEY);
133
+ if (typeof notice?.text !== "string" || notice.text === "")
134
+ return { ok: false, reason: "bad_response" };
135
+ return { ok: true, text: notice.text };
136
+ }
137
+ /** The tool's arguments (`index.ts` registers exactly this shape). */
138
+ export const RESTART_NOTICE_ARGS = {
139
+ predecessor_id: z
140
+ .string()
141
+ .min(1)
142
+ .optional()
143
+ .describe("Session id of the session this one replaced, when known. Asked alone, and wins over transcript_path. With neither argument the newest earlier session of the project is guessed."),
144
+ transcript_path: z
145
+ .string()
146
+ .min(1)
147
+ .optional()
148
+ .describe("Absolute path of this session's transcript (.jsonl file): a forked session's copied rows name the session it was forked from."),
149
+ };
150
+ const stop = (reason, detail) => ({ stopped: { reason, detail, fix: fixForStop(reason) } });
151
+ /**
152
+ * DX-4234 - the module docblock's precedence: a named id, else the transcript's lineage, else the project scan. Throws
153
+ * `TranscriptReadAborted` when the deadline fires mid-read; every other failure is an answer.
154
+ */
155
+ async function choosePredecessors(lookup, ownId, signal) {
156
+ let namedId = lookup.predecessorId;
157
+ let namedBy = "predecessor_id";
158
+ if (namedId === undefined && lookup.transcriptPath !== undefined) {
159
+ let path;
160
+ try {
161
+ path = checkedTranscriptPath(lookup.transcriptPath, "restart_notice transcript_path");
162
+ }
163
+ catch (err) {
164
+ return { answer: stop("transcript_unreadable", err.message) };
165
+ }
166
+ let lineage;
167
+ try {
168
+ lineage = await readTranscriptLineage(path, ownId, { signal, open: lookup.openTranscript });
169
+ }
170
+ catch (err) {
171
+ if (err instanceof TranscriptUnreadable)
172
+ return { answer: stop("transcript_unreadable", err.message) };
173
+ throw err;
174
+ }
175
+ if (lineage.kind === "not_fork")
176
+ return { answer: { notice: null } };
177
+ namedId = lineage.parent;
178
+ namedBy = "transcript_path";
179
+ }
180
+ if (namedId === undefined) {
181
+ const found = findPredecessorCandidates(projectKeyOf(lookup.cwd), ownId, { home: lookup.home, now: lookup.now });
182
+ return { candidates: found.slice(0, RESTART_NOTICE_MAX_CANDIDATES), named: false };
183
+ }
184
+ let record;
185
+ try {
186
+ record = readSessionConnection(namedId, { home: lookup.home });
187
+ }
188
+ catch (err) {
189
+ return { answer: stop("no_predecessor_record", `session ${namedId} (${namedBy}) has no usable dashboard connection record: ${err.message}`) };
190
+ }
191
+ if (record === null) {
192
+ // A parent found through the transcript that never connected a plan has nothing to tell; an id the caller NAMED that has
193
+ // no record is something the caller must hear about.
194
+ if (namedBy === "transcript_path")
195
+ return { answer: { notice: null } };
196
+ return { answer: stop("no_predecessor_record", `session ${namedId} (${namedBy}) has no dashboard connection record on this machine`) };
197
+ }
198
+ return { candidates: [record], named: true };
199
+ }
200
+ /** The tool's whole body: see the module docblock for the contract. Throws only on a bug, never on a lookup failure. */
201
+ export async function lookupRestartNotice(lookup) {
202
+ const ownId = lookup.sessionId;
203
+ if (ownId === undefined || ownId === "") {
204
+ return stop("no_session_id", "this server has no CLAUDE_CODE_SESSION_ID, so it cannot tell which session it replaced");
205
+ }
206
+ const resolveFrom = { home: lookup.home, now: lookup.now };
207
+ // DX-4632: ONE deadline for the transcript read and every candidate read. It is not unref'd: the lookup is awaited, and a
208
+ // pending request holds the process anyway.
209
+ const deadline = new AbortController();
210
+ const deadlineMs = lookup.deadlineMs ?? RESTART_NOTICE_DEADLINE_MS;
211
+ const timer = setTimeout(() => deadline.abort(), deadlineMs);
212
+ try {
213
+ let choice;
214
+ try {
215
+ choice = await choosePredecessors(lookup, ownId, deadline.signal);
216
+ }
217
+ catch (err) {
218
+ if (!(err instanceof TranscriptReadAborted))
219
+ throw err;
220
+ return stop("lookup_timeout", `the lookup did not finish within ${deadlineMs} ms (it was still reading the transcript)`);
221
+ }
222
+ if ("answer" in choice)
223
+ return choice.answer;
224
+ const { candidates, named } = choice;
225
+ // One candidate failing (its credential moved, its dashboard is down) must not hide a LATER candidate that
226
+ // would answer: keep going, and report failure only when no candidate answered at all.
227
+ const failures = [];
228
+ let answered = 0;
229
+ for (const predecessor of candidates) {
230
+ let options;
231
+ try {
232
+ options = resolveDashboardAuth(predecessor.sessionId, lookup.env, resolveFrom);
233
+ }
234
+ catch (err) {
235
+ if (!(err instanceof SessionStartError))
236
+ throw err; // a bug in the resolver is not a lookup failure: fail loud
237
+ failures.push(`${err.reason}: ${err.message}. Fix: ${err.fix}.`);
238
+ continue;
239
+ }
240
+ const output = await fetchRestartNotice({
241
+ dashboardUrl: options.dashboardUrl,
242
+ token: options.token,
243
+ predecessorId: predecessor.sessionId,
244
+ askingSessionId: ownId,
245
+ named,
246
+ }, { fetch: lookup.fetch, requestTimeoutMs: lookup.requestTimeoutMs ?? RESTART_NOTICE_REQUEST_TIMEOUT_MS, signal: deadline.signal });
247
+ // A notice the dashboard fully answered is kept even when the deadline fired right after it; every other outcome of a
248
+ // deadline that passed is the stop below (this is the lookup's one deadline check).
249
+ if (output.ok && output.text !== null)
250
+ return { notice: output.text };
251
+ if (deadline.signal.aborted)
252
+ break;
253
+ if (!output.ok) {
254
+ failures.push(`${output.reason}: could not ask the dashboard what session ${predecessor.sessionId} was working`);
255
+ continue;
256
+ }
257
+ answered += 1;
258
+ }
259
+ // DX-4632: a deadline that passed is a stop even when earlier candidates answered "nothing": an unasked candidate may be the one.
260
+ if (deadline.signal.aborted) {
261
+ return stop("lookup_timeout", `the lookup did not finish within ${deadlineMs} ms (${failures.length} of ${candidates.length} candidates had failed)`);
262
+ }
263
+ if (answered === 0 && failures.length > 0) {
264
+ return stop("lookup_failed", `no candidate answered (${failures.length} of ${candidates.length} failed): ${failures.join(" | ")}`);
265
+ }
266
+ return { notice: null };
267
+ }
268
+ finally {
269
+ clearTimeout(timer);
270
+ }
271
+ }
@@ -0,0 +1,31 @@
1
+ import { createHash } from "node:crypto";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { writeFileDurable } from "./durable-write.js";
5
+ /**
6
+ * DX-4400 — the most characters one `50arms_api`, `50arms_api_spec` or `50arms_api_list` result may carry
7
+ * back to the agent. Every character is context the agent pays for on every later call, and Claude Code itself
8
+ * saves a larger result to a file with only a short preview, after the cost is paid. A deliberate, narrow
9
+ * exception to PLAN-11 rule R-25 (verbatim forwarding): `plan_connect` and the lifecycle tools never pass here.
10
+ */
11
+ export const MCP_MAX_RESULT_CHARS = 100_000;
12
+ const FILE_INSTRUCTION = "The reply was over the inline limit, so its body is this file, whole: read it with Read (in parts with offset and limit when it is long), or grep it.";
13
+ /**
14
+ * The text the agent receives for `result`: its compact JSON when that fits `MCP_MAX_RESULT_CHARS`, otherwise
15
+ * `{ok, status, reply_file:{path, bytes, chars, instruction}}`, the body written whole to
16
+ * `<tmp>/danx-api/<content hash>.json` (DX-6117, as `docs_read` and the role text do). Never truncates, never forwards a part. `ok`
17
+ * and `status` stay so a write that already committed is still reported as committed.
18
+ */
19
+ export function boundedResultText(result, deps = {}) {
20
+ const text = JSON.stringify(result);
21
+ if (text.length <= MCP_MAX_RESULT_CHARS)
22
+ return text;
23
+ const body = JSON.stringify(result.body);
24
+ const file = path.join(deps.tmpDir ?? os.tmpdir(), "danx-api", `${createHash("sha256").update(body).digest("hex").slice(0, 16)}.json`);
25
+ writeFileDurable(file, body);
26
+ return JSON.stringify({
27
+ ok: result.ok,
28
+ status: result.status,
29
+ reply_file: { path: file, bytes: Buffer.byteLength(body, "utf8"), chars: body.length, instruction: FILE_INSTRUCTION },
30
+ });
31
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * DX-6089 - a session's role text reaches it as a local file, never inline. The dashboard answers `roleInstruction: {profile, text}` on
3
+ * `plan_connect`, `card_connect` and `GET /api/plan-sessions/me/role`; the text runs to tens of KB, and a tool reply that large is
4
+ * saved to a file by the client with only a short preview, which also hides every field after it (the briefing, the agent instruction, the
5
+ * reminders). So this server writes the text to `<tmp>/danx-role/<profile>-<content hash>.md` and answers `{profile, path, bytes, instruction}`
6
+ * in its place, leaving every other field inline, as `docs_read` does for a docs page (`docs-tools.ts`). The name carries the content hash, so a
7
+ * changed text never overwrites a file a session is reading, and the file is written under a temp name and renamed into place, so a reader never
8
+ * sees half of it.
9
+ */
10
+ import { createHash } from "node:crypto";
11
+ import os from "node:os";
12
+ import path from "node:path";
13
+ import { writeFileDurable } from "./durable-write.js";
14
+ export const ROLE_FILE_INSTRUCTION = "Read this file in full now, with Read (in parts with offset and limit when it is long): it is your role's workflow, the procedure you work by.";
15
+ // The profile names the file, so it must be a plain name: never a path a reply could steer.
16
+ const PROFILE_NAME = /^[a-z][a-z0-9-]*$/;
17
+ /**
18
+ * `result` with its body's `roleInstruction` written to a file and replaced by where it is. A body without the key, or with `null` (nothing to
19
+ * send), passes unchanged. A `roleInstruction` of any other shape is a dashboard contract break and throws (CP1).
20
+ */
21
+ export function spillRoleInstruction(result, deps = {}) {
22
+ const body = result.body;
23
+ if (typeof body !== "object" || body === null || !Object.hasOwn(body, "roleInstruction"))
24
+ return result;
25
+ const role = body.roleInstruction;
26
+ if (role === null)
27
+ return result;
28
+ const { profile, text } = (role ?? {});
29
+ if (typeof profile !== "string" || !PROFILE_NAME.test(profile) || typeof text !== "string") {
30
+ throw new Error(`the dashboard answered a roleInstruction that is not {profile, text}: ${JSON.stringify(role).slice(0, 200)}`);
31
+ }
32
+ const file = path.join(deps.tmpDir ?? os.tmpdir(), "danx-role", `${profile}-${createHash("sha256").update(text).digest("hex").slice(0, 16)}.md`);
33
+ writeFileDurable(file, text);
34
+ const spilled = { profile, path: file, bytes: Buffer.byteLength(text, "utf8"), instruction: ROLE_FILE_INSTRUCTION };
35
+ return { ...result, body: { ...body, roleInstruction: spilled } };
36
+ }
@@ -0,0 +1,95 @@
1
+ export const GET_ROUTE_BOARD_SCOPE = [
2
+ ["/api/access-requests", "refuse"],
3
+ ["/api/account/sessions", "refuse"],
4
+ ["/api/agents/:agentId/halt-history", "refuse"],
5
+ ["/api/audit-events", "refuse"],
6
+ ["/api/boards", "refuse"],
7
+ ["/api/boards/:boardId", "refuse"],
8
+ ["/api/boards/:boardId/context-cost", "refuse"],
9
+ ["/api/boards/:boardId/usage-series", "refuse"],
10
+ ["/api/catalog/:catalogId", "refuse"],
11
+ ["/api/dispatch-queue", "refuse"],
12
+ ["/api/dispatches", "refuse"],
13
+ ["/api/dispatches/:dispatchId", "stamp"],
14
+ ["/api/dispatches/:dispatchId/context-series", "refuse"],
15
+ ["/api/dispatches/:dispatchId/logs", "refuse"],
16
+ ["/api/dispatches/:dispatchId/usage-events", "refuse"],
17
+ ["/api/dispatches/dead-letter-ids", "refuse"],
18
+ ["/api/docs/:repo/changes", "refuse"],
19
+ ["/api/docs/:repo/pages/:id", "refuse"],
20
+ ["/api/docs/:repo/progress/:pathId", "refuse"],
21
+ ["/api/docs/:repo/search", "refuse"],
22
+ ["/api/failure-categories", "refuse"],
23
+ ["/api/failure-categories/ambiguous", "refuse"],
24
+ ["/api/failure-categories/uncategorized", "refuse"],
25
+ ["/api/failure-signatures", "refuse"],
26
+ ["/api/feedback/mine", "refuse"],
27
+ ["/api/issues", "named"],
28
+ ["/api/issues/:id/decision-records", "refuse"],
29
+ ["/api/issues/:id/problems", "refuse"],
30
+ ["/api/issues/:id/usage-runs", "refuse"],
31
+ ["/api/issues/:id/usage-series", "refuse"],
32
+ ["/api/issues/:issueId/dispatches", "refuse"],
33
+ ["/api/landing-queue/pipeline-health", "refuse"],
34
+ ["/api/model-ledger", "refuse"],
35
+ ["/api/notifications", "refuse"],
36
+ ["/api/pacing/history", "refuse"],
37
+ ["/api/permission-requests", "refuse"],
38
+ ["/api/plan-sessions", "refuse"],
39
+ ["/api/plan-sessions/ready", "refuse"],
40
+ ["/api/plans", "refuse"],
41
+ ["/api/plans/:planId/architecture/sections", "refuse"],
42
+ ["/api/plans/:planId/cards", "refuse"],
43
+ ["/api/plans/:planId/designs", "refuse"],
44
+ ["/api/plans/:planId/notes", "refuse"],
45
+ ["/api/plans/:planId/records", "refuse"],
46
+ ["/api/plans/:planId/usage-series", "refuse"],
47
+ ["/api/plans/mine/architecture/sections", "refuse"],
48
+ ["/api/plans/mine/records", "refuse"],
49
+ ["/api/platform/feedback", "refuse"],
50
+ ["/api/platform/signup-codes", "refuse"],
51
+ ["/api/platform/teams", "refuse"],
52
+ ["/api/plugins", "refuse"],
53
+ ["/api/product-docs/pages/:id", "refuse"],
54
+ ["/api/product-docs/search", "refuse"],
55
+ ["/api/reminders", "refuse"],
56
+ ["/api/repos", "refuse"],
57
+ ["/api/security-incidents", "refuse"],
58
+ ["/api/self-repair/errors", "refuse"],
59
+ ["/api/sync-root", "refuse"],
60
+ ["/api/tasks", "refuse"],
61
+ ["/api/team", "refuse"],
62
+ ["/api/team/activity", "refuse"],
63
+ ["/api/team/audit-events", "refuse"],
64
+ ["/api/team/consumer-tokens", "refuse"],
65
+ ["/api/team/cost/landed-cards", "refuse"],
66
+ ["/api/team/delivery", "refuse"],
67
+ ["/api/team/invitations", "refuse"],
68
+ ["/api/team/members", "refuse"],
69
+ ["/api/team/overview/boards", "refuse"],
70
+ ["/api/team/overview/boards-spend", "refuse"],
71
+ ["/api/team/overview/plans", "refuse"],
72
+ ["/api/team/overview/plans-spend", "refuse"],
73
+ ["/api/team/quality/dispatches", "refuse"],
74
+ ["/api/team/quality/effort", "refuse"],
75
+ ["/api/team/quality/gates", "refuse"],
76
+ ["/api/team/quality/people", "refuse"],
77
+ ["/api/team/roles", "refuse"],
78
+ ["/api/test-runs/file-times", "refuse"],
79
+ ["/api/test-runs/stats", "refuse"],
80
+ ["/api/usage/series", "refuse"],
81
+ ["/api/users", "refuse"],
82
+ ["/api/widget-surfaces", "refuse"],
83
+ ["/api/worker/catalog-mcp-servers", "refuse"],
84
+ ["/api/worker/dispatches/:dispatchId", "stamp"],
85
+ ["/api/worker/dispatches/:dispatchId/usage-predecessor", "refuse"],
86
+ ["/api/worker/dispatches/non-terminal", "refuse"],
87
+ ["/api/worker/dispatches/recent-signal-failures", "refuse"],
88
+ ["/api/worker/test-runs/file-times", "refuse"],
89
+ ["/api/workers", "refuse"],
90
+ ];
91
+ /** DX-5337 - the routes that read the card's board from `body.board` only: the client fills a dispatch's board in there and sends no `?board=`. */
92
+ export const BODY_BOARD_ROUTES = [
93
+ ["POST", "/api/issues"],
94
+ ["POST", "/api/issues/import"],
95
+ ];
@@ -0,0 +1,71 @@
1
+ /**
2
+ * DX-4303 / DX-4542 - what the client does with a `?board=` query key on the dashboard route a request reaches: `stamp` it (the route
3
+ * takes it, so the dispatch's board is filled in when the server has one), `refuse` it (the server 400s it) or `named` (the board is optional and no board is the
4
+ * whole team: sent only when the caller names one).
5
+ *
6
+ * The answer comes from the dashboard's own route table, shipped in this package as `route-board-scope.generated.ts` (generated by
7
+ * the server's `src/dashboard/mcp-board-scope.ts`; a root test fails when it drifts). This file only matches a concrete request
8
+ * path against those templates, the way the server's `RouteTable` does: segments compared left to right, a literal beating a
9
+ * `:param` beating a trailing `*`. It holds no route knowledge of its own.
10
+ *
11
+ * Only GET routes are listed - the server refuses an unknown query key on a read alone - so every other method takes the key. A
12
+ * GET path no template matches takes it too: the server answers it with a 404 whatever the query.
13
+ */
14
+ import { BODY_BOARD_ROUTES, GET_ROUTE_BOARD_SCOPE } from "./route-board-scope.generated.js";
15
+ const RANK = { literal: 2, param: 1, rest: 0 };
16
+ function compile(template, stamp) {
17
+ const segments = template
18
+ .slice(1)
19
+ .split("/")
20
+ .map((s) => (s === "*" ? { kind: "rest" } : s.startsWith(":") ? { kind: "param" } : { kind: "literal", value: s }));
21
+ return { segments, rank: segments.map((s) => RANK[s.kind]), stamp };
22
+ }
23
+ function compareRank(a, b) {
24
+ const n = Math.max(a.length, b.length);
25
+ for (let i = 0; i < n; i++) {
26
+ const x = a[i] ?? -1;
27
+ const y = b[i] ?? -1;
28
+ if (x !== y)
29
+ return y - x;
30
+ }
31
+ return 0;
32
+ }
33
+ const ROUTES = GET_ROUTE_BOARD_SCOPE.map(([template, stamp]) => compile(template, stamp)).sort((a, b) => compareRank(a.rank, b.rank));
34
+ function matches(route, parts) {
35
+ for (let i = 0; i < route.segments.length; i++) {
36
+ const seg = route.segments[i];
37
+ if (seg.kind === "rest")
38
+ return true;
39
+ const part = parts[i];
40
+ if (part === undefined)
41
+ return false;
42
+ if (seg.kind === "literal" ? part !== seg.value : part === "")
43
+ return false;
44
+ }
45
+ return parts.length === route.segments.length;
46
+ }
47
+ /**
48
+ * What the client does with `?board=` on `<method> <routePath>` (no query string): `stamp` the dispatch's board when the caller names none,
49
+ * `refuse` to send it (the server 400s it), or (DX-4542) `named`: the route takes it OPTIONALLY and no board is the whole team, so it is sent
50
+ * only when the caller names one.
51
+ */
52
+ export function boardStampOf(method, routePath) {
53
+ if (method !== "GET")
54
+ return "stamp";
55
+ const parts = routePath.slice(1).split("/");
56
+ return ROUTES.find((route) => matches(route, parts))?.stamp ?? "stamp";
57
+ }
58
+ /**
59
+ * DX-5337 - the routes that read the card's board ONLY from `body.board` (issue create and import), from the generated
60
+ * `BODY_BOARD_ROUTES` (the server's `RouteDef.boardInBody`). A dispatch's default board reaches them through the body and through
61
+ * nothing else: the client fills `body.board` when the caller named none and sends no `?board=` there.
62
+ */
63
+ const BODY_BOARD = BODY_BOARD_ROUTES.map(([method, template]) => ({
64
+ method,
65
+ route: compile(template, "stamp"),
66
+ }));
67
+ /** Whether `<method> <routePath>` (no query string) takes its board in `body.board`. */
68
+ export function takesBoardInBody(method, routePath) {
69
+ const parts = routePath.replace(/\/+$/, "").slice(1).split("/");
70
+ return BODY_BOARD.some((entry) => entry.method === method && matches(entry.route, parts));
71
+ }