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

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 +93 -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,345 @@
1
+ /**
2
+ * DX-4233 - the dashboard HTTP contract of a session's event stream, spoken by the in-process relay behind
3
+ * `plan_events_wait` (`plan-events-relay.ts`).
4
+ *
5
+ * One authenticated request with a timeout, the listener-ticket mint, the best-effort plan inventory read and the 15 s
6
+ * heartbeat. Everything here is about talking to the dashboard; WHEN to talk (the mint/stream loop, the heartbeat schedule,
7
+ * what a stop means for the session) belongs to the relay.
8
+ */
9
+ import { errorCodeOf } from "./json-body.js";
10
+ import { boundRequest } from "./request-bound.js";
11
+ import { keyRevocationOf, keyRevokedHalt } from "./key-revoked-halt.js";
12
+ import { oneLine } from "./one-line.js";
13
+ import { PLUGIN_BACKGROUND_HEADER, SESSION_ID_HEADER } from "./session-options.js";
14
+ import { isClaimState } from "@50arms/session-telemetry";
15
+ /**
16
+ * DX-3912 — the longest heartbeat `reason` the relay sends: one under the dashboard's own
17
+ * limit (`HEARTBEAT_REASON_MAX_CHARS` = 200, `src/issues/db/plan-session-heartbeat.ts`),
18
+ * because `oneLine` appends a "…" to a truncated string and a longer reason refuses the whole
19
+ * heartbeat. `plan-events-wait.test.ts` asserts the cap.
20
+ */
21
+ const HEARTBEAT_REASON_MAX_CODE_POINTS = 199;
22
+ export const STREAM_TICKET_PATH = "/api/plan-sessions/me/stream-ticket";
23
+ // DX-2970 — the bare (no `fields=`) cheap-scalars read, used ONLY to report the
24
+ // connected plan's real `boards`/`cardCount` on the `ready` record (see
25
+ // `fetchPlanInventory`). This is NOT a revival of DX-2920's deleted
26
+ // `verifyPlanBoardsReadable` gate — it never refuses a mint, never blocks
27
+ // streaming, and is best-effort (one attempt, no retry/backoff).
28
+ export const PLAN_MINE_PATH = "/api/plans/mine";
29
+ // DX-3028 (AC1) — `POST /api/plan-sessions/me/heartbeat` (DX-2952's server
30
+ // route, `plan-sessions-routes.ts#handleHeartbeat`). Every 15s this process
31
+ // reports its truthful streaming state; see `sendHeartbeat` below.
32
+ export const HEARTBEAT_PATH = "/api/plan-sessions/me/heartbeat";
33
+ /** How long one dashboard request may take before it counts as a transient failure. */
34
+ export const REQUEST_TIMEOUT_MS = 10_000;
35
+ /** A freshly minted ticket refused this many times in a row is a terminal outcome, not a loop. */
36
+ export const MAX_REFUSED_TICKETS_IN_A_ROW = 2;
37
+ /** DX-3028 (AC1) — the dashboard's own heartbeat cadence; see the card's AC1. */
38
+ export const HEARTBEAT_INTERVAL_MS = 15_000;
39
+ /**
40
+ * DX-3099 — how many consecutive 15s heartbeat POSTs may fail to LAND
41
+ * (network error, timeout, 5xx, 408/429 — never an ordinary 401/403 answer,
42
+ * which proves the pipe itself still works) while this process still
43
+ * believes it is `"streaming"`, before that counts as evidence the current
44
+ * stream connection is dead without having errored — the exact "alive but
45
+ * not reporting" signature the investigation found: the SSE connection can
46
+ * read as established at the OS level and never trip `listen.ts`'s own
47
+ * read-idle timeout, while every outbound heartbeat silently never lands
48
+ * (the send call's own result used to be discarded entirely — see the
49
+ * card). `3 * HEARTBEAT_INTERVAL_MS` = 45s, deliberately UNDER
50
+ * `SESSION_LIVENESS_WINDOW_MS` (60s, `plan-session-heartbeat.ts`) so this
51
+ * process self-heals before, or right around, the moment the dashboard
52
+ * would itself start reporting the session `unattached` — never a shorter
53
+ * freshness window (that constant is untouched) and never a blind
54
+ * "restart more eagerly" (no external watchdog, no timer-driven kill): this
55
+ * fires only on repeated, OBSERVED delivery failure, self-diagnosed by the
56
+ * SAME process that is already streaming. Card: DX-3099 (AC3 forbids both
57
+ * of the obvious wrong fixes this constant is not).
58
+ */
59
+ export const HEARTBEAT_SILENT_FAILURE_THRESHOLD = 3;
60
+ /**
61
+ * DX-5088 - how long the heartbeat must keep failing to land (counted from the first failure, across every reconnect, until one
62
+ * lands) before the MODEL is told. 5 minutes: a production deploy replaces the single dashboard container, an outage the
63
+ * investigation measured at about 35 s (DX-5085 comment 14821), and the lock-convoy stalls around it ran 8-27 s more; 5 minutes is
64
+ * roughly 8x the restart, so a deploy never wakes a session, while an outage that outlasts it is one the session's person should hear
65
+ * about (the dashboard has long read the session `unattached`). Below it the self-heal is silent and logged to stderr.
66
+ */
67
+ export const HEARTBEAT_SUSTAINED_OUTAGE_MS = 5 * 60_000;
68
+ const TRANSIENT_CLIENT_STATUSES = new Set([408, 429]);
69
+ /**
70
+ * One authenticated request, with a timeout, classifying the failures that are
71
+ * about THIS MOMENT (network, timeout, 408/429, 5xx) as transient. Shared by
72
+ * every request this module makes so they can never disagree about which
73
+ * failures are worth retrying.
74
+ */
75
+ async function request(options, deps, what, url, init = { method: "GET" }) {
76
+ const bound = boundRequest(deps.requestTimeoutMs, deps.signal);
77
+ try {
78
+ let status;
79
+ let text;
80
+ try {
81
+ const headers = {
82
+ Authorization: `Bearer ${options.token}`,
83
+ Accept: "application/json",
84
+ [SESSION_ID_HEADER]: options.sessionId,
85
+ // DX-5076: every request this module makes is the relay's own work (ticket mint, inventory read, heartbeat), never the agent acting, so it must not end an agent pause. One marking point, so a new request here cannot forget it.
86
+ [PLUGIN_BACKGROUND_HEADER]: "1",
87
+ };
88
+ if (init.body !== undefined)
89
+ headers["Content-Type"] = "application/json";
90
+ const response = await deps.fetch(url, {
91
+ method: init.method,
92
+ headers,
93
+ body: init.body,
94
+ signal: bound.signal,
95
+ });
96
+ status = response.status;
97
+ text = await response.text();
98
+ }
99
+ catch (err) {
100
+ return {
101
+ kind: "transient",
102
+ detail: bound.aborted()
103
+ ? `${what} timed out after ${deps.requestTimeoutMs} ms`
104
+ : `${what} failed: ${err.message}`,
105
+ };
106
+ }
107
+ if (status >= 500 || TRANSIENT_CLIENT_STATUSES.has(status)) {
108
+ return { kind: "transient", detail: `${what} HTTP ${status}` };
109
+ }
110
+ let body = null;
111
+ try {
112
+ body = text === "" ? null : JSON.parse(text);
113
+ }
114
+ catch {
115
+ body = null;
116
+ }
117
+ return { kind: "answer", status, text, body };
118
+ }
119
+ finally {
120
+ bound.release();
121
+ }
122
+ }
123
+ /** What is wrong with a 2xx mint body, or `null` when it is a ticket. Never echoes the body. */
124
+ function badTicketReason(body) {
125
+ if (typeof body !== "object" || body === null)
126
+ return "the body is not a JSON object";
127
+ const b = body;
128
+ if (typeof b.ticket !== "string" || b.ticket === "")
129
+ return "ticket is not a non-empty string";
130
+ if (typeof b.streamPath !== "string" || !b.streamPath.startsWith("/"))
131
+ return "streamPath is not a path";
132
+ if (typeof b.leaseMs !== "number" || !Number.isSafeInteger(b.leaseMs) || b.leaseMs <= 0) {
133
+ return "leaseMs is not a positive integer";
134
+ }
135
+ return null;
136
+ }
137
+ /** Mint this session's listener ticket, classifying every way that can end. */
138
+ export async function mintTicket(options, deps) {
139
+ const outcome = await request(options, deps, "ticket mint", `${options.dashboardUrl}${STREAM_TICKET_PATH}`, {
140
+ method: "POST",
141
+ body: "{}",
142
+ });
143
+ if (outcome.kind === "transient")
144
+ return outcome;
145
+ const { status, text, body } = outcome;
146
+ // DX-4882: a session on no plan is minted a ticket like any other (its stream carries its account's pacing and resume messages), so
147
+ // `409 session_not_connected` is no longer an answer the mint gives.
148
+ // DX-3028 (AC2) — matched BEFORE the generic mint_refused fallback below,
149
+ // which every other 409 falls into: this session id belongs to a worker
150
+ // dispatch (`SessionIsWorkerError`, the same shape the heartbeat route
151
+ // already gives it, DX-2952).
152
+ if (status === 409 && errorCodeOf(body) === "session_is_worker") {
153
+ return { kind: "terminal", reason: "session_is_worker", detail: "this session id belongs to a worker dispatch, not an operator plan session" };
154
+ }
155
+ // DX-4418 - a key a person revoked is not a lapsed one: the detail IS the stop halt (who revoked it, what to do), so the record
156
+ // the plugin relays to the session says it in full.
157
+ const revocation = keyRevocationOf({ ok: false, status, body });
158
+ if (revocation !== null) {
159
+ return { kind: "terminal", reason: "key_revoked", detail: keyRevokedHalt(revocation, false) };
160
+ }
161
+ if (status === 401 || status === 403) {
162
+ return { kind: "terminal", reason: "unauthorized", detail: `ticket mint HTTP ${status} ${oneLine(text, 300)}` };
163
+ }
164
+ if (status < 200 || status >= 300) {
165
+ return { kind: "terminal", reason: "mint_refused", detail: `ticket mint HTTP ${status} ${oneLine(text, 300)}` };
166
+ }
167
+ const bad = badTicketReason(body);
168
+ if (bad !== null) {
169
+ return { kind: "terminal", reason: "mint_bad_response", detail: `ticket mint HTTP ${status}: ${bad}` };
170
+ }
171
+ const ticket = body;
172
+ return {
173
+ kind: "ticket",
174
+ ticket: ticket.ticket,
175
+ streamUrl: `${options.dashboardUrl}${ticket.streamPath}`,
176
+ leaseMs: ticket.leaseMs,
177
+ };
178
+ }
179
+ /**
180
+ * What is wrong with a 2xx bare `/api/plans/mine` body, or `null` when it carries a usable inventory.
181
+ *
182
+ * DX-3912 — the field names here are the plan resource's WIRE names (`boards`, `card_count`,
183
+ * snake_case since DX-3428), the same ones `src/resources/plan/plan-resource.ts` emits; the
184
+ * contract test `src/issues/__tests__/plan-inventory-relay-contract.test.ts` runs this parser
185
+ * on the server's own response, so a rename there fails that test instead of silently
186
+ * degrading every listener. A missing field lists the keys the body DID carry, so a rename is
187
+ * named in the first message anyone reads.
188
+ */
189
+ function badInventoryReason(body) {
190
+ if (!isRecord(body))
191
+ return "the body is not a JSON object";
192
+ if (!("boards" in body))
193
+ return `boards is missing (response keys: ${Object.keys(body).join(", ")})`;
194
+ if (!Array.isArray(body.boards) || body.boards.some((x) => typeof x !== "number" || !Number.isSafeInteger(x) || x <= 0)) {
195
+ return "boards is not an array of board ids (positive integers)";
196
+ }
197
+ if (!("card_count" in body))
198
+ return `card_count is missing (response keys: ${Object.keys(body).join(", ")})`;
199
+ const count = body.card_count;
200
+ if (typeof count !== "number" || !Number.isSafeInteger(count) || count < 0) {
201
+ return `card_count is not a non-negative integer (got ${typeof count === "number" ? count : typeof count})`;
202
+ }
203
+ return null;
204
+ }
205
+ /**
206
+ * DX-2970 — a single BEST-EFFORT read of the connected plan's real
207
+ * `boards`/`cardCount`, purely for accurate `ready`-record reporting. NEVER a
208
+ * gate: unlike DX-2920's deleted `verifyPlanBoardsReadable`, a failure here
209
+ * (network, timeout, non-2xx, unusable body) is NOT retried and NEVER blocks
210
+ * or delays streaming — the relay reports `degraded: true` with `boards`/
211
+ * `cardCount` both `null` and proceeds exactly as it would have before this
212
+ * read existed. The bare (no `fields=`) call is deliberately the cheapest
213
+ * read the route offers — the plan's own "cheap scalars," always present on
214
+ * every `GET /api/plans/mine` response regardless of `fields`.
215
+ */
216
+ export async function fetchPlanInventory(options, deps) {
217
+ const outcome = await request(options, deps, "plan inventory read", `${options.dashboardUrl}${PLAN_MINE_PATH}`);
218
+ if (outcome.kind === "transient")
219
+ return { kind: "unavailable", detail: outcome.detail };
220
+ const { status, text, body } = outcome;
221
+ if (status < 200 || status >= 300) {
222
+ return { kind: "unavailable", detail: `plan inventory read HTTP ${status} ${oneLine(text, 300)}` };
223
+ }
224
+ const bad = badInventoryReason(body);
225
+ if (bad !== null) {
226
+ return { kind: "unavailable", detail: `plan inventory read HTTP ${status}: ${bad}` };
227
+ }
228
+ const b = body;
229
+ return { kind: "ok", boards: b.boards, cardCount: b.card_count };
230
+ }
231
+ /**
232
+ * The one real `scheduleInterval`: a `setInterval` and its canceler, for the relay's heartbeat. Unref'd: a heartbeat never keeps
233
+ * the MCP server alive after its session ended.
234
+ */
235
+ export function realScheduleInterval(tick, ms) {
236
+ const timer = setInterval(tick, ms);
237
+ timer.unref();
238
+ return { stop: () => clearInterval(timer) };
239
+ }
240
+ /**
241
+ * The one real sleep of the relay's mint / stream loop: unref'd, so a backoff never keeps the MCP server alive after its session
242
+ * ended, and cut short by `signal` (the run was ended), so a suspended relay does not leave a timer behind.
243
+ */
244
+ export function realSleep(ms, signal) {
245
+ return new Promise((resolve) => {
246
+ if (signal?.aborted)
247
+ return resolve();
248
+ const done = () => {
249
+ clearTimeout(timer);
250
+ signal?.removeEventListener("abort", done);
251
+ resolve();
252
+ };
253
+ const timer = setTimeout(done, ms);
254
+ timer.unref();
255
+ signal?.addEventListener("abort", done, { once: true });
256
+ });
257
+ }
258
+ /**
259
+ * The truthful `connection` for a heartbeat: `streaming` only while the SSE connection is open (with whether the plan
260
+ * inventory read worked), `stopped` with the reason the stream ended, otherwise `reconnecting`.
261
+ */
262
+ export function heartbeatConnection(state, stoppedReason, inventoryError) {
263
+ if (state === "streaming") {
264
+ return inventoryError === null
265
+ ? { state, inventory: "ok" }
266
+ : { state, inventory: "unavailable", reason: oneLine(inventoryError, HEARTBEAT_REASON_MAX_CODE_POINTS) };
267
+ }
268
+ return state === "stopped" ? { state, reason: stoppedReason ?? undefined } : { state };
269
+ }
270
+ /**
271
+ * One heartbeat POST. Never throws on a failure of the request itself and NEVER stops the relay on one
272
+ * (the one throw is a contract breach: a `key_revoked` answer that does not say who revoked the key, DX-4418; `heartbeatTick`'s
273
+ * `.catch` keeps it from taking the relay down) (card AC1 — "a failed heartbeat never stops the relay"): `request()`
274
+ * itself already classifies every network/timeout/5xx failure as
275
+ * `{kind:"transient"}` rather than throwing, and a 401/403 (a revoked
276
+ * credential) simply comes back as an ordinary non-2xx answer this function
277
+ * discards — there is nothing meaningful to retry a heartbeat POST for, and
278
+ * the NEXT tick will just try again. A heartbeat refused with 401/403 cannot
279
+ * report itself, so `computeListenerHealth`'s staleness fallback
280
+ * (`heartbeatIsFresh`) is what eventually reflects it: the row's
281
+ * `last_heartbeat_at` stops advancing, and once it falls outside
282
+ * `SESSION_LIVENESS_WINDOW_MS` the badge reads `unattached`, NOT
283
+ * `credential_mismatch` — `credential_mismatch` is computed only from a
284
+ * heartbeat that DID land with `state:"streaming"` and a live ticket whose
285
+ * issuer hash differs from the caller's; it has no way to represent "the
286
+ * heartbeat call itself was rejected". This gap is real and is recorded on
287
+ * the card rather than silently papered over.
288
+ */
289
+ export async function sendHeartbeat(options, deps, connection, stats,
290
+ // DX-4461 - the session's current Claude title, read from its transcript (`session-title.ts`).
291
+ title,
292
+ // DX-6367 - this relay already holds the stored stats; the dashboard then answers a stats-free heartbeat without them. Sent only to a dashboard that said it accepts it.
293
+ statsHeld) {
294
+ const body = { state: connection.state };
295
+ if (connection.reason !== undefined)
296
+ body.reason = connection.reason;
297
+ if (connection.inventory !== undefined)
298
+ body.inventory = connection.inventory;
299
+ const outcome = await request(options, deps, "heartbeat", `${options.dashboardUrl}${HEARTBEAT_PATH}`, {
300
+ method: "POST",
301
+ body: JSON.stringify({ connection: body, ...(stats !== undefined ? { stats } : {}), ...(title !== undefined ? { title } : {}), ...(statsHeld === true ? { statsHeld } : {}) }),
302
+ });
303
+ // DX-3099 — this is now the ONE place a heartbeat's own outcome is
304
+ // inspected. `request()` still never throws, and a 401/403 is still an
305
+ // ordinary answer this function does not retry for (the credential is
306
+ // whatever it is; there is nothing to redo about THIS request) — only a
307
+ // "transient" outcome (network error, timeout, 5xx, 408/429) means the
308
+ // request never landed at all, which is exactly the signal the silent-
309
+ // failure counter in the relay below needs. Still never throws.
310
+ if (outcome.kind !== "answer")
311
+ return { landed: false, status: null, stats: null, statsNotStored: null, acceptsStatsHeld: false, revocation: null, title: null };
312
+ // DX-2954 — the answer's `stats` (the operator dispatch's stored or
313
+ // just-written totals and cursors) feed the session's stats collector.
314
+ const ok = outcome.status >= 200 && outcome.status < 300;
315
+ // A heartbeat whose carried stats were NOT applied (`statsError`) still
316
+ // landed; its `stats` are the stored values, so the collector must re-queue
317
+ // rather than treat them as the answer to its write.
318
+ const applied = ok && isRecord(outcome.body) && outcome.body.statsError === undefined;
319
+ const statsBody = applied && isRecord(outcome.body) && isRecord(outcome.body.stats) ? outcome.body.stats : null;
320
+ // DX-3962: an answer from a server that predates attribution stores cursors with no `claims`. Treated exactly like
321
+ // an answer with no stats (the older-server path): the collector never starts, the heartbeat still lands, and
322
+ // nothing is recorded until the server is deployed. Handing it on would crash the collector at attach.
323
+ const answered = statsBody !== null &&
324
+ isRecord(statsBody.sourceCursors) &&
325
+ isRecord(statsBody.totals) &&
326
+ Object.values(statsBody.sourceCursors).every((cursor) => isRecord(cursor) && isClaimState(cursor.claims))
327
+ ? statsBody
328
+ : null;
329
+ // DX-4418 - the dashboard refuses the beat of a session whose key a person revoked, and says so.
330
+ // DX-4461 - the dashboard's stored title for this session; an answer without one is an older dashboard's.
331
+ const storedTitle = ok && isRecord(outcome.body) && typeof outcome.body.title === "string" ? outcome.body.title : null;
332
+ return {
333
+ landed: true,
334
+ status: outcome.status,
335
+ stats: answered,
336
+ acceptsStatsHeld: ok && isRecord(outcome.body) && outcome.body.acceptsStatsHeld === true,
337
+ // DX-4892: the dashboard says why carried stats were not stored (a session on no plan), instead of the relay inferring it from a missing `stats`.
338
+ statsNotStored: ok && isRecord(outcome.body) && isRecord(outcome.body.statsError) && outcome.body.statsError.kind === "no_plan" ? "no_plan" : null,
339
+ revocation: keyRevocationOf({ ok: false, status: outcome.status, body: outcome.body }),
340
+ title: storedTitle,
341
+ };
342
+ }
343
+ function isRecord(value) {
344
+ return typeof value === "object" && value !== null && !Array.isArray(value);
345
+ }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * DX-4461 - a session's live Claude title, read from its own transcript with no help from the agent.
3
+ *
4
+ * WHY THE TRANSCRIPT. The dashboard stores a plan session's title, and until now it arrived only as the `title`
5
+ * argument of `plan_connect`, so a session renamed afterwards showed a stale name until the agent happened to connect
6
+ * again. DX-2816 found no env var, hook input or MCP-reachable signal carrying the title, but Claude Code writes every
7
+ * title change into the session's transcript as a `{"type":"custom-title","customTitle":"..."}` line the moment the
8
+ * rename happens, even while the session is idle (observed on session 952f0739: 76 records of the old title, then the
9
+ * renamed one, no turn in between). the relay already holds the transcript path (the plugin passes the hook input's
10
+ * `transcript_path` as the `transcript_path` argument, `session-stats.ts`), so it reads the last such record on each
11
+ * heartbeat and reports it when it differs from the title the dashboard answered with (the relay); the dashboard
12
+ * stores it (`plan-sessions-routes.ts`).
13
+ *
14
+ * INCREMENTAL, NEVER A RE-READ. The reader remembers how far it has scanned (always a line boundary) and the title it
15
+ * found, so a heartbeat reads only the bytes appended since the last one. The first read of a long transcript is
16
+ * bounded to its last {@link TRANSCRIPT_TITLE_MAX_SCAN_BYTES}: Claude Code repeats the record on every turn, so the
17
+ * latest one is always near the end, and a transcript with none in that window has nothing to report.
18
+ *
19
+ * It reads synchronously on purpose: the relay's heartbeat tick is synchronous up to its first request, and a read
20
+ * of a few appended kilobytes every 15 seconds costs less than the plumbing an async read would need.
21
+ */
22
+ import { closeSync, fstatSync, openSync, readSync } from "node:fs";
23
+ import { stripSessionTitleMarks } from "@50arms/session-telemetry";
24
+ /**
25
+ * The longest title the dashboard accepts (`SESSION_TITLE_MAX_CHARS`, `src/issues/db/plan-sessions.ts`, counted in
26
+ * UTF-16 units). A longer one would 400 the whole heartbeat, which is liveness, so the reader cuts it first.
27
+ */
28
+ export const SESSION_TITLE_MAX_CHARS = 200;
29
+ /** How much of a transcript's end the first read looks at. */
30
+ export const TRANSCRIPT_TITLE_MAX_SCAN_BYTES = 2 * 1024 * 1024;
31
+ /** The marker of a title record. A message quoting it carries escaped quotes, so it never matches. */
32
+ const TITLE_RECORD_MARKER = '"type":"custom-title"';
33
+ export class TranscriptTitleReader {
34
+ path;
35
+ /** Offset just past the last complete line already scanned; always 0 or a line boundary. */
36
+ scannedTo = 0;
37
+ title = null;
38
+ constructor(path) {
39
+ this.path = path;
40
+ }
41
+ /**
42
+ * The session's current title, or `null` when its transcript has none (yet). Throws on a transcript this cannot
43
+ * read, or a complete title record that is not JSON: the caller decides what a failed read costs.
44
+ */
45
+ read() {
46
+ const fd = this.open();
47
+ if (fd === null)
48
+ return this.title;
49
+ try {
50
+ const size = fstatSync(fd).size;
51
+ if (size < this.scannedTo) {
52
+ // The file was rewritten shorter than what was already scanned; nothing remembered about it holds.
53
+ this.scannedTo = 0;
54
+ this.title = null;
55
+ }
56
+ if (size === this.scannedTo)
57
+ return this.title;
58
+ const start = Math.max(this.scannedTo, size - TRANSCRIPT_TITLE_MAX_SCAN_BYTES);
59
+ const buf = Buffer.alloc(size - start);
60
+ let filled = 0;
61
+ while (filled < buf.length) {
62
+ const n = readSync(fd, buf, filled, buf.length - filled, start + filled);
63
+ if (n === 0)
64
+ break;
65
+ filled += n;
66
+ }
67
+ const lastNewline = buf.subarray(0, filled).lastIndexOf(0x0a);
68
+ if (lastNewline === -1)
69
+ return this.title;
70
+ const lines = buf.subarray(0, lastNewline).toString("utf8").split("\n");
71
+ // A region that starts mid-file (the first read of a long transcript) begins inside a line.
72
+ if (start !== this.scannedTo)
73
+ lines.shift();
74
+ this.scannedTo = start + lastNewline + 1;
75
+ for (let i = lines.length - 1; i >= 0; i--) {
76
+ if (!lines[i].includes(TITLE_RECORD_MARKER))
77
+ continue;
78
+ this.title = parseTitleRecord(lines[i], this.path);
79
+ break;
80
+ }
81
+ return this.title;
82
+ }
83
+ finally {
84
+ closeSync(fd);
85
+ }
86
+ }
87
+ /** The transcript's descriptor, or `null` when it does not exist yet (Claude Code creates it on the first turn). */
88
+ open() {
89
+ try {
90
+ return openSync(this.path, "r");
91
+ }
92
+ catch (err) {
93
+ if (err.code === "ENOENT")
94
+ return null;
95
+ throw err;
96
+ }
97
+ }
98
+ }
99
+ function parseTitleRecord(line, path) {
100
+ let record;
101
+ try {
102
+ record = JSON.parse(line);
103
+ }
104
+ catch (err) {
105
+ throw new Error(`a custom-title line in ${path} is not JSON: ${err.message}`);
106
+ }
107
+ const customTitle = record?.customTitle;
108
+ if (typeof customTitle !== "string")
109
+ return null;
110
+ // DX-6078: the plugin's display marks are dropped by the one list the dashboard also strips on every title write.
111
+ const trimmed = stripSessionTitleMarks(customTitle.trim());
112
+ return trimmed === "" ? null : boundTitle(trimmed);
113
+ }
114
+ /**
115
+ * At most {@link SESSION_TITLE_MAX_CHARS} UTF-16 units, cut on a code-point boundary. A cut that lands after a space is
116
+ * trimmed again: the dashboard stores the trimmed title, so an untrimmed one would never equal it and the relay would
117
+ * resend it on every heartbeat.
118
+ */
119
+ function boundTitle(title) {
120
+ if (title.length <= SESSION_TITLE_MAX_CHARS)
121
+ return title;
122
+ let out = "";
123
+ for (const point of title) {
124
+ if (out.length + point.length > SESSION_TITLE_MAX_CHARS)
125
+ break;
126
+ out += point;
127
+ }
128
+ return out.trimEnd();
129
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * DX-4523 / DX-4233 - what the server lets go of when its session ends: the permission watch's open stream and the plan event
3
+ * relay (its pending call, stream, heartbeat and tailer), so nothing keeps the process alive after the client is gone.
4
+ *
5
+ * Three triggers, all needed: the transport's `onclose` (an orderly close of the connection), the end of stdin (the SDK's stdio
6
+ * transport does not watch stdin for its end, so a client that simply exits would leave the process running without it), and a
7
+ * SIGTERM / SIGINT.
8
+ *
9
+ * DX-5415: `stop` returns the relay's bounded final settle (a sub-agent's held final report is stored before the collector stops).
10
+ * The first two triggers leave the process to end on its own once nothing keeps it alive, so the settle completes. A signal would
11
+ * kill it mid-settle, so its handler awaits `stop` and only then exits. The handler is `once` per signal: a second signal of the
12
+ * SAME kind takes the default action, but a DIFFERENT one (SIGTERM then SIGINT) runs the handler again, which is safe because
13
+ * `stop` is idempotent. The settle is bounded (10 s), yet a parent that signals with an SDK client allows only about 4 s before
14
+ * it sends SIGKILL, so the held finals are stored best-effort within that window, not guaranteed.
15
+ */
16
+ export function stopWhenSessionEnds(wiring) {
17
+ wiring.server.onclose = wiring.stop;
18
+ wiring.stdin.once("end", wiring.stop);
19
+ const exitAfterStop = () => {
20
+ // A synchronous throw from `stop` takes the same "shutdown failed" path as a rejection.
21
+ void Promise.resolve().then(wiring.stop).then(() => wiring.exit(0), (err) => {
22
+ process.stderr.write(`platform-mcp: shutdown failed: ${err instanceof Error ? err.message : String(err)}\n`);
23
+ wiring.exit(1);
24
+ });
25
+ };
26
+ wiring.signals.once("SIGTERM", exitAfterStop);
27
+ wiring.signals.once("SIGINT", exitAfterStop);
28
+ }
29
+ /**
30
+ * DX-5415: the server's stop callback, the one link from every shutdown trigger to the relay's settle: it returns the relay's
31
+ * pending `close()` so a signal handler can wait for it before exiting.
32
+ */
33
+ export function makeSessionStop(permissionWatch, planEventRelay) {
34
+ return () => {
35
+ permissionWatch.stop();
36
+ return planEventRelay.close();
37
+ };
38
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * DX-4233 - the remedy a session is shown for each way its dashboard event stream can stop, in ONE place.
3
+ *
4
+ * The client that ends in these texts is the in-process relay behind `plan_events_wait` (`plan-events-relay.ts`); `session-options.ts` uses
5
+ * `fixForStop` for a start it cannot make. Pure data and one function with no imports.
6
+ */
7
+ /**
8
+ * The remedy per terminal reason. ONE map, so the message a session is shown and the message a log carries can never drift.
9
+ * `KnownStopReason` is only a SUBSET of `ListenStopReason` (which also has `unknown`, for a newer dashboard's wire reason this
10
+ * build predates), so `fixForStop` accepts a plain string and falls back to `DEFAULT_STOP_FIX`.
11
+ *
12
+ * DX-4233 - the texts name the session's listener, never a process: with the relay inside the session's own MCP server there
13
+ * is no separate process to restart.
14
+ */
15
+ const RESTART_NOTICE_STOP_LEAD = "continue without it";
16
+ const RESTART_NOTICE_STOP_TAIL = "call plan_connect to connect this session to a plan";
17
+ export const STOP_FIXES = {
18
+ no_connection_record: "call plan_connect again in this session — its platform MCP server records the connection that restart_notice and the listener reminder read " +
19
+ "(a server older than the one that introduced the record writes none)",
20
+ credential_unavailable: "call plan_connect again to request access: this session's key file is missing or unreadable",
21
+ credential_mismatch: "restart this Claude Code session so its MCP server resolves its credential afresh, then call plan_connect again",
22
+ not_connected: "call plan_connect to connect this session to a plan",
23
+ plan_archived: "the plan was archived or deleted, so this session was disconnected; ask the operator to restore it, then call plan_connect again",
24
+ unauthorized: "the dashboard no longer accepts this session's key (it lapsed after a day unused): call plan_connect again to request access",
25
+ key_revoked: "stop all work now, commit your work in progress and end this session: the person who owns this session's access revoked it; " +
26
+ "do not call plan_connect or request access again",
27
+ mint_refused: "check that this session is signed in to the dashboard its MCP server points at, then call plan_connect again",
28
+ mint_bad_response: "check that DANXBOT_DASHBOARD_URL for this session's MCP server points at a 50 Arms dashboard",
29
+ // A newer ticket exists (superseded) or another connection is already using this one (replaced): THIS listener is ending
30
+ // because the session is already served elsewhere, not because anything is broken.
31
+ superseded: "no action needed — a newer ticket was minted for this session, and a fresh listener is already using it",
32
+ replaced: "no action needed — another connection is already using this session's ticket; call plan_connect again only if that was unexpected",
33
+ revoked: "call plan_connect again to mint a new listener ticket for this session",
34
+ refused: "call plan_connect again to mint a new listener ticket for this session",
35
+ // DX-6272: calling plan_connect again would reconnect with the same old relay and be refused again.
36
+ mcp_outdated: "restart this Claude Code session, or reconnect its platform server in /mcp, to install the current version of the MCP server; the dashboard's event stream needs a newer one than this session runs",
37
+ // DX-3028 - this session id belongs to a worker dispatch; there is no listener ticket a worker session can ever use.
38
+ session_is_worker: "this session id belongs to a worker dispatch; start a new session",
39
+ // DX-4632: `restart_notice` only tells a restarted session which plan its predecessor was on; none of its stops blocks anything.
40
+ no_session_id: `${RESTART_NOTICE_STOP_LEAD}: this server was started without a session id, so it cannot tell which session it replaced; ${RESTART_NOTICE_STOP_TAIL}`,
41
+ lookup_timeout: `${RESTART_NOTICE_STOP_LEAD}: the dashboard did not answer in time; ${RESTART_NOTICE_STOP_TAIL}`,
42
+ no_predecessor_record: `${RESTART_NOTICE_STOP_LEAD}: this machine holds no usable record of the named earlier session, so there is nothing to carry over; ${RESTART_NOTICE_STOP_TAIL}`,
43
+ transcript_unreadable: `${RESTART_NOTICE_STOP_LEAD}: this session's transcript could not be read to find the session it replaced (the detail says why); ${RESTART_NOTICE_STOP_TAIL}`,
44
+ lookup_failed: `${RESTART_NOTICE_STOP_LEAD}: no earlier session's record could be checked (the detail says why); ${RESTART_NOTICE_STOP_TAIL}`,
45
+ relay_failed: "the event relay inside this session's platform MCP server failed (the detail says why); the next call tries again, and if it keeps failing restart this Claude Code session",
46
+ };
47
+ export const DEFAULT_STOP_FIX = "call plan_connect again in this session to restart the listener";
48
+ export function fixForStop(reason) {
49
+ return STOP_FIXES[reason] ?? DEFAULT_STOP_FIX;
50
+ }