@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,385 @@
1
+ /**
2
+ * Per-tool handlers for the dashboard MCP server.
3
+ *
4
+ * Each handler is a pure async function: validate args (Zod at the MCP
5
+ * boundary), build the HTTP request the corresponding `/api/issues/*`
6
+ * route expects, and pass the envelope back to the caller verbatim.
7
+ *
8
+ * BOARD-ONLY (DX-1171): board is the first-level concept; repo is
9
+ * DERIVED from board server-side, never passed. Every handler accepts an
10
+ * optional `board` arg — a board ref (the number that numbers the board within its team, DX-6486 DEC-186). When
11
+ * set, it overrides the dispatch's configured board for that single call
12
+ * (forwarded as `?board=<board>` — and into `body.board` for create — so
13
+ * an agent can target any board). Omitted → falls back to the dispatch's
14
+ * env-derived board, if any (a board-less server stamps none). The dashboard route validates the board (owning
15
+ * board→repo) and 404s on unknown; the MCP forwards the refusal envelope
16
+ * verbatim, never duplicates validation.
17
+ *
18
+ * Zero transformation of the server response. Refusal payloads carry
19
+ * structured detail (`failed_gate`, `non_terminal_phases`,
20
+ * `offending_keys`, `received_reason`, etc.) that the agent uses to
21
+ * decide its next move; swallowing any of it would force the agent to
22
+ * guess.
23
+ */
24
+ import { posix, win32 } from "node:path";
25
+ import { readFile, stat } from "node:fs/promises";
26
+ // ---------------- plan_connect (DX-2683) ----------------
27
+ // DX-3465 (PLN-11 R-25) — every other per-operation handler that used to
28
+ // live in this file (issue_*, plan_* except plan_connect, brief_*,
29
+ // repo_knowledge_*, failure_category_*, dispatch_transcript_search,
30
+ // quality_gate_instruction) is deleted: `danxbotApi`/`danxbotApiSpec` below
31
+ // are the generic replacement. `plan_connect` stays a dedicated tool because
32
+ // it is session-bound (see `index.ts`'s own comment on this).
33
+ const PLAN_SESSIONS_BASE_PATH = "/api/plan-sessions";
34
+ /**
35
+ * Connect THIS session to a plan — the same write the operator's Connect
36
+ * action performs, reaching the same server-side code path. `me` in the URL
37
+ * is resolved from the forwarded session id, so this can only ever bind the
38
+ * caller's own session.
39
+ *
40
+ * A session already on another plan is MOVED, and the response says which
41
+ * plan it left (`movedFrom`).
42
+ *
43
+ * IT DOES NOTHING ABOUT HEARING THE PLAN'S EVENTS, deliberately. Delivery belongs
44
+ * to the plan event relay inside this MCP server (`plan_events_wait`, `plan-events-relay.ts`),
45
+ * which holds the session's ONE listener ticket and is started by the 50arms plugin's loop once this tool
46
+ * succeeds. The dashboard keeps one ticket per session, so a ticket minted here
47
+ * would end the relay's stream.
48
+ *
49
+ * `title` (DX-2816) rides here, not on a header, because the AGENT — not this
50
+ * server — can read Claude's own session title at connect time (via a
51
+ * Desktop-side `get_session` tool no MCP server subprocess can call). Sent
52
+ * only when the caller supplied one; the server leaves an unset title alone.
53
+ * The event relay keeps it current afterwards, from the transcript (DX-4461,
54
+ * `session-title.ts`).
55
+ */
56
+ export async function planConnect(client, args) {
57
+ if (args.disconnect === true) {
58
+ if (args.title !== undefined) {
59
+ return { ok: false, status: 400, body: { error: "plan_connect: `disconnect: true` takes no `title` — a leave changes no title" } };
60
+ }
61
+ // DX-2985 — `DELETE /api/plan-sessions/me/plan?plan_id=`: `me` is the forwarded session id, so only the caller's own session leaves.
62
+ return client.request({
63
+ method: "DELETE",
64
+ path: "/me/plan",
65
+ basePath: PLAN_SESSIONS_BASE_PATH,
66
+ query: { plan_id: args.plan_id },
67
+ });
68
+ }
69
+ return client.request({
70
+ method: "POST",
71
+ path: "/me/plan",
72
+ basePath: PLAN_SESSIONS_BASE_PATH,
73
+ body: {
74
+ plan_id: args.plan_id,
75
+ ...(args.title === undefined ? {} : { title: args.title }),
76
+ ...(args.assignment_id === undefined ? {} : { assignment_id: args.assignment_id }),
77
+ },
78
+ });
79
+ }
80
+ /**
81
+ * DX-5549 - sign THIS session in as a team agent without joining a plan: `POST /api/plan-sessions/me/agent`. `me` is the forwarded session
82
+ * id, so it can only ever claim for the caller's own session. Answers `state: agent_held` with the agent and the instruction to use its name
83
+ * and personality, or `agent_needed` / `agent_limit_reached` when the session has none (see `agent-gate.ts`).
84
+ */
85
+ export async function claimSessionAgent(client) {
86
+ return client.request({ method: "POST", path: "/me/agent", basePath: PLAN_SESSIONS_BASE_PATH });
87
+ }
88
+ /**
89
+ * DX-5553 - hold ONE stand-alone card (a card on no plan) instead of a plan, or let it go: `POST|DELETE /api/plan-sessions/me/card`. `me` is the forwarded session id, so only
90
+ * the caller's own session is ever bound. Like `plan_connect`, the answer carries what the session missed (`missedEvents`) and the exact thread title to take (`naming`); the
91
+ * session then hears that card's and its descendants' events through the same relay as a plan's.
92
+ */
93
+ export async function cardConnect(client, args) {
94
+ if (args.disconnect === true) {
95
+ if (args.title !== undefined) {
96
+ return { ok: false, status: 400, body: { error: "card_connect: `disconnect: true` takes no `title` — a leave changes no title" } };
97
+ }
98
+ return client.request({ method: "DELETE", path: "/me/card", basePath: PLAN_SESSIONS_BASE_PATH, query: { card_id: args.card_id } });
99
+ }
100
+ return client.request({
101
+ method: "POST",
102
+ path: "/me/card",
103
+ basePath: PLAN_SESSIONS_BASE_PATH,
104
+ body: { card_id: args.card_id, ...(args.title === undefined ? {} : { title: args.title }), ...(args.assignment_id === undefined ? {} : { assignment_id: args.assignment_id }) },
105
+ });
106
+ }
107
+ // DX-3465 (code-architecture gate finding, at 275f82c0d) — the dedicated
108
+ // `resource_fields` handler (DX-3426) was deleted from here: it was a thin
109
+ // wrapper around `GET /api/resources/:resource/fields`, which `50arms_api`
110
+ // already reaches with nothing agent-specific in it. See `index.ts`'s own
111
+ // note at the former tool-registration site.
112
+ // ---------------- 50arms_api (DX-3465, PLN-11 R-25) ----------------
113
+ export const DANXBOT_API_METHODS = ["GET", "POST", "PATCH", "PUT", "DELETE"];
114
+ export function isDanxbotApiMethod(value) {
115
+ return typeof value === "string" && DANXBOT_API_METHODS.includes(value);
116
+ }
117
+ /** The file types a `body_file` may carry, by extension; the content type sent is the one the extension names. The one route that takes a file today re-draws each as a PNG (the proposal avatar). */
118
+ export const BODY_FILE_MIME_BY_EXT = {
119
+ ".png": "image/png",
120
+ ".jpg": "image/jpeg",
121
+ ".jpeg": "image/jpeg",
122
+ ".webp": "image/webp",
123
+ ".svg": "image/svg+xml",
124
+ };
125
+ /** The largest `body_file` this server sends: the dashboard's own avatar limit (`AVATAR_MAX_BYTES`, `src/dashboard/avatar-upload.ts`), which this package cannot import. */
126
+ export const MAX_BODY_FILE_BYTES = 1_000_000;
127
+ /** DX-5633: the platform's own path rules (a Windows drive path is absolute only under win32). */
128
+ function pathRulesFor(platform) {
129
+ return (platform ?? process.platform) === "win32" ? win32 : posix;
130
+ }
131
+ /** A `body_file` checked and read as a raw request body. Every refusal throws before any request is made. */
132
+ async function readRawBodyFile(path, deps) {
133
+ const { isAbsolute, basename, extname } = pathRulesFor(deps.platform);
134
+ if (!isAbsolute(path))
135
+ throw new Error(`50arms_api: body_file must be an absolute path (got "${path}")`);
136
+ const contentType = BODY_FILE_MIME_BY_EXT[extname(path).toLowerCase()];
137
+ if (!contentType) {
138
+ throw new Error(`50arms_api: body_file accepts only ${Object.keys(BODY_FILE_MIME_BY_EXT).join(", ")} files (got "${extname(path) || basename(path)}")`);
139
+ }
140
+ // Stat first so an oversized file is refused without being read into memory.
141
+ const size = await (deps.fileSize ?? (async (p) => (await stat(p)).size))(path);
142
+ if (size === 0)
143
+ throw new Error(`50arms_api: body_file "${path}" is empty`);
144
+ if (size > MAX_BODY_FILE_BYTES)
145
+ throw new Error(`50arms_api: body_file "${path}" is ${size} bytes; the limit is ${MAX_BODY_FILE_BYTES} (1 MB)`);
146
+ return { bytes: await (deps.readFile ?? readFile)(path), contentType };
147
+ }
148
+ /**
149
+ * `50arms_api({method, path, query, body})` — the ONE generic tool that
150
+ * reaches every dashboard API route this credential is allowed to call
151
+ * (R-25, DX-3465). Replaces the ~45 per-operation tools this package used to
152
+ * ship one-for-one against each route: a new route needs a permission and a
153
+ * spec entry (`50arms_api_spec`), never a new tool.
154
+ *
155
+ * The credential lives inside `client` (resolved once at boot — the
156
+ * operator's declared token file for a session, the per-dispatch
157
+ * `DANX_DASHBOARD_CREDENTIAL` for a worker) and never appears in this
158
+ * function's arguments or return value — nothing in `DanxbotApiArgs` can
159
+ * carry one. The session id (`x-danx-session-id`) rides every call the SAME
160
+ * way it already does for every other tool on this server
161
+ * (`http-client.ts#request` stamps it from `config.session`); this tool adds
162
+ * no special-casing for that.
163
+ *
164
+ * `path` MUST start with `/api/` — this is a guardrail against a caller
165
+ * accidentally reaching for something outside the dashboard's own API
166
+ * surface (there is nothing else this credential could reach through
167
+ * `client`'s configured `baseUrl` that would make sense to call this way),
168
+ * not a route allowlist: the dashboard's own route policy (`authorize()`,
169
+ * `src/dashboard/route-policy.ts`) is the actual authorization boundary —
170
+ * an unknown or unauthorized route 404s/401s/403s exactly as it would to any
171
+ * other caller, and that refusal body (which names the fix — CP1) is
172
+ * returned verbatim, unmodified, like every other route's response.
173
+ *
174
+ * The response is the target route's JSON body, VERBATIM — including its
175
+ * own `reminders[]` field when the route embeds one (every reminder-
176
+ * registry row is keyed on `{method, route}` now, DX-3465 item 5, so a
177
+ * route that used to attach a reminder to a deleted per-operation tool's
178
+ * response still attaches it here, with zero special MCP-side plumbing).
179
+ */
180
+ export async function danxbotApi(client, args, deps = {}) {
181
+ // The file is checked and read BEFORE the request, so a bad path leaves nothing behind on the dashboard.
182
+ const rawBody = args.body_file === undefined ? undefined : await readRawBodyFile(args.body_file, deps);
183
+ return client.request({
184
+ method: args.method,
185
+ // `basePath: ""` — `path` here is the FULL absolute path already (see
186
+ // the tool's own docs), unlike every other handler in this file, which
187
+ // passes a path RELATIVE to a fixed `basePath` (`/api/issues`,
188
+ // `/api/plans`, ...). This tool has no fixed base; the caller names the
189
+ // whole route.
190
+ basePath: "",
191
+ path: args.path,
192
+ query: args.query,
193
+ body: args.body,
194
+ rawBody,
195
+ board: args.board,
196
+ background: args.background,
197
+ });
198
+ }
199
+ // ---------------- 50arms_api_list (DX-3465 A5, PLN-11 R-25) ----------------
200
+ const ROUTES_BASE_PATH = "/api/routes";
201
+ /**
202
+ * `50arms_api_list()` — the routes YOUR credential may currently call,
203
+ * `{routes: ["METHOD /path"]}`, one string each (DX-4401). Backed by DX-3528's
204
+ * `GET /api/routes`, which filters the served route table through the SAME
205
+ * `isRouteAuthorizedForPrincipal` check `authorize()` enforces on every
206
+ * request — a route listed here is always callable, and a route this
207
+ * credential cannot call never appears. This is how you find the route for
208
+ * something you need to do: call this first when you don't already know the
209
+ * path, then `50arms_api_spec({endpoints})` for the exact contract of the
210
+ * one(s) you picked, then `50arms_api` to call it. No arguments — the
211
+ * server-side route table has no filter to pass.
212
+ */
213
+ export async function danxbotApiList(client) {
214
+ return client.request({
215
+ method: "GET",
216
+ path: "",
217
+ basePath: ROUTES_BASE_PATH,
218
+ });
219
+ }
220
+ // ---------------- 50arms_api_spec (DX-3465, PLN-11 R-25) ----------------
221
+ const OPENAPI_BASE_PATH = "/api/openapi";
222
+ /**
223
+ * `50arms_api_spec({endpoints})` — the exact OpenAPI 3.1 contract (DX-3527)
224
+ * for the named routes: request/response shapes, required permission/
225
+ * consumer action, and every documented error envelope. Call this before a
226
+ * `50arms_api` call whose shape you are not already sure of — "a 4xx body
227
+ * names the fix; check the spec" (the MCP server's own shared instructions).
228
+ *
229
+ * Backed by DX-3528's permission-aware `GET /api/openapi` — the SAME
230
+ * `isRouteAuthorizedForPrincipal` check `50arms_api_list` uses, so an
231
+ * endpoint this credential may not call refuses loud (400
232
+ * `invalid_endpoints`, naming it under `unknown` or `disallowed`) rather
233
+ * than silently documenting a route you cannot actually reach.
234
+ */
235
+ export async function danxbotApiSpec(client, args) {
236
+ return client.request({
237
+ method: "GET",
238
+ path: "",
239
+ basePath: OPENAPI_BASE_PATH,
240
+ query: { endpoints: args.endpoints.join(",") },
241
+ });
242
+ }
243
+ /**
244
+ * Extension → MIME map for the agent-attached file types that matter for
245
+ * downstream rendering (image previews in the dashboard drawer, Trello
246
+ * card, and Slack thread all key on the persisted `mime`). This package is
247
+ * a standalone published artifact and cannot import the dashboard
248
+ * backend's own MIME table, so the small map lives here. An unknown
249
+ * extension resolves to the generic octet-stream — a sensible default for
250
+ * "we don't know the type", NOT a silenced error (the bytes still upload +
251
+ * the row is still written).
252
+ */
253
+ const MIME_BY_EXT = {
254
+ ".png": "image/png",
255
+ ".jpg": "image/jpeg",
256
+ ".jpeg": "image/jpeg",
257
+ ".gif": "image/gif",
258
+ ".webp": "image/webp",
259
+ ".svg": "image/svg+xml",
260
+ ".pdf": "application/pdf",
261
+ ".csv": "text/csv",
262
+ ".txt": "text/plain",
263
+ ".log": "text/plain",
264
+ ".md": "text/markdown",
265
+ ".json": "application/json",
266
+ ".html": "text/html",
267
+ ".zip": "application/zip",
268
+ ".gz": "application/gzip",
269
+ ".tgz": "application/gzip",
270
+ };
271
+ /**
272
+ * DX-5241: the types and ceiling of `POST /api/uploads/images` (`src/dashboard/uploads-images-route.ts`, no SVG). This package is a separate
273
+ * published artifact and cannot import them, so they are restated here - keep them in sync with that route. The route enforces both too;
274
+ * checking here first gives the agent a plain refusal without reading or sending a file the route would refuse.
275
+ */
276
+ export const INLINE_IMAGE_MIME_BY_EXT = {
277
+ ".png": "image/png",
278
+ ".jpg": "image/jpeg",
279
+ ".jpeg": "image/jpeg",
280
+ ".gif": "image/gif",
281
+ ".webp": "image/webp",
282
+ ".avif": "image/avif",
283
+ };
284
+ export const MAX_INLINE_IMAGE_BYTES = 25 * 1024 * 1024;
285
+ export function inferAttachmentMime(filename) {
286
+ return MIME_BY_EXT[posix.extname(filename).toLowerCase()] ?? "application/octet-stream";
287
+ }
288
+ /**
289
+ * Attach a local file to a card (`target` "card", the default; replies with the attachment row) or, with `target: "inline_image"`, upload
290
+ * a local image to `POST /api/uploads/images` and reply `{url, markdown}` (see `uploadInlineImage`). Reads the bytes from the dispatch's
291
+ * shared filesystem (this MCP server runs co-located with the agent that
292
+ * calls it), then forwards to the dashboard's `POST /api/issues/:id/attachments`
293
+ * route (or, with `replaces`, `PUT /api/issues/:id/attachments/:aid`, which swaps that row's bytes in place) — the
294
+ * SAME ingest the dashboard UI's own upload uses: stores the
295
+ * bytes in S3, inserts or updates one attachment row, and (via the dashboard's
296
+ * existing outbound projection) auto-mirrors the file to the card's
297
+ * linked Trello card and Slack thread. Board-scoped; see `board`.
298
+ *
299
+ * Fail-loud (`base:fail-loudly`): `path` is validated (non-empty +
300
+ * absolute) and the file is read BEFORE any HTTP call, so a bad/missing/
301
+ * unreadable path throws here and NEVER reaches S3, the DB row, or the
302
+ * projection. THE FILE'S BYTES NEVER APPEAR IN THIS FUNCTION'S ARGUMENTS
303
+ * OR IN THE AGENT'S OWN TRANSCRIPT — they are read directly off disk into
304
+ * this process's memory and sent straight to the dashboard, the same
305
+ * privacy property `50arms_api`'s credential handling has for tokens. An
306
+ * S3 / server fault on the route surfaces as a 5xx, which the HTTP client
307
+ * throws.
308
+ */
309
+ export async function attachFile(client, args, deps = {}) {
310
+ if (typeof args.path !== "string" || args.path.trim() === "") {
311
+ throw new Error("attach_file: path must be a non-empty string");
312
+ }
313
+ const { isAbsolute, basename, extname } = pathRulesFor(deps.platform);
314
+ if (!isAbsolute(args.path)) {
315
+ throw new Error(`attach_file: path must be an absolute path (got "${args.path}")`);
316
+ }
317
+ const read = deps.readFile ?? readFile;
318
+ if (args.target === "inline_image") {
319
+ return uploadInlineImage(client, args, read, deps.fileSize ?? (async (p) => (await stat(p)).size), { basename, extname });
320
+ }
321
+ // DX-4386: the replaced row keeps its stored name, so a filename override has nothing to apply to - refuse it loudly, before any read.
322
+ if (args.replaces !== undefined && args.filename !== undefined) {
323
+ throw new Error("attach_file: filename cannot be set with replaces (the replaced attachment keeps its name)");
324
+ }
325
+ if (typeof args.card_id !== "string" || args.card_id.trim() === "") {
326
+ throw new Error("attach_file: card_id is required unless target is inline_image");
327
+ }
328
+ // A missing / unreadable file rejects here — no HTTP call is made, so no
329
+ // S3 upload, no row insert, and no projection are ever triggered.
330
+ const bytes = await read(args.path);
331
+ const contentTypeOf = (name) => (args.content_type?.trim() ? args.content_type.trim() : inferAttachmentMime(name));
332
+ const issuePath = `/${encodeURIComponent(args.card_id)}/attachments`;
333
+ // DX-4386: `primary` is the dashboard's own atomic option (one live cover, set in the write's own transaction); an absent flag sends no key.
334
+ const primary = args.primary === undefined ? {} : { primary: args.primary };
335
+ if (args.replaces !== undefined) {
336
+ return client.request({
337
+ method: "PUT",
338
+ basePath: "/api/issues",
339
+ path: `${issuePath}/${args.replaces}`,
340
+ body: { contentType: contentTypeOf(basename(args.path)), base64: bytes.toString("base64"), ...primary },
341
+ board: args.board,
342
+ });
343
+ }
344
+ const filename = args.filename?.trim() ? args.filename.trim() : basename(args.path);
345
+ return client.request({
346
+ method: "POST",
347
+ basePath: "/api/issues",
348
+ path: issuePath,
349
+ body: { filename, contentType: contentTypeOf(filename), base64: bytes.toString("base64"), ...primary },
350
+ board: args.board,
351
+ });
352
+ }
353
+ /**
354
+ * DX-5241 / PLAN-47 CAV-1: upload a local image to `POST /api/uploads/images` and answer its permanent URL plus the markdown to embed it
355
+ * (feedback bodies, any markdown field). `50arms_api`'s `body_file` sends one raw file of at most 1 MB to a route that takes a file; this adds the upload routes, more image types and the 25 MB limit. The bytes are read here, never through the agent's context.
356
+ * Everything that makes no sense for an inline image (a card, a replace, a cover flag, a name or type override) is refused before any read.
357
+ */
358
+ async function uploadInlineImage(client, args, read, fileSize, { basename, extname }) {
359
+ for (const key of ["card_id", "filename", "content_type", "replaces", "primary", "board"]) {
360
+ if (args[key] !== undefined)
361
+ throw new Error(`attach_file: ${key} cannot be set with target inline_image`);
362
+ }
363
+ const ext = extname(args.path).toLowerCase();
364
+ const contentType = INLINE_IMAGE_MIME_BY_EXT[ext];
365
+ if (!contentType) {
366
+ throw new Error(`attach_file: inline_image accepts only ${Object.keys(INLINE_IMAGE_MIME_BY_EXT).join(", ")} files (got "${ext || basename(args.path)}"); no SVG`);
367
+ }
368
+ // Stat first so an oversized file is refused without being read into memory.
369
+ const size = await fileSize(args.path);
370
+ if (size === 0)
371
+ throw new Error(`attach_file: "${args.path}" is empty`);
372
+ if (size > MAX_INLINE_IMAGE_BYTES) {
373
+ throw new Error(`attach_file: "${args.path}" is ${size} bytes; the limit is ${MAX_INLINE_IMAGE_BYTES} (25 MB)`);
374
+ }
375
+ const bytes = await read(args.path);
376
+ const result = await client.request({ method: "POST", basePath: "/api/uploads", path: "/images", rawBody: { bytes, contentType } });
377
+ if (!result.ok)
378
+ return result;
379
+ const url = result.body?.url;
380
+ if (typeof url !== "string" || url === "") {
381
+ throw new Error("attach_file: the image upload answered no url");
382
+ }
383
+ const alt = basename(args.path, extname(args.path)).replace(/[[\]]/g, "");
384
+ return { ...result, body: { url, markdown: `![${alt}](${url})` } };
385
+ }
@@ -0,0 +1,156 @@
1
+ /**
2
+ * DX-3028 / DX-4233 - ONE heartbeat tick of a session's event stream, of the in-process relay behind
3
+ * `plan_events_wait`: what to send (connection state, session stats, the transcript's title), what
4
+ * to do with the answer, and the self-heal for a stream that reads as open while no heartbeat lands. WHEN a tick fires, and
5
+ * what an announced notice does, belongs to the owner.
6
+ *
7
+ * A tick never throws and never stops the stream: the stats and title are cosmetic and the heartbeat is liveness, so a
8
+ * failure of either costs that part (said once on stderr), never the beat.
9
+ */
10
+ import { keyRevokedHalt } from "./key-revoked-halt.js";
11
+ import { oneLine } from "./one-line.js";
12
+ import { DIGEST_PREFIX } from "./digest-hold.js";
13
+ import { HEARTBEAT_SILENT_FAILURE_THRESHOLD, HEARTBEAT_SUSTAINED_OUTAGE_MS, sendHeartbeat, } from "./session-stream.js";
14
+ import { OperatorSessionStats } from "./session-stats.js";
15
+ import { TranscriptTitleReader } from "./session-title.js";
16
+ /** DX-5415: how long a stop waits for the final settle (drain + one stats flush) before giving up; shutdown must stay bounded. */
17
+ export const FINAL_SETTLE_BOUND_MS = 10_000;
18
+ function settleBound() {
19
+ return AbortSignal.timeout(FINAL_SETTLE_BOUND_MS);
20
+ }
21
+ export class HeartbeatReporter {
22
+ deps;
23
+ sessionStats;
24
+ titleReader;
25
+ transcriptPath;
26
+ // DX-4461 - the title the dashboard last answered with. Only the LATEST landed answer counts: one without a title (an older
27
+ // dashboard, including one rolled back to mid-session, or a refusal) stops the title until an answer carries one again,
28
+ // since an older dashboard refuses the `title` key and a refused heartbeat is a dead listener.
29
+ dashboardTitle;
30
+ lastTitleError = null;
31
+ // DX-3099 - consecutive heartbeats that failed to LAND on one open stream (DX-5088: per stream, reset whenever the state leaves `streaming`).
32
+ failureStreak = 0;
33
+ // DX-5088 - when the heartbeat first failed to land in the current outage (across every reconnect); null while the last one landed.
34
+ outageSince = null;
35
+ outageAnnounced = false;
36
+ // DX-4418 - the stop notice for a revoked key goes out once; every later beat is refused the same way.
37
+ revocationAnnounced = false;
38
+ constructor(deps, transcriptPath) {
39
+ this.deps = deps;
40
+ this.transcriptPath = transcriptPath;
41
+ this.sessionStats = new OperatorSessionStats(transcriptPath, deps.makeStatsWatcher);
42
+ this.titleReader = transcriptPath === null ? null : new TranscriptTitleReader(transcriptPath);
43
+ }
44
+ /**
45
+ * The relay learns the session's transcript from each `plan_events_wait` call, after the reporter exists: a different path
46
+ * (a resumed session) starts a fresh collector and title reader; the same one, or none, changes nothing.
47
+ */
48
+ useTranscript(path) {
49
+ if (path === undefined || path === this.transcriptPath)
50
+ return null;
51
+ const previous = this.sessionStats;
52
+ this.transcriptPath = path;
53
+ this.sessionStats = new OperatorSessionStats(path, this.deps.makeStatsWatcher);
54
+ this.titleReader = new TranscriptTitleReader(path);
55
+ this.dashboardTitle = undefined;
56
+ this.lastTitleError = null;
57
+ // DX-5415: the old transcript's held finals are stored before this returns; the swap above is synchronous so a second call sees the new path.
58
+ return previous.stop(this.send(undefined), settleBound());
59
+ }
60
+ /** A fresh connect cycle earns a clean slate for the silent-failure count. */
61
+ resetFailureStreak() {
62
+ this.failureStreak = 0;
63
+ }
64
+ /** DX-5415: settle the collector's held sub-agent finals (bounded), then stop tailing the transcript. Idempotent. */
65
+ async stop() {
66
+ await this.sessionStats.stop(this.send(undefined), settleBound());
67
+ }
68
+ /** One heartbeat as the reporter sends it: the carried answer's title and revocation are applied here. */
69
+ send(title) {
70
+ return async (stats, statsHeld) => {
71
+ const answer = await sendHeartbeat(this.deps.auth(), this.deps.http, this.deps.connection(), stats, title, statsHeld);
72
+ if (answer.landed)
73
+ this.dashboardTitle = answer.title ?? undefined;
74
+ // DX-4418 - a person revoked this session's key: tell the session to stop, at once and only once. Its next
75
+ // dashboard call says the same.
76
+ if (answer.revocation !== null && !this.revocationAnnounced) {
77
+ this.revocationAnnounced = true;
78
+ this.deps.announce(keyRevokedHalt(answer.revocation, false), "key_revoked");
79
+ }
80
+ return answer;
81
+ };
82
+ }
83
+ titleToReport() {
84
+ if (this.titleReader === null || this.dashboardTitle === undefined)
85
+ return undefined;
86
+ // The title is cosmetic and the heartbeat is liveness: a transcript the title cannot be read from costs the title (said
87
+ // once per distinct failure), never the beat.
88
+ try {
89
+ const title = this.titleReader.read();
90
+ this.lastTitleError = null;
91
+ return title !== null && title !== this.dashboardTitle ? title : undefined;
92
+ }
93
+ catch (err) {
94
+ const message = err instanceof Error ? err.message : String(err);
95
+ if (message !== this.lastTitleError)
96
+ process.stderr.write(`session title: ${message}\n`);
97
+ this.lastTitleError = message;
98
+ return undefined;
99
+ }
100
+ }
101
+ /** One heartbeat. Fired without being awaited: its own failures are reported here, never thrown. */
102
+ tick() {
103
+ const title = this.titleToReport();
104
+ void this.sessionStats
105
+ .tick(this.send(title))
106
+ .then((landed) => {
107
+ const now = this.deps.now();
108
+ if (landed) {
109
+ this.failureStreak = 0;
110
+ this.outageSince = null;
111
+ this.outageAnnounced = false;
112
+ return;
113
+ }
114
+ this.outageSince ??= now;
115
+ // DX-5088 - the model hears of an outage only once it is sustained, in any connection state (a dashboard that stays down keeps
116
+ // the stream reconnecting, and that is exactly the outage worth saying); a blip such as a ~35 s deploy restart is stderr only.
117
+ const downMs = now - this.outageSince;
118
+ if (downMs >= HEARTBEAT_SUSTAINED_OUTAGE_MS && !this.outageAnnounced) {
119
+ this.outageAnnounced = true;
120
+ const minutes = Math.round(downMs / 60_000);
121
+ const connection = this.deps.connection();
122
+ // DX-5088: the wording follows the state. A stopped relay (a terminal outcome) does not reconnect by itself, so "keeps reconnecting" would be false.
123
+ const wording = connection.state === "stopped"
124
+ ? `this session's listener has stopped${connection.reason === undefined ? "" : ` (${connection.reason})`} and does not reconnect by itself, ` +
125
+ `so the dashboard has heard nothing from it for ${minutes} minutes. The next plan_events_wait call says why it stopped and what to do; ` +
126
+ `a person can also restart the session.`
127
+ : `this session's listener has not reached the dashboard for ${minutes} minutes (its heartbeats keep failing). ` +
128
+ `It keeps reconnecting on its own, and nothing is needed from you.`;
129
+ this.deps.announce(oneLine(`${DIGEST_PREFIX} ${wording}`), "relay_notice");
130
+ }
131
+ // DX-3099 - the self-heal counts only while a stream is open: that is the desync the card found (the client's stream reads
132
+ // open; the dashboard has nothing recent for it). DX-5088: while it is reconnecting the reconnect already owns recovery, and
133
+ // aborting it would restart the backoff, so a failure then neither counts nor forces anything.
134
+ if (!this.deps.streaming()) {
135
+ this.failureStreak = 0;
136
+ return;
137
+ }
138
+ this.failureStreak += 1;
139
+ if (this.failureStreak < HEARTBEAT_SILENT_FAILURE_THRESHOLD)
140
+ return;
141
+ this.failureStreak = 0;
142
+ process.stderr.write(`plan_events_wait: ${HEARTBEAT_SILENT_FAILURE_THRESHOLD} heartbeats in a row did not land on a stream that reads open; forcing a fresh connection\n`);
143
+ // End the CURRENT connection now, rather than wait on the stream reader's own read-idle timeout (which this exact
144
+ // failure mode can outlive indefinitely: the connection reads as established and keeps producing keep-alive bytes,
145
+ // only the outbound heartbeat silently never lands). The new attempt reconnects with the SAME ticket; the
146
+ // dashboard's one-open-stream-per-ticket rule then ends the old, superseded entry, releasing the exclusive claim
147
+ // as a side effect of THIS reconnect, never an external watchdog. The new stream's `streaming` sends a heartbeat at once.
148
+ this.deps.forceReconnect();
149
+ })
150
+ .catch((err) => {
151
+ // DX-3962: a stats failure must never take the stream down. `tick` is fired without being awaited, so a throw here
152
+ // would be an unhandled rejection that kills the process and with it the session's plan events.
153
+ process.stderr.write(`operator session stats: heartbeat tick failed: ${err instanceof Error ? err.message : String(err)}\n`);
154
+ });
155
+ }
156
+ }