@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.
- package/README.md +114 -2
- package/dist/access-request.js +101 -0
- package/dist/agent-gate.js +69 -0
- package/dist/board-id.js +27 -0
- package/dist/bounded-retry.js +25 -0
- package/dist/checkout-docs-root.js +54 -0
- package/dist/clear-control.js +77 -0
- package/dist/compaction-control.js +142 -0
- package/dist/credential.js +104 -0
- package/dist/danxbot-api-examples.js +97 -0
- package/dist/dashboard-identity.js +14 -0
- package/dist/design-screenshot.js +138 -0
- package/dist/digest-hold.js +67 -0
- package/dist/docs-tools.js +140 -0
- package/dist/durable-write.js +56 -0
- package/dist/entrypoint.js +35 -0
- package/dist/forbidden-hint.js +23 -0
- package/dist/guarded-calls.js +21 -0
- package/dist/handlers.js +385 -0
- package/dist/heartbeat-reporter.js +156 -0
- package/dist/http-client.js +190 -0
- package/dist/index.js +1128 -0
- package/dist/json-body.js +12 -0
- package/dist/key-revoked-halt.js +30 -0
- package/dist/lifecycle.js +113 -0
- package/dist/listen.js +999 -0
- package/dist/listener-reminder.js +119 -0
- package/dist/machine-daemon/autostart.js +160 -0
- package/dist/machine-daemon/beat.js +77 -0
- package/dist/machine-daemon/cli.js +42 -0
- package/dist/machine-daemon/install.js +168 -0
- package/dist/machine-daemon/state.js +64 -0
- package/dist/machine-daemon/version.js +13 -0
- package/dist/machine-daemon.js +553 -0
- package/dist/machine-toolkit.js +460 -0
- package/dist/mini-app-build.js +159 -0
- package/dist/mini-app-checkout.js +215 -0
- package/dist/mini-app-output.js +111 -0
- package/dist/mini-app-publish.js +154 -0
- package/dist/mini-app-restore.js +175 -0
- package/dist/one-line.js +25 -0
- package/dist/pacing-relay.js +16 -0
- package/dist/permission-request.js +134 -0
- package/dist/permission-watch.js +145 -0
- package/dist/plan-events-relay.js +572 -0
- package/dist/plan-events-tool.js +9 -0
- package/dist/plugin-publish.js +38 -0
- package/dist/plugin-screenshot.js +81 -0
- package/dist/request-bound.js +21 -0
- package/dist/restart-notice.js +271 -0
- package/dist/result-limit.js +31 -0
- package/dist/role-instruction-file.js +36 -0
- package/dist/route-board-scope.generated.js +95 -0
- package/dist/route-board-scope.js +71 -0
- package/dist/session-access.js +322 -0
- package/dist/session-connection.js +264 -0
- package/dist/session-control-file.js +58 -0
- package/dist/session-key.js +125 -0
- package/dist/session-options.js +60 -0
- package/dist/session-stats.js +235 -0
- package/dist/session-stream.js +345 -0
- package/dist/session-title.js +129 -0
- package/dist/shutdown.js +38 -0
- package/dist/stop-fixes.js +50 -0
- package/dist/subagents-live.js +162 -0
- package/dist/terms-halt.js +39 -0
- package/dist/text-limit.js +24 -0
- package/dist/tool-access.js +114 -0
- package/dist/tool-defs.js +58 -0
- package/dist/tool-surface.js +59 -0
- package/dist/transcript-lineage.js +105 -0
- package/dist/types.js +45 -0
- package/dist/urgent-records.js +47 -0
- package/dist/version-floor.js +146 -0
- 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
|
+
}
|
package/dist/shutdown.js
ADDED
|
@@ -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
|
+
}
|