@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,142 @@
1
+ /**
2
+ * DX-4993 (PLAN-42 rule R-4) - the compaction control of an OPERATOR session, as this package relays it.
3
+ *
4
+ * 50 Arms decides when a session is asked to prepare for a compaction and when its agent has said ready (the dashboard's `compaction-handshake/`). The
5
+ * session's stream carries the decision as a `compaction` frame (`event: compaction`, data `{"control": <control> | null}`, level-triggered: the current
6
+ * control on connect and whenever it changes). The relay does two things with each frame:
7
+ *
8
+ * - the REQUEST (`control.ask`) reaches the agent as an urgent record, once per request (`ask.at` names it), so it arrives inside a running turn;
9
+ * - the rest is written to ONE local file the 50arms plugin's `PreCompact` gate reads: `~/.config/danxbot/compaction/<sessionId>.json`. The gate never
10
+ * asks the network: Claude Code proposes a compaction before every model request.
11
+ *
12
+ * The file is `{v: 1, sessionId, control: {floorTokens, passAtTokens, windowTokens, releaseUntilMs, releaseId, agents, valves, mode} | null, askedAt: string | null}`.
13
+ * DX-5218: `agents` holds each SUB-AGENT's own control by agent id, `{passAtTokens}`: the sub-agent's own safety-valve size. DX-6184: it carries no
14
+ * request and no release (a handover is proposed to the orchestrator through the main `ask`); a key beyond `passAtTokens` is ignored, as the dashboard's
15
+ * `isCompactionControl` and the plugin's gate ignore it. `sessionId` is the
16
+ * session the file was written for (the plugin refuses a file that names another); `windowTokens` is the context window the dashboard assumed for the session's
17
+ * model line (the plugin does not arm a session whose real window is smaller). `control: null` says the session is not under the handshake (the plugin disarms). `askedAt` is the relay's own memory of the last request it showed the agent, kept in the file so a restart
18
+ * of this server does not show it again; the plugin ignores it.
19
+ *
20
+ * The shapes below mirror the dashboard's `OperatorCompactionControl` (`src/dashboard/compaction-handshake/operator-session.ts`) and the plugin's gate
21
+ * (`danxbot/hooks/compaction/control.ts` in the plugin repo): this published package cannot import either. Each side tolerates the other's previous
22
+ * version by ignoring a frame or a file key it does not know and refusing nothing else (see the release order on DX-4993).
23
+ */
24
+ import { homedir } from "node:os";
25
+ import { prefixedLine } from "./one-line.js";
26
+ import { sessionControlDir, sessionControlFilePath, sessionControlFilesIn } from "./session-control-file.js";
27
+ /** The longest request line relayed whole (the pacing line's bound): the dashboard's request is a few hundred characters. */
28
+ export const COMPACTION_ASK_MAX_CODE_POINTS = 1000;
29
+ const isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
30
+ const isCount = (v) => typeof v === "number" && Number.isInteger(v) && v >= 0;
31
+ const isTime = (v) => typeof v === "number" && Number.isFinite(v);
32
+ const isAsk = (a) => isRecord(a) && isCount(a.seq) && typeof a.at === "string" && a.at !== "" && typeof a.text === "string" && a.text.trim() !== "";
33
+ /** Why an ask / release pair (the main agent's, or a sub-agent's) cannot be read, or `null` when it can. */
34
+ function invalidAskAndRelease(c) {
35
+ if (c.ask !== null && !isAsk(c.ask))
36
+ return "ask must be null or {seq, at, text}";
37
+ if ((c.releaseUntilMs === null) !== (c.releaseId === null))
38
+ return "releaseUntilMs and releaseId are both set or both null";
39
+ if (c.releaseUntilMs !== null && !isTime(c.releaseUntilMs))
40
+ return "releaseUntilMs must be a number or null";
41
+ if (c.releaseId !== null && (typeof c.releaseId !== "string" || c.releaseId === ""))
42
+ return "releaseId must be a string or null";
43
+ return null;
44
+ }
45
+ /** Why a `compaction` frame's data cannot be read, or `null` when it can. */
46
+ export function invalidCompactionReason(value) {
47
+ if (!isRecord(value) || !("control" in value))
48
+ return "the frame has no control";
49
+ const c = value.control;
50
+ if (c === null)
51
+ return null;
52
+ if (!isRecord(c))
53
+ return "control is not an object or null";
54
+ if (c.v !== 1)
55
+ return `control version ${JSON.stringify(c.v)} is not 1`;
56
+ if (!isCount(c.floorTokens) || !isCount(c.passAtTokens) || !isCount(c.windowTokens))
57
+ return "floorTokens, passAtTokens and windowTokens must be whole numbers";
58
+ // DX-4993: the plugin refuses a control whose valve is not above its floor, and the dashboard never sends one; a frame that would become such a file is a fault line
59
+ if (c.passAtTokens <= c.floorTokens)
60
+ return "passAtTokens must be above floorTokens";
61
+ if (c.windowTokens <= c.passAtTokens)
62
+ return "windowTokens must be above passAtTokens";
63
+ const own = invalidAskAndRelease(c);
64
+ if (own !== null)
65
+ return own;
66
+ if (!isRecord(c.agents))
67
+ return "agents must be an object keyed by agent id";
68
+ // DX-5222: no default for a missing table - a frame from a dashboard that predates it is refused, never read as "no valves"
69
+ if (!isRecord(c.valves))
70
+ return "valves must be an object keyed by model id";
71
+ // DX-6732: no default for a missing mode - a frame from a dashboard that predates it is refused, as with `valves`
72
+ if (c.mode !== "compact" && c.mode !== "clear")
73
+ return `mode must be "compact" or "clear"`;
74
+ for (const [modelId, size] of Object.entries(c.valves)) {
75
+ if (modelId === "")
76
+ return "valves has an empty model id";
77
+ if (!isCount(size) || size === 0)
78
+ return `valves.${modelId} must be a positive whole number`;
79
+ }
80
+ for (const [id, a] of Object.entries(c.agents)) {
81
+ if (id === "")
82
+ return "agents has an empty agent id";
83
+ if (!isRecord(a))
84
+ return `agents.${id} is not an object`;
85
+ if (!isCount(a.passAtTokens))
86
+ return `agents.${id}.passAtTokens must be a whole number`;
87
+ }
88
+ return null;
89
+ }
90
+ /** The control of a valid frame's data (`invalidCompactionReason` is `null`). */
91
+ export function controlOfFrame(value) {
92
+ const reason = invalidCompactionReason(value);
93
+ if (reason !== null)
94
+ throw new Error(`malformed compaction frame: ${reason}`);
95
+ return value.control;
96
+ }
97
+ /** The line the agent reads for a request: the dashboard's text on one line, prefixed like every stream line. */
98
+ export function formatCompactionAskLine(ask) {
99
+ return prefixedLine(ask.text, COMPACTION_ASK_MAX_CODE_POINTS);
100
+ }
101
+ /** The file of session `sessionId` for `control` (`null`: not under the handshake) after the request `askedAt` was shown last. */
102
+ export function fileOf(control, askedAt, sessionId) {
103
+ return {
104
+ v: 1,
105
+ sessionId,
106
+ control: control === null
107
+ ? null
108
+ : {
109
+ floorTokens: control.floorTokens,
110
+ passAtTokens: control.passAtTokens,
111
+ windowTokens: control.windowTokens,
112
+ releaseUntilMs: control.releaseUntilMs,
113
+ releaseId: control.releaseId,
114
+ agents: Object.fromEntries(Object.entries(control.agents).map(([id, a]) => [id, { passAtTokens: a.passAtTokens }])),
115
+ valves: control.valves,
116
+ mode: control.mode,
117
+ },
118
+ askedAt,
119
+ };
120
+ }
121
+ const COMPACTION_DIR = "compaction";
122
+ export function compactionDir(home = homedir()) {
123
+ return sessionControlDir(COMPACTION_DIR, home);
124
+ }
125
+ /** `~/.config/danxbot/compaction/<sessionId>.json`, next to the plugin's other per-session records. */
126
+ export function compactionFilePath(sessionId, home = homedir()) {
127
+ return sessionControlFilePath(COMPACTION_DIR, sessionId, home);
128
+ }
129
+ export function parseCompactionFile(text, where) {
130
+ const parsed = JSON.parse(text);
131
+ if (!isRecord(parsed) || parsed.v !== 1 || typeof parsed.sessionId !== "string" || !("control" in parsed) || (parsed.askedAt !== null && typeof parsed.askedAt !== "string")) {
132
+ throw new Error(`${where} is not a compaction file`);
133
+ }
134
+ const reason = parsed.control === null ? null : invalidCompactionReason({ control: { ...parsed.control, v: 1, ask: null } });
135
+ if (reason !== null)
136
+ throw new Error(`${where}: ${reason}`);
137
+ return parsed;
138
+ }
139
+ /** The real store, under `home` (`sessionControlFilesIn`: atomic writes, the weekly pruning of files no live relay touches). */
140
+ export function compactionFilesIn(home = homedir(), now = Date.now) {
141
+ return sessionControlFilesIn(COMPACTION_DIR, parseCompactionFile, home, now);
142
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * DX-2862 / DX-4390 - the ONE resolver for this package's dashboard credential.
3
+ *
4
+ * WHY IT IS ONE FUNCTION AND NOT TWO. Two kinds of caller speak to the dashboard for the same
5
+ * Claude Code session: the MCP SERVER (the session's tools and its event relay) and the `restart_notice` predecessor lookup.
6
+ * When they resolved their credential independently they could differ (DX-2862): the session's tools worked, a
7
+ * lookup authenticated as somebody else, and its result was dropped with no error anywhere. So resolution lives HERE,
8
+ * both go through it, and the lookup additionally PROVES it holds the server's credential by comparing fingerprints
9
+ * (`session-connection.ts`).
10
+ *
11
+ * A credential comes from exactly one of two places:
12
+ *
13
+ * - `DANX_DASHBOARD_CREDENTIAL` - the bearer verbatim. A DISPATCH declares it: the worker
14
+ * substitutes it into the materialized `.mcp.json` from the dispatch overlay. Authoritative
15
+ * when set, and it never signs out.
16
+ * - a SESSION KEY (`session-key.ts`) - the key a user approved for this one session through
17
+ * `plan_connect`. An operator session has no other credential: with neither of these it boots
18
+ * signed out, and `plan_connect` asks for access.
19
+ *
20
+ * There is no token-file source (DX-4390): a long-lived bearer placed on disk by hand is exactly
21
+ * what request-and-approve replaces.
22
+ */
23
+ import { createHash } from "node:crypto";
24
+ import { homedir } from "node:os";
25
+ import { SessionKeyAbsentError, readSessionKeyFile, sessionKeyPath, } from "./session-key.js";
26
+ export const CREDENTIAL_TOKEN_ENV = "DANX_DASHBOARD_CREDENTIAL";
27
+ /**
28
+ * A credential that cannot be resolved. Carries the remedy beside the reason, because both of this
29
+ * module's callers report failures to a human who is not reading a log: the server prints it to
30
+ * stderr, and `restart_notice` answers it to the session.
31
+ */
32
+ export class CredentialError extends Error {
33
+ fix;
34
+ constructor(detail, fix) {
35
+ super(detail);
36
+ this.name = "CredentialError";
37
+ this.fix = fix;
38
+ }
39
+ }
40
+ function readEnv(env, name) {
41
+ const value = env[name];
42
+ return typeof value === "string" && value.trim() !== "" ? value.trim() : null;
43
+ }
44
+ /** One line naming a source, for a message a human reads. Holds no secret. */
45
+ export function describeCredentialSource(source) {
46
+ return source.kind === "session-key"
47
+ ? `the session key file ${source.path}`
48
+ : `the environment variable ${source.name}`;
49
+ }
50
+ /** The credential a source yields. Every failure is fatal and named. */
51
+ export function readCredential(source, env) {
52
+ if (source.kind === "env") {
53
+ const token = readEnv(env, source.name);
54
+ if (token === null) {
55
+ throw new CredentialError(`${describeCredentialSource(source)} is unset or empty in this process`, `set ${source.name} for this process`);
56
+ }
57
+ return token;
58
+ }
59
+ try {
60
+ return readSessionKeyFile(source.path);
61
+ }
62
+ catch (err) {
63
+ throw new CredentialError(`cannot read ${describeCredentialSource(source)}: ${err.message}`, "call plan_connect to request access again; the key file is written when the request is approved");
64
+ }
65
+ }
66
+ /**
67
+ * A credential's IDENTITY, for comparing two processes' credentials without either of them
68
+ * handling the other's. The first 12 hex of SHA-256 - 48 bits, enough that two distinct tokens
69
+ * colliding is not a thing that happens, and nothing an attacker can invert.
70
+ */
71
+ export function credentialFingerprint(token) {
72
+ return createHash("sha256").update(token).digest("hex").slice(0, 12);
73
+ }
74
+ /**
75
+ * This session's stored key, or `null` (never signed in here). A key file that is there but
76
+ * unusable throws `SessionKeyUnusableError`: the caller decides what to do with the file, nothing
77
+ * is guessed here. Never reads the environment: an operator tool that must authenticate as the
78
+ * session (`make finish-worktree`) uses this alone, so an inherited dispatch credential cannot win.
79
+ */
80
+ export function resolveSessionKeyCredential(where) {
81
+ if (where.sessionId === undefined)
82
+ return null;
83
+ const path = sessionKeyPath(where.dashboardUrl, where.sessionId, where.home ?? homedir());
84
+ try {
85
+ const key = readSessionKeyFile(path);
86
+ return { source: { kind: "session-key", path }, token: key, fingerprint: credentialFingerprint(key) };
87
+ }
88
+ catch (err) {
89
+ if (err instanceof SessionKeyAbsentError)
90
+ return null;
91
+ throw err;
92
+ }
93
+ }
94
+ /**
95
+ * What this server boots with: the dispatch credential when the env declares one, else this
96
+ * session's stored key, else `null` (signed out).
97
+ */
98
+ export function resolveBootCredential(env, where) {
99
+ const token = readEnv(env, CREDENTIAL_TOKEN_ENV);
100
+ if (token !== null) {
101
+ return { source: { kind: "env", name: CREDENTIAL_TOKEN_ENV }, token, fingerprint: credentialFingerprint(token) };
102
+ }
103
+ return resolveSessionKeyCredential(where);
104
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * DX-3923 — correct first calls for the routes every card session uses,
3
+ * rendered into the `50arms_api` tool description (index.ts). The DX-3678
4
+ * grading run found the same avoidable 4xx in session after session (a card
5
+ * read's `fields` sent as a comma string, `fields.parent`, top-level list
6
+ * filters, a card edit sent to `PATCH /api/issues/:id`, a comment keyed
7
+ * `body`, retro `tests[].kind: "unit"`, the AC-tick route hunted through a
8
+ * 36 KB `50arms_api_list`) because nothing an agent
9
+ * reads before its first call showed the right shape. The tool description
10
+ * is the one surface every profile and every operator session carries.
11
+ * DX-4016 — the DX-3678 phase C run found the non-work profiles still paying
12
+ * a 4xx on three writes: a card create without `plan`, an edit whose `ac[]`
13
+ * items were keyed `text` (and one with no `content_hash`), and a retro whose
14
+ * flag was false but whose description was missing, then prose.
15
+ * DX-3917 / DX-4015 — the phase D run found 9 work sessions writing the
16
+ * retro before the `complete` transition (the list went AC tick -> retro,
17
+ * with no transition between) and 14 of 24 writing the transition with no
18
+ * spec call: the transition is now a shown write, placed before the retro.
19
+ * DX-3923 (phase E) — four triage card reads asked `fields` for
20
+ * `status_derived`, a LIST filter key the card-list example showed, so the
21
+ * list example filters by `q`/`include_closed` instead. Two haiku AC ticks
22
+ * read `ac` (no checklist id) and sent `ac` / `0` as the checklist id: the
23
+ * card read shows only `checklists`, and both tick ids name it.
24
+ * DX-4525 - the card-list and comment examples were trimmed: a list or a comment sent wrong is refused with a body naming
25
+ * the fix, and the spec call the description asks for first shows both. The dispatch-callback calls (complete, gate sign-off,
26
+ * code review) are NOT shown here: only a dispatch makes them, so every operator session would pay for them in this
27
+ * description; each profile's own instructions name its route and body.
28
+ *
29
+ * Kept as data, not prose, so danxbot's own tests can check each example
30
+ * against the live server contract (route table, resource fields, body
31
+ * schemas) and fail when one drifts — this package cannot import the
32
+ * server's constants itself (it is published on its own).
33
+ */
34
+ export const DANXBOT_API_EXAMPLES = {
35
+ cardRead: {
36
+ method: "GET",
37
+ path: "/api/issues/<id>",
38
+ query: { fields: { description: true, agent_notes: true, comments: true, checklists: true, parent_title: true } },
39
+ },
40
+ cardCreate: {
41
+ method: "POST",
42
+ path: "/api/issues",
43
+ body: {
44
+ board: 3,
45
+ type: "Bug",
46
+ title: "<title>",
47
+ summary: "<one plain line: what this card is>",
48
+ description: "<markdown>",
49
+ plan: null,
50
+ },
51
+ },
52
+ cardEdit: {
53
+ method: "PATCH",
54
+ path: "/api/issues/<id>/edit",
55
+ body: { description: "<markdown>", ac: [{ title: "<criterion>" }], content_hash: "<content_hash from your read>" },
56
+ },
57
+ acTick: {
58
+ method: "PATCH",
59
+ path: "/api/issues/<id>/checklists/<checklists[].id>/items/<checklists[].items[].id>",
60
+ body: { status: "passing" },
61
+ },
62
+ transition: {
63
+ method: "POST",
64
+ path: "/api/issues/<id>/transition",
65
+ body: { action: "<action>", summary: "<text>" },
66
+ },
67
+ retro: {
68
+ method: "PUT",
69
+ path: "/api/issues/<id>/retro",
70
+ body: {
71
+ good: "<text>",
72
+ bad: "<text>",
73
+ correctable_danxbot_problem: false,
74
+ correctable_danxbot_problem_description: "",
75
+ action_item_ids: [],
76
+ commits: [{ sha: "<sha>" }],
77
+ tests: [],
78
+ },
79
+ },
80
+ };
81
+ /** The retro route's accepted `tests[].kind` values. */
82
+ export const RETRO_TEST_KIND_EXAMPLES = ["group", "e2e"];
83
+ function render(example) {
84
+ const { method, path, query, body } = example;
85
+ return JSON.stringify({ method, path, ...(query ? { query } : {}), ...(body ? { body } : {}) });
86
+ }
87
+ /** The description sentence `50arms_api` carries. */
88
+ export function danxbotApiExamplesText() {
89
+ const e = DANXBOT_API_EXAMPLES;
90
+ return (`Correct first calls: card read ${render(e.cardRead)} (\`fields\` is an object, never a comma string); ` +
91
+ `card create ${render(e.cardCreate)} (\`plan\` required: "mine" or null; \`summary\` required, on every phase_children[] item too); ` +
92
+ `card edit ${render(e.cardEdit)} (only the keys you change; \`ac\` replaces the list); ` +
93
+ `AC tick ${render(e.acTick)}; ` +
94
+ `transition ${render(e.transition)}; ` +
95
+ `retro, only after \`complete\` or \`cancel\`, ${render(e.retro)} (description "" unless the flag is true; ` +
96
+ `\`tests[].kind\` ${RETRO_TEST_KIND_EXAMPLES.map((k) => `"${k}"`).join(" or ")}).`);
97
+ }
@@ -0,0 +1,14 @@
1
+ export function dashboardIdentity(baseUrl) {
2
+ const url = baseUrl.replace(/\/+$/, "");
3
+ let parsed;
4
+ try {
5
+ parsed = new URL(url);
6
+ }
7
+ catch {
8
+ throw new Error(`the dashboard URL is not an http(s) URL: ${JSON.stringify(baseUrl)}`);
9
+ }
10
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
11
+ throw new Error(`the dashboard URL is not an http(s) URL: ${JSON.stringify(baseUrl)}`);
12
+ }
13
+ return { url, host: parsed.host };
14
+ }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * DX-4077 (operator decision, DX-4083 problem 1856) - the screenshot a design revision carries: a 1280x800 viewport PNG of the built
3
+ * page, taken in a headless Chromium with the network off.
4
+ *
5
+ * The browser is resolved the way Vite is, never as a package dependency: Playwright (or `playwright-core`) from the TARGET root first,
6
+ * then from this server's own working directory (`createRequire(<dir>/package.json)`, never a global lookup). The operator's rule is
7
+ * that "no preview image is a fine degraded mode": no browser, a failed launch, a failed capture or a PNG over 1 MiB all publish
8
+ * WITHOUT a screenshot and say why in the result (`none: <reason>`). That is the one stated degraded mode, reported and never silent,
9
+ * and it never fails the publish. `captureWith` is the shared launch-drive-shoot-close path (DX-5256): `plugin-screenshot.ts` drives it online
10
+ * against the dashboard's preview page, where a design capture runs offline against its own HTML.
11
+ */
12
+ import { createRequire } from "node:module";
13
+ import path from "node:path";
14
+ export const SCREENSHOT_WIDTH = 1280;
15
+ export const SCREENSHOT_HEIGHT = 800;
16
+ /** The revision route's cap on a decoded screenshot (`MAX_SCREENSHOT_BYTES`, `src/issues/design-limits.ts`): 1 MiB. */
17
+ export const MAX_SCREENSHOT_BYTES = 1024 * 1024;
18
+ /** How long a loaded page may take to say it mounted (`danx-design:ready`) before the capture goes ahead anyway. */
19
+ const READY_WAIT_MS = 5000;
20
+ /** The message a design's harness posts when it has mounted (`DESIGN_FRAME_READY` in `@danxbot/ui`, restated: this package cannot import it). */
21
+ export const READY_MESSAGE = "danx-design:ready";
22
+ const READY_LISTENER = `window.__danxDesignReady = false; window.addEventListener("message", function (e) { ` +
23
+ `if (e.data && e.data.type === ${JSON.stringify(READY_MESSAGE)}) window.__danxDesignReady = true; });`;
24
+ /**
25
+ * DX-5350: the screenshot renders the page outside `SandboxedFrame`, so `window.danxMiniApp` (which the frame injects, `@danxbot/ui` 27.0.0)
26
+ * is restated here as a stub: `holdReady`, a once-only `ready` and ready-on-`load` unless held, posting the same ready message the listener
27
+ * above hears. `reportError` and `onAppearance` are no-ops so a harness never throws.
28
+ */
29
+ export const MINI_APP_STUB = `(function () { var held = false, posted = false; ` +
30
+ `function ready() { if (posted) return; posted = true; window.postMessage({ type: ${JSON.stringify(READY_MESSAGE)} }, "*"); } ` +
31
+ `window.danxMiniApp = { holdReady: function () { held = true; }, ready: ready, reportError: function () {}, onAppearance: function () { return function () {}; } }; ` +
32
+ `window.addEventListener("load", function () { if (!held) ready(); }); })();`;
33
+ /**
34
+ * DX-5880: the screenshot renders the page outside the viewer, so `window.danxDesignOptions` (the viewer's option-picks channel,
35
+ * `frontend/src/lib/design-frame-options.ts`) is restated here with the RECOMMENDED picks, so the tile's screenshot shows the recommended combination.
36
+ * The groups are the revision's as the designer passed them; the server validates them when the revision is posted, so this stub reads them as typed.
37
+ * A design with no groups gets no global (the page then shows its own default).
38
+ */
39
+ export function designOptionsStub(groups) {
40
+ if (groups.length === 0)
41
+ return null;
42
+ return (`(function () { var groups = ${JSON.stringify(groups).replace(/</g, "\\u003c")}; var picks = {}; ` +
43
+ `groups.forEach(function (g) { picks[g.key] = g.options.filter(function (o) { return g.recommended.indexOf(o) !== -1; }); }); ` +
44
+ `window.danxDesignOptions = { groups: groups, picks: function () { return JSON.parse(JSON.stringify(picks)); }, subscribe: function () { return function () {}; } }; })();`);
45
+ }
46
+ function none(reason) {
47
+ return { png_base64: null, screenshot: `none: ${reason}` };
48
+ }
49
+ /** One line of an error: its first line, bounded, so a long Playwright launch log never floods the result. */
50
+ function oneLine(err) {
51
+ const text = err instanceof Error ? err.message : String(err);
52
+ const first = text.split("\n")[0].trim();
53
+ return first.length > 200 ? `${first.slice(0, 200)}...` : first;
54
+ }
55
+ /**
56
+ * The first of `playwright` / `playwright-core` that resolves from the target root, else from `cwd`. Returns the loaded module, or null
57
+ * when neither resolves.
58
+ */
59
+ function resolvePlaywright(rootAbs, cwd) {
60
+ for (const base of [rootAbs, cwd]) {
61
+ const req = createRequire(path.join(base, "package.json"));
62
+ for (const name of ["playwright", "playwright-core"]) {
63
+ let resolved;
64
+ try {
65
+ resolved = req.resolve(name);
66
+ }
67
+ catch {
68
+ continue;
69
+ }
70
+ return { module: req(resolved), from: `${name} from ${base}` };
71
+ }
72
+ }
73
+ return null;
74
+ }
75
+ /**
76
+ * The one capture path (DX-4077, DX-5256): resolve a browser, open a 1280x800 device-scale-1 page, let `drive` bring it to the state to shoot,
77
+ * take the viewport PNG, and close the browser in a `finally` on every path. `offline` cuts the network (a design page is self-contained; a
78
+ * plugin's render needs the dashboard's own page and shims).
79
+ */
80
+ export async function captureWith(where, offline, drive) {
81
+ let playwright;
82
+ try {
83
+ playwright = resolvePlaywright(where.rootAbs, where.cwd);
84
+ }
85
+ catch (err) {
86
+ return none(`playwright could not be loaded: ${oneLine(err)}`);
87
+ }
88
+ if (playwright === null) {
89
+ return none(`neither playwright nor playwright-core resolves from the target root ${where.rootAbs} or from ${where.cwd}`);
90
+ }
91
+ let browser;
92
+ try {
93
+ browser = await playwright.module.chromium.launch({ headless: true });
94
+ }
95
+ catch (err) {
96
+ return none(`the browser could not be launched (${playwright.from}): ${oneLine(err)}`);
97
+ }
98
+ try {
99
+ const context = await browser.newContext({ viewport: { width: SCREENSHOT_WIDTH, height: SCREENSHOT_HEIGHT }, deviceScaleFactor: 1, offline });
100
+ const page = await context.newPage();
101
+ const refusal = await drive(page);
102
+ if (typeof refusal === "string")
103
+ return none(refusal);
104
+ const png = await page.screenshot({ type: "png" });
105
+ if (png.length > MAX_SCREENSHOT_BYTES) {
106
+ return none(`the screenshot is ${png.length} bytes, over the ${MAX_SCREENSHOT_BYTES} byte (1 MiB) limit`);
107
+ }
108
+ return { png_base64: png.toString("base64"), screenshot: "captured" };
109
+ }
110
+ catch (err) {
111
+ return none(`the screenshot failed: ${oneLine(err)}`);
112
+ }
113
+ finally {
114
+ // A browser that will not close must not fail a publish whose screenshot is already decided.
115
+ await browser.close().catch((err) => console.error(`[platform-mcp] could not close the browser: ${oneLine(err)}`));
116
+ }
117
+ }
118
+ /** DX-5319: where the built html is served for the capture: a route answered by the capture itself, so the page is a real navigation and never reaches a network. */
119
+ const DESIGN_PAGE_URL = "https://design.invalid/";
120
+ /**
121
+ * Capture `html` as a 1280x800 viewport PNG (device scale 1, network disabled). The page is served from a route and navigated to, never
122
+ * `setContent`-ed: an init script runs on a navigation and not on `setContent`'s `document.write`, so the ready listener and the
123
+ * `window.danxMiniApp` stub would not exist and every capture would wait out the whole ready timeout (DX-5319, measured: 5.2 s, the stub
124
+ * undefined). It then gets a short wait for the harness's ready message (the page has loaded either way).
125
+ */
126
+ export function captureScreenshot(html, where, optionGroups) {
127
+ return captureWith(where, true, async (page) => {
128
+ await page.addInitScript(READY_LISTENER);
129
+ await page.addInitScript(MINI_APP_STUB);
130
+ const options = designOptionsStub(optionGroups);
131
+ if (options !== null)
132
+ await page.addInitScript(options);
133
+ await page.route(DESIGN_PAGE_URL, (route) => route.fulfill({ status: 200, contentType: "text/html; charset=utf-8", body: html }));
134
+ await page.goto(DESIGN_PAGE_URL, { waitUntil: "load" });
135
+ // The page has loaded; a design that never posts ready (it threw) is still captured as it stands.
136
+ await page.waitForFunction("window.__danxDesignReady === true", undefined, { timeout: READY_WAIT_MS }).catch(() => undefined);
137
+ });
138
+ }
@@ -0,0 +1,67 @@
1
+ import { isUrgent } from "./urgent-records.js";
2
+ export const DIGEST_INTERVAL_MS = 10 * 60_000;
3
+ export const DIGEST_PREFIX = "[plan events]";
4
+ /** ONE combined record for every event held since the last flush. */
5
+ function formatDigest(entries) {
6
+ const header = `${DIGEST_PREFIX} digest — ${entries.length} agent/machine event${entries.length === 1 ? "" : "s"} since the last update:`;
7
+ return [header, ...entries.map((e) => e.text)].join("\n");
8
+ }
9
+ export class DigestHold {
10
+ emit;
11
+ held = [];
12
+ timer = null;
13
+ /** `emit` receives every record the session should now be told, in order. */
14
+ constructor(emit) {
15
+ this.emit = emit;
16
+ }
17
+ /**
18
+ * Decide immediate vs. held. `operator` and `null` flush any pending digest first and then emit at once. `agent` /
19
+ * `machine` are appended to the hold, arming the timer only for the FIRST held event since the last flush: a later
20
+ * addition must never push the deadline out, or "at most every 10 minutes" would erode into "10 minutes after the last
21
+ * event", which can never fire while events keep arriving. Deduped by id (defense in depth, see the module docblock).
22
+ */
23
+ route(event) {
24
+ // DX-5846 / problem 2579: an event carrying acks is sent at once whoever made it (a changes-requested review is machine-made); the digest cannot carry one ack per event.
25
+ if ((event.origin === "agent" || event.origin === "machine") && event.acks.length === 0) {
26
+ if (event.id !== null && this.heldIds().includes(event.id))
27
+ return;
28
+ this.held.push(event);
29
+ // unref'd: a held digest never keeps the MCP server alive after its session ended
30
+ this.timer ??= setTimeout(() => this.flush(), DIGEST_INTERVAL_MS).unref();
31
+ return;
32
+ }
33
+ this.flush();
34
+ this.emit(event);
35
+ }
36
+ /** Combine every held event into ONE record and emit it, if there is anything to combine. */
37
+ flush() {
38
+ this.cancel();
39
+ if (this.held.length === 0)
40
+ return;
41
+ const entries = this.held;
42
+ this.held = [];
43
+ const ids = entries.map((e) => e.id).filter((id) => id !== null);
44
+ this.emit({ type: "event", id: ids.length === 0 ? null : Math.max(...ids), ids, text: formatDigest(entries), origin: null, urgent: isUrgent({ source: "digest" }), acks: [] });
45
+ }
46
+ /**
47
+ * Stop the timer WITHOUT flushing. For a stop where a newer listener already streams this session and will receive (and
48
+ * hold) the same events via the dashboard's replay, flushing here too would double-deliver the digest; for any other stop
49
+ * whatever is held was never marked delivered, so a later start picks it up the same way. A live, un-fired timer would also
50
+ * keep a process's event loop alive for no reason.
51
+ */
52
+ cancel() {
53
+ if (this.timer === null)
54
+ return;
55
+ clearTimeout(this.timer);
56
+ this.timer = null;
57
+ }
58
+ /** Drop everything held, without emitting it: the caller is letting go of the stream and the dashboard replays it. */
59
+ reset() {
60
+ this.cancel();
61
+ this.held = [];
62
+ }
63
+ /** Every id currently held, unflushed: handed to the next listener so it drops a replay of one. */
64
+ heldIds() {
65
+ return this.held.map((e) => e.id).filter((id) => id !== null);
66
+ }
67
+ }