mockpit 2.0.0 → 3.1.0

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 (90) hide show
  1. package/README.md +59 -223
  2. package/bin/demoData.js +2 -2
  3. package/bin/initDesign.js +17 -22
  4. package/bin/mockpit.js +626 -216
  5. package/bin/poster.js +434 -0
  6. package/dist/mcp/server.js +127 -29
  7. package/dist/server/answerAdditions.d.ts +7 -0
  8. package/dist/server/answerAdditions.js +76 -0
  9. package/dist/server/apiViews.d.ts +627 -385
  10. package/dist/server/apiViews.js +198 -81
  11. package/dist/server/app.d.ts +7 -2
  12. package/dist/server/app.js +3514 -984
  13. package/dist/server/appIcon.js +15 -5
  14. package/dist/server/appIconPng.js +3 -3
  15. package/dist/server/askNudges.js +1 -1
  16. package/dist/server/cdnInline.d.ts +8 -0
  17. package/dist/server/cdnInline.js +640 -0
  18. package/dist/server/codeWorker.js +2 -2
  19. package/dist/server/contrast.d.ts +4 -0
  20. package/dist/server/contrast.js +157 -0
  21. package/dist/server/decisionList.d.ts +48 -0
  22. package/dist/server/decisionList.js +340 -0
  23. package/dist/server/decisions.d.ts +4 -4
  24. package/dist/server/decisions.js +9 -9
  25. package/dist/server/designGuide.d.ts +14 -7
  26. package/dist/server/designGuide.js +314 -88
  27. package/dist/server/embeds.d.ts +3 -1
  28. package/dist/server/embeds.js +31 -6
  29. package/dist/server/eventLog.d.ts +2 -2
  30. package/dist/server/eventLog.js +11 -11
  31. package/dist/server/events.d.ts +15 -6
  32. package/dist/server/feedbackBatch.d.ts +39 -13
  33. package/dist/server/feedbackBatch.js +129 -47
  34. package/dist/server/fills.d.ts +4 -0
  35. package/dist/server/fills.js +55 -1
  36. package/dist/server/guidance.d.ts +1 -1
  37. package/dist/server/guidance.js +2 -2
  38. package/dist/server/handoff.d.ts +4 -1
  39. package/dist/server/handoff.js +8 -2
  40. package/dist/server/icons.js +1 -1
  41. package/dist/server/index.js +39 -10
  42. package/dist/server/jump.d.ts +21 -0
  43. package/dist/server/jump.js +175 -0
  44. package/dist/server/kits.d.ts +2 -2
  45. package/dist/server/kits.js +54 -1
  46. package/dist/server/knobs.d.ts +3 -3
  47. package/dist/server/knobs.js +6 -6
  48. package/dist/server/mcpHttp.d.ts +7 -1
  49. package/dist/server/mcpHttp.js +29 -12
  50. package/dist/server/mcpSpec.d.ts +27 -27
  51. package/dist/server/mcpSpec.js +196 -56
  52. package/dist/server/parts.js +9 -1
  53. package/dist/server/run.d.ts +1 -1
  54. package/dist/server/run.js +47 -17
  55. package/dist/server/runApi.d.ts +3 -3
  56. package/dist/server/runApi.js +34 -21
  57. package/dist/server/sqlStore.d.ts +148 -103
  58. package/dist/server/sqlStore.js +1052 -181
  59. package/dist/server/standings.d.ts +25 -0
  60. package/dist/server/standings.js +113 -0
  61. package/dist/server/surfacePage.d.ts +8 -9
  62. package/dist/server/surfacePage.js +423 -100
  63. package/dist/server/system.d.ts +234 -0
  64. package/dist/server/system.js +1508 -0
  65. package/dist/server/systemChecks.d.ts +15 -0
  66. package/dist/server/systemChecks.js +224 -0
  67. package/dist/server/systemSpecimen.d.ts +2 -0
  68. package/dist/server/systemSpecimen.js +49 -0
  69. package/dist/server/themes.d.ts +9 -7
  70. package/dist/server/themes.js +43 -90
  71. package/dist/server/types.d.ts +266 -93
  72. package/dist/server/types.js +76 -79
  73. package/extensions/mockpit.js +89 -72
  74. package/guide/AGENT_SETUP.md +15 -1
  75. package/guide/packs/design-craft.md +2 -3
  76. package/guide/packs/foundations.md +35 -0
  77. package/guide/topics/asks.md +108 -63
  78. package/guide/topics/feedback.md +129 -120
  79. package/guide/topics/html.md +18 -25
  80. package/guide/topics/http.md +43 -41
  81. package/guide/topics/knobs.md +7 -10
  82. package/guide/topics/scripts.md +22 -33
  83. package/guide/topics/surfaces.md +39 -48
  84. package/guide/topics/system.md +157 -0
  85. package/opencode/mockpit.js +120 -0
  86. package/opencode/package.json +8 -0
  87. package/package.json +15 -14
  88. package/skills/launch-video/SKILL.md +11 -11
  89. package/skills/mockpit/SKILL.md +33 -26
  90. package/viewer/dist/index.html +10 -9
package/bin/poster.js ADDED
@@ -0,0 +1,434 @@
1
+ // Automatic posters: a still of a page just published, taken with a local headless Chrome
2
+ // or Chromium, so Studio and Home show a picture without the agent doing anything. Built-ins
3
+ // only, like the rest of the CLI, and the stdio MCP server loads this same file.
4
+ //
5
+ // A publish only leaves a job here and returns. A detached worker does the rest, one page at
6
+ // a time through one browser, because an agent publishing thirty variants in a loop must not
7
+ // start thirty Chromes. Nothing in here may reach the agent: no browser, a timeout or a failed
8
+ // capture means the page runs live in the overviews, which is what it did before posters.
9
+ //
10
+ // The worker renders the served document and doesn't take a poster's place: one sent by the
11
+ // agent wins, and the server refuses a still of a version that has since changed. A system
12
+ // write retakes the project's stills: one taken under an older revision no longer shows.
13
+
14
+ import { spawn } from "node:child_process";
15
+ import {
16
+ existsSync,
17
+ mkdirSync,
18
+ mkdtempSync,
19
+ readdirSync,
20
+ readFileSync,
21
+ rmdirSync,
22
+ rmSync,
23
+ statSync,
24
+ utimesSync,
25
+ writeFileSync,
26
+ } from "node:fs";
27
+ import { tmpdir, userInfo } from "node:os";
28
+ import { delimiter, join } from "node:path";
29
+ import { fileURLToPath } from "node:url";
30
+
31
+ const SELF = fileURLToPath(import.meta.url);
32
+
33
+ // One page's whole capture. A page that hasn't drawn by then isn't worth a browser's time.
34
+ const CAPTURE_MS = 15_000;
35
+ const LOAD_MS = 8_000;
36
+ // A job this old describes a burst long past, and its version has likely moved on.
37
+ const JOB_MAX_AGE_MS = 2 * 60_000;
38
+ // Stills one system change retakes, latest variants first: the rest run live until then.
39
+ const RETAKE_MAX = 40;
40
+ // Stays up briefly for the next publish of a burst, which saves starting a browser again.
41
+ const WORKER_IDLE_MS = 3_000;
42
+ const WORKER_MAX_MS = 10 * 60_000;
43
+ // A lock not touched for longer than the longest capture belongs to a worker that died.
44
+ const LOCK_STALE_MS = CAPTURE_MS * 2;
45
+ const BROWSER_START_MS = 10_000;
46
+ // Overviews show a page small and whole; past this its top is what can be read anyway.
47
+ const MAX_HEIGHT_PER_WIDTH = 3;
48
+ const DEFAULT_WIDTH = 820;
49
+ const VIEWPORT_H = 800;
50
+ const JPEG_QUALITY = 80;
51
+
52
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
53
+
54
+ function browserCandidates() {
55
+ if (process.platform === "darwin") {
56
+ return [
57
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
58
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
59
+ ];
60
+ }
61
+ if (process.platform === "win32") {
62
+ return [process.env.PROGRAMFILES, process.env["PROGRAMFILES(X86)"], process.env.LOCALAPPDATA]
63
+ .filter(Boolean)
64
+ .map((root) => join(root, "Google", "Chrome", "Application", "chrome.exe"));
65
+ }
66
+ const names = ["google-chrome", "google-chrome-stable", "chromium", "chromium-browser"];
67
+ const dirs = (process.env.PATH ?? "").split(delimiter).filter(Boolean);
68
+ return dirs.flatMap((dir) => names.map((name) => join(dir, name)));
69
+ }
70
+
71
+ // CHROME_PATH names the browser. Set to anything that isn't one, it means there is none.
72
+ export function findBrowser() {
73
+ const chosen = process.env.CHROME_PATH;
74
+ if (chosen !== undefined) return chosen && existsSync(chosen) ? chosen : null;
75
+ return browserCandidates().find((path) => existsSync(path)) ?? null;
76
+ }
77
+
78
+ // --- the queue: a private directory, since a job holds the workspace's token ---
79
+
80
+ function queueDir() {
81
+ const who = process.getuid?.() ?? userInfo().username;
82
+ const dir = join(tmpdir(), `mockpit-posters-${who}`);
83
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
84
+ return dir;
85
+ }
86
+ const lockPath = (dir) => join(dir, "worker.lock");
87
+ const lockIsFresh = (dir) => {
88
+ try {
89
+ return Date.now() - statSync(lockPath(dir)).mtimeMs < LOCK_STALE_MS;
90
+ } catch {
91
+ return false;
92
+ }
93
+ };
94
+ const jobsIn = (dir) =>
95
+ readdirSync(dir)
96
+ .filter((name) => name.endsWith(".job"))
97
+ .sort();
98
+
99
+ const startWorker = () =>
100
+ spawn(process.execPath, [SELF, "--work"], { detached: true, stdio: "ignore" }).unref();
101
+
102
+ // { base, token, client, session, design, postId, version }. Never throws, never waits.
103
+ export function queuePoster(job) {
104
+ try {
105
+ if (!findBrowser()) return;
106
+ const dir = queueDir();
107
+ const name = `${Date.now()}-${process.pid}-${Math.random().toString(36).slice(2)}.job`;
108
+ writeFileSync(join(dir, name), JSON.stringify({ ...job, queuedAt: Date.now() }), {
109
+ mode: 0o600,
110
+ });
111
+ if (!lockIsFresh(dir)) startWorker();
112
+ } catch {
113
+ // A poster is never worth a failed publish.
114
+ }
115
+ }
116
+
117
+ // { base, token, client, session, project }: after a system write that changed something.
118
+ // The worker reads which variants need a new still, so this never waits either.
119
+ export function queueRetakes(job) {
120
+ queuePoster({ ...job, retake: true });
121
+ }
122
+
123
+ // --- the browser, over the DevTools protocol on a local port ---
124
+
125
+ async function launchBrowser(path) {
126
+ const profile = mkdtempSync(join(tmpdir(), "mockpit-poster-profile-"));
127
+ const proc = spawn(
128
+ path,
129
+ [
130
+ "--headless=new",
131
+ "--remote-debugging-port=0",
132
+ `--user-data-dir=${profile}`,
133
+ "--no-first-run",
134
+ "--no-default-browser-check",
135
+ "--disable-extensions",
136
+ "--disable-background-networking",
137
+ "--disable-sync",
138
+ "--hide-scrollbars",
139
+ "--mute-audio",
140
+ // Never a keychain prompt on the user's screen for a profile thrown away after.
141
+ "--use-mock-keychain",
142
+ // A machine with no GPU still draws a WebGL page, the kind that most needs a poster.
143
+ "--enable-unsafe-swiftshader",
144
+ "about:blank",
145
+ ],
146
+ { stdio: "ignore" },
147
+ );
148
+ const stop = () => {
149
+ proc.kill();
150
+ // The browser is still writing to its profile for a moment after the signal.
151
+ setTimeout(() => rmSync(profile, { recursive: true, force: true }), 500);
152
+ };
153
+ const portFile = join(profile, "DevToolsActivePort");
154
+ const giveUp = Date.now() + BROWSER_START_MS;
155
+ while (!existsSync(portFile) || !readFileSync(portFile, "utf8").includes("\n")) {
156
+ if (proc.exitCode !== null || Date.now() > giveUp) {
157
+ stop();
158
+ return null;
159
+ }
160
+ await sleep(50);
161
+ }
162
+ const port = Number(readFileSync(portFile, "utf8").split("\n")[0]);
163
+ return { port, stop, alive: () => proc.exitCode === null };
164
+ }
165
+
166
+ async function openPage(port) {
167
+ const res = await fetch(`http://127.0.0.1:${port}/json/new?about:blank`, { method: "PUT" });
168
+ const target = await res.json();
169
+ const ws = new WebSocket(target.webSocketDebuggerUrl);
170
+ await new Promise((resolve, reject) => {
171
+ ws.onopen = resolve;
172
+ ws.onerror = reject;
173
+ });
174
+ let nextId = 0;
175
+ const pending = new Map();
176
+ const waiting = new Map();
177
+ ws.onmessage = (e) => {
178
+ const m = JSON.parse(e.data);
179
+ if (m.id !== undefined) {
180
+ const call = pending.get(m.id);
181
+ pending.delete(m.id);
182
+ if (m.error) call?.reject(new Error(m.error.message));
183
+ else call?.resolve(m.result);
184
+ } else waiting.get(m.method)?.();
185
+ };
186
+ return {
187
+ send: (method, params = {}) =>
188
+ new Promise((resolve, reject) => {
189
+ const id = ++nextId;
190
+ pending.set(id, { resolve, reject });
191
+ ws.send(JSON.stringify({ id, method, params }));
192
+ }),
193
+ event: (method) => new Promise((resolve) => waiting.set(method, resolve)),
194
+ close: async () => {
195
+ ws.close();
196
+ await fetch(`http://127.0.0.1:${port}/json/close/${target.id}`).catch(() => {});
197
+ },
198
+ };
199
+ }
200
+
201
+ // Run in the page. Waits for what's drawing itself in and doesn't wait for what loops.
202
+ const SETTLE = `(async () => {
203
+ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
204
+ await Promise.race([document.fonts ? document.fonts.ready : 0, wait(2000)]);
205
+ await wait(400);
206
+ const finite = document.getAnimations().filter((a) => {
207
+ const t = a.effect && a.effect.getComputedTiming();
208
+ return t && Number.isFinite(t.endTime) && a.playState === "running";
209
+ });
210
+ await Promise.race([Promise.allSettled(finite.map((a) => a.finished)), wait(3000)]);
211
+ await wait(300);
212
+ return document.body ? document.body.scrollHeight : 0;
213
+ })()`;
214
+
215
+ async function shoot(port, job, url, width) {
216
+ const page = await openPage(port);
217
+ try {
218
+ // A cookie and not `?key=` or a header: the page is the agent's, and a script can read
219
+ // its own URL, while extra headers would go to every CDN it loads from too.
220
+ if (job.token) {
221
+ await page.send("Network.setCookie", {
222
+ name: "mockpit_key",
223
+ value: job.token,
224
+ url,
225
+ httpOnly: true,
226
+ sameSite: "Lax",
227
+ });
228
+ }
229
+ await page.send("Emulation.setDeviceMetricsOverride", {
230
+ width,
231
+ height: VIEWPORT_H,
232
+ deviceScaleFactor: 1,
233
+ mobile: false,
234
+ });
235
+ await page.send("Page.enable");
236
+ await page.send("Runtime.enable");
237
+ const loaded = page.event("Page.loadEventFired");
238
+ await page.send("Page.navigate", { url });
239
+ await Promise.race([loaded, sleep(LOAD_MS)]);
240
+ const measured = await page.send("Runtime.evaluate", {
241
+ expression: SETTLE,
242
+ awaitPromise: true,
243
+ returnByValue: true,
244
+ });
245
+ const height = Math.min(
246
+ Math.max(Number(measured.result?.value) || VIEWPORT_H, 1),
247
+ width * MAX_HEIGHT_PER_WIDTH,
248
+ );
249
+ const { data } = await page.send("Page.captureScreenshot", {
250
+ format: "jpeg",
251
+ quality: JPEG_QUALITY,
252
+ captureBeyondViewport: true,
253
+ clip: { x: 0, y: 0, width, height, scale: 1 },
254
+ });
255
+ return data;
256
+ } finally {
257
+ await page.close();
258
+ }
259
+ }
260
+
261
+ // --- one job, end to end ---
262
+
263
+ const headersOf = (job) => ({
264
+ "content-type": "application/json",
265
+ "x-mockpit-client": job.client,
266
+ ...(job.token ? { authorization: `Bearer ${job.token}` } : {}),
267
+ });
268
+
269
+ // A retake becomes one job per live variant whose page shows no still now: the views
270
+ // already leave out one taken under another revision, and keep the agent's own.
271
+ async function expandRetake(dir, job) {
272
+ const res = await fetch(`${job.base}/api/projects/${encodeURIComponent(job.project)}/tree`, {
273
+ headers: headersOf(job),
274
+ });
275
+ if (!res.ok) return;
276
+ const { tree } = await res.json();
277
+ const wanted = (tree ?? [])
278
+ .filter((d) => !d.archivedAt)
279
+ .flatMap((d) =>
280
+ (d.variants ?? [])
281
+ .filter((v) => v.status !== "archived" && v.surface >= 0 && !v.poster)
282
+ .map((v) => ({ design: d.id, postId: v.postId, version: v.version, at: v.updatedAt })),
283
+ )
284
+ .sort((a, b) => String(b.at).localeCompare(String(a.at)))
285
+ .slice(0, RETAKE_MAX);
286
+ const { retake: _r, project: _p, ...base } = job;
287
+ for (const [i, v] of wanted.entries()) {
288
+ const name = `${Date.now()}-${String(i).padStart(3, "0")}-retake.job`;
289
+ writeFileSync(
290
+ join(dir, name),
291
+ JSON.stringify({
292
+ ...base,
293
+ design: v.design,
294
+ postId: v.postId,
295
+ version: v.version,
296
+ queuedAt: Date.now(),
297
+ }),
298
+ { mode: 0o600 },
299
+ );
300
+ }
301
+ }
302
+
303
+ async function capture(port, job) {
304
+ const headers = headersOf(job);
305
+ const get = async (path) => {
306
+ const res = await fetch(`${job.base}${path}`, { headers });
307
+ if (!res.ok) throw new Error(`${path}: ${res.status}`);
308
+ return res.json();
309
+ };
310
+ const post = async (path, body) => {
311
+ const res = await fetch(`${job.base}${path}`, {
312
+ method: "POST",
313
+ headers,
314
+ body: JSON.stringify(body),
315
+ });
316
+ if (!res.ok) throw new Error(`${path}: ${res.status}`);
317
+ return res.json();
318
+ };
319
+ const design = await get(`/api/designs/${encodeURIComponent(job.design)}`);
320
+ const variant = design.variants?.find((v) => v.postId === job.postId);
321
+ const page = variant?.surfaces?.find((s) => s.kind === "html");
322
+ // Moved on already, or the agent's own still arrived: nothing to add.
323
+ const { mode } = await get("/api/theme");
324
+ if (!page || page.poster || variant.version !== job.version) return;
325
+ const rewrites = variant.rewrites ?? 0;
326
+ const query = new URLSearchParams({
327
+ surface: String(page.index),
328
+ ver: String(job.version),
329
+ ...(mode ? { mode } : {}),
330
+ ...(rewrites ? { w: String(rewrites) } : {}),
331
+ ...(design.systemRev ? { y: String(design.systemRev) } : {}),
332
+ });
333
+ const width = design.widths?.[0] ?? DEFAULT_WIDTH;
334
+ const data = await shoot(
335
+ port,
336
+ job,
337
+ `${job.base}/s/${encodeURIComponent(job.postId)}?${query}`,
338
+ width,
339
+ );
340
+ const asset = await post("/api/assets", {
341
+ data,
342
+ contentType: "image/jpeg",
343
+ filename: "poster.jpg",
344
+ session: job.session,
345
+ });
346
+ await post(
347
+ `/api/designs/${encodeURIComponent(job.design)}/variants/${encodeURIComponent(job.postId)}/poster`,
348
+ {
349
+ version: job.version,
350
+ rewrites,
351
+ poster: asset.id,
352
+ mode,
353
+ system: design.systemRev ?? 0,
354
+ session: job.session,
355
+ },
356
+ );
357
+ }
358
+
359
+ async function work() {
360
+ const dir = queueDir();
361
+ if (lockIsFresh(dir)) return;
362
+ try {
363
+ rmdirSync(lockPath(dir));
364
+ } catch {
365
+ // Not there, which is the usual case.
366
+ }
367
+ try {
368
+ mkdirSync(lockPath(dir));
369
+ } catch {
370
+ return; // Another worker got there first.
371
+ }
372
+ const path = findBrowser();
373
+ let browser = null;
374
+ const done = () => {
375
+ browser?.stop();
376
+ try {
377
+ rmdirSync(lockPath(dir));
378
+ } catch {
379
+ // Already gone; nothing depends on it.
380
+ }
381
+ };
382
+ setTimeout(() => {
383
+ done();
384
+ setTimeout(() => process.exit(0), 600);
385
+ }, WORKER_MAX_MS).unref();
386
+
387
+ let idleSince = Date.now();
388
+ for (;;) {
389
+ const name = jobsIn(dir)[0];
390
+ if (!name) {
391
+ if (Date.now() - idleSince > WORKER_IDLE_MS) break;
392
+ await sleep(100);
393
+ continue;
394
+ }
395
+ idleSince = Date.now();
396
+ utimesSync(lockPath(dir), new Date(), new Date());
397
+ const file = join(dir, name);
398
+ let job;
399
+ try {
400
+ job = JSON.parse(readFileSync(file, "utf8"));
401
+ } catch {
402
+ job = null;
403
+ }
404
+ rmSync(file, { force: true });
405
+ if (!job || !path || Date.now() - job.queuedAt > JOB_MAX_AGE_MS) continue;
406
+ if (job.retake) {
407
+ await expandRetake(dir, job).catch(() => {});
408
+ continue;
409
+ }
410
+ try {
411
+ if (!browser?.alive()) browser = await launchBrowser(path);
412
+ if (browser) {
413
+ await Promise.race([
414
+ capture(browser.port, job),
415
+ sleep(CAPTURE_MS).then(() => {
416
+ throw new Error("timed out");
417
+ }),
418
+ ]);
419
+ }
420
+ } catch {
421
+ // This page stays live; the next one still gets its try.
422
+ }
423
+ idleSince = Date.now();
424
+ }
425
+ done();
426
+ // A publish that saw this worker's lock in its last moment still gets its poster.
427
+ if (jobsIn(dir).length) startWorker();
428
+ await sleep(600);
429
+ process.exit(0);
430
+ }
431
+
432
+ if (process.argv[1] === SELF && process.argv[2] === "--work") {
433
+ work().catch(() => process.exit(0));
434
+ }
@@ -1,8 +1,16 @@
1
1
  #!/usr/bin/env node
2
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
3
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
4
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
5
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
6
+ });
7
+ }
8
+ return path;
9
+ };
2
10
  import { execFileSync } from "node:child_process";
3
- import { readFileSync, statSync } from "node:fs";
11
+ import { existsSync, readFileSync, statSync } from "node:fs";
4
12
  import { dirname, join } from "node:path";
5
- import { fileURLToPath } from "node:url";
13
+ import { fileURLToPath, pathToFileURL } from "node:url";
6
14
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
7
15
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
8
16
  import { ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
@@ -12,7 +20,7 @@ import { RUN_INSTRUCTIONS } from "../server/runApi.js";
12
20
  const API = process.env.MOCKPIT_URL ?? "http://localhost:8228";
13
21
  const TOKEN = process.env.MOCKPIT_TOKEN;
14
22
  const AGENT = process.env.MOCKPIT_AGENT ?? "claude-code";
15
- // "code" serves the codemode catalog: one `run` tool instead of the mock tools.
23
+ // "code" serves the codemode catalog: one `run` tool instead of the design tools.
16
24
  const CODE_MODE = process.env.MOCKPIT_MCP_MODE === "code";
17
25
  // Names this client in the server's event log; the version is how a stdio
18
26
  // server left running across an upgrade shows up there.
@@ -31,6 +39,14 @@ function packageVersion() {
31
39
  }
32
40
  return "unknown";
33
41
  }
42
+ const posters = (async () => {
43
+ for (const rel of ["../bin/poster.js", "../../bin/poster.js"]) {
44
+ const path = join(dirname(fileURLToPath(import.meta.url)), rel);
45
+ if (existsSync(path))
46
+ return (await import(__rewriteRelativeImportExtension(pathToFileURL(path).href)));
47
+ }
48
+ return null;
49
+ })().catch(() => null);
34
50
  async function api(path, init = {}) {
35
51
  const headers = {
36
52
  "content-type": "application/json",
@@ -52,6 +68,22 @@ async function api(path, init = {}) {
52
68
  }
53
69
  // Stdio runs on the agent's machine, so a value may name a file: markup and
54
70
  // asset bytes never have to travel through the model's context.
71
+ // A sheet is CSS text when it has a block, else a path: a mistyped path must fail
72
+ // rather than be sent as the project's stylesheet.
73
+ function readSheet(value) {
74
+ if (value === "" || value.includes("{"))
75
+ return value;
76
+ let stat;
77
+ try {
78
+ stat = statSync(value);
79
+ }
80
+ catch {
81
+ throw new Error(`sheet: no file at ${value} (pass CSS text, or a path to a .css file)`);
82
+ }
83
+ if (!stat.isFile())
84
+ throw new Error(`sheet: ${value} is not a file`);
85
+ return readFileSync(value, "utf8");
86
+ }
55
87
  function readMaybeFile(value) {
56
88
  if (value.includes("\n") || value.length > 512)
57
89
  return value;
@@ -123,9 +155,21 @@ const query = (params) => {
123
155
  let sessionId = process.env.MOCKPIT_SESSION ?? null;
124
156
  // A resumed Claude Code conversation spawns a new MCP process; its session id
125
157
  // survives the resume, so keying on it hands back the same mockpit session.
126
- const harnessKey = process.env.CLAUDE_CODE_SESSION_ID
158
+ let harnessKey = process.env.CLAUDE_CODE_SESSION_ID
127
159
  ? `claude-code:${process.env.CLAUDE_CODE_SESSION_ID}`
128
160
  : undefined;
161
+ // Codex gives a stdio server no conversation id in its environment, only on each
162
+ // call, and the key must be right before the call that creates the session.
163
+ function noteCaller(extra) {
164
+ const metadata = extra._meta?.["x-codex-turn-metadata"];
165
+ if (typeof metadata?.thread_id !== "string")
166
+ return;
167
+ const key = `codex:${metadata.thread_id}`;
168
+ if (key === harnessKey)
169
+ return;
170
+ harnessKey = key;
171
+ sessionId = process.env.MOCKPIT_SESSION ?? null;
172
+ }
129
173
  // `title` is used only when this call creates the session — once one exists
130
174
  // (here or in the viewer, where the user can rename it) it is never retitled.
131
175
  async function ensureSession(title) {
@@ -172,7 +216,8 @@ const handlers = {
172
216
  async publish(args) {
173
217
  const session = await ensureSession(args.sessionTitle);
174
218
  const html = typeof args.html === "string" ? readMaybeFile(args.html) : undefined;
175
- return post("/api/mocks", {
219
+ const taking = args.poster === undefined ? await posters : null;
220
+ const result = await post("/api/designs", {
176
221
  ...args,
177
222
  html,
178
223
  parts: readPartFiles(args.parts),
@@ -181,25 +226,42 @@ const handlers = {
181
226
  session,
182
227
  agent: AGENT,
183
228
  project: resolveProject(args.project),
229
+ ...(taking?.findBrowser() ? { posterComing: true } : {}),
184
230
  });
231
+ if (taking &&
232
+ result.post?.surfaces?.some((s) => s.kind === "html") &&
233
+ !result.withdrawn) {
234
+ taking.queuePoster({
235
+ base: API.replace(/\/$/, ""),
236
+ token: TOKEN,
237
+ client: CLIENT,
238
+ session,
239
+ design: result.design.id,
240
+ postId: result.post.id,
241
+ version: result.post.version,
242
+ });
243
+ }
244
+ return result;
185
245
  },
186
246
  async ask(args) {
187
247
  const session = await ensureSession();
188
- return post(`/api/mocks/${enc(args.mock)}/asks`, {
248
+ return post(`/api/designs/${enc(args.design)}/asks`, {
189
249
  ...args,
190
250
  session,
191
251
  project: resolveProject(args.project),
192
252
  });
193
253
  },
194
- read: (args) => json(args.mock === undefined && args.tree === true
195
- ? `/api/projects/${enc(resolveProject(args.project))}/tree`
196
- : args.mock === undefined
197
- ? `/api/mocks${query({ project: resolveProject(args.project) })}`
198
- : `/api/mocks/${enc(args.mock)}${query({
199
- project: resolveProject(args.project),
200
- body: args.body,
201
- history: args.history,
202
- })}`),
254
+ read: (args) => json(args.decisions === true
255
+ ? `/api/projects/${enc(resolveProject(args.project))}/decisions`
256
+ : args.design === undefined && args.tree === true
257
+ ? `/api/projects/${enc(resolveProject(args.project))}/tree`
258
+ : args.design === undefined
259
+ ? `/api/designs${query({ project: resolveProject(args.project) })}`
260
+ : `/api/designs/${enc(args.design)}${query({
261
+ project: resolveProject(args.project),
262
+ body: args.body,
263
+ history: args.history,
264
+ })}`),
203
265
  // Never waits: the server reads from the session's cursor, shared with
204
266
  // piggyback and `mockpit watch`, so each Send is delivered exactly once.
205
267
  async feedback(args) {
@@ -208,7 +270,7 @@ const handlers = {
208
270
  },
209
271
  async say(args) {
210
272
  const session = await ensureSession();
211
- return post(`/api/mocks/${enc(args.mock)}/say`, {
273
+ return post(`/api/designs/${enc(args.design)}/say`, {
212
274
  state: args.state,
213
275
  variant: args.variant,
214
276
  project: resolveProject(args.project),
@@ -216,25 +278,57 @@ const handlers = {
216
278
  message: args.message,
217
279
  });
218
280
  },
281
+ async move(args) {
282
+ const session = await ensureSession();
283
+ return post(`/api/designs/${enc(args.design)}/move`, {
284
+ ...args,
285
+ project: resolveProject(args.project),
286
+ session,
287
+ });
288
+ },
219
289
  async edit(args) {
220
290
  const session = await ensureSession();
221
291
  const project = resolveProject(args.project);
222
292
  if (args.rename !== undefined || args.areaOrder !== undefined) {
223
- if (args.mock !== undefined) {
224
- throw new Error('"rename" and "areaOrder" edit the project; edit the mock without them');
293
+ if (args.design !== undefined) {
294
+ throw new Error('"rename" and "areaOrder" edit the project; edit the design without them');
225
295
  }
226
296
  return post(`/api/projects/${enc(project)}`, { name: args.rename, areaOrder: args.areaOrder, session }, "PATCH");
227
297
  }
228
- if (typeof args.mock !== "string" || !args.mock) {
229
- throw new Error('provide "mock", or "rename" / "areaOrder" to edit the project');
298
+ if (typeof args.design !== "string" || !args.design) {
299
+ throw new Error('provide "design", or "rename" / "areaOrder" to edit the project');
230
300
  }
231
- return post(`/api/mocks/${enc(args.mock)}`, { ...args, project, session }, "PATCH");
301
+ return post(`/api/designs/${enc(args.design)}`, { ...args, project, session }, "PATCH");
232
302
  },
233
- export: (args) => json(`/api/mocks/${enc(args.mock)}/export${query({
234
- project: resolveProject(args.project),
235
- state: args.state,
236
- variant: args.variant,
237
- })}`),
303
+ async system(args) {
304
+ const session = await ensureSession();
305
+ const { project: named, ...rest } = args;
306
+ const project = resolveProject(named);
307
+ const writes = Object.keys(rest).filter((k) => rest[k] !== undefined);
308
+ if (writes.length === 0)
309
+ return json(`/api/projects/${enc(project)}/system`);
310
+ const body = typeof rest.sheet === "string" ? { ...rest, sheet: readSheet(rest.sheet) } : rest;
311
+ const result = await post(`/api/projects/${enc(project)}/system`, { ...body, session }, "PUT");
312
+ // Every page looks different now: its stills are taken again in the background.
313
+ const taking = result?.changed?.length ? await posters : null;
314
+ if (taking) {
315
+ taking.queueRetakes({
316
+ base: API.replace(/\/$/, ""),
317
+ token: TOKEN,
318
+ client: CLIENT,
319
+ session,
320
+ project,
321
+ });
322
+ }
323
+ return result;
324
+ },
325
+ export: (args) => args.design === undefined
326
+ ? json(`/api/projects/${enc(resolveProject(args.project))}/system/export`)
327
+ : json(`/api/designs/${enc(args.design)}/export${query({
328
+ project: resolveProject(args.project),
329
+ state: args.state,
330
+ variant: args.variant,
331
+ })}`),
238
332
  async upload({ path, data, contentType, filename, kind }) {
239
333
  const session = await ensureSession();
240
334
  // Stdio shares the agent's filesystem, so a path beats base64 in context.
@@ -250,7 +344,9 @@ const handlers = {
250
344
  // agent never restates them; a topic is the same text on every tier.
251
345
  guide: (args) => api(`/agent-howto${query(args.topic === undefined
252
346
  ? { project: resolveProject(args.project) }
253
- : { topic: args.topic })}`),
347
+ : args.topic === "system"
348
+ ? { topic: args.topic, project: resolveProject(args.project) }
349
+ : { topic: args.topic })}`),
254
350
  };
255
351
  // The script travels as a file path when it can, so it is never JSON-escaped
256
352
  // into the model's tool call.
@@ -284,7 +380,8 @@ const server = new McpServer(MCP_SERVER_INFO, {
284
380
  });
285
381
  if (CODE_MODE) {
286
382
  const [{ name, ...config }] = STDIO_RUN_TOOLS;
287
- server.registerTool(name, config, async (args) => {
383
+ server.registerTool(name, config, async (args, extra) => {
384
+ noteCaller(extra);
288
385
  const envelope = await run(args);
289
386
  return withFeedbackImages(runToolResult(envelope), envelope, loadImage);
290
387
  });
@@ -293,7 +390,8 @@ else {
293
390
  for (const tool of STDIO_MCP_TOOLS) {
294
391
  const handler = handlers[tool.name];
295
392
  const { name, ...config } = tool;
296
- server.registerTool(name, config, async (args) => {
393
+ server.registerTool(name, config, async (args, extra) => {
394
+ noteCaller(extra);
297
395
  const value = await handler(args);
298
396
  return withFeedbackImages(toolResult(name, value), value, loadImage);
299
397
  });