mockpit 1.3.0 → 3.0.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 (77) hide show
  1. package/README.md +66 -202
  2. package/bin/demoData.js +5 -5
  3. package/bin/initDesign.js +6 -6
  4. package/bin/mockpit.js +687 -192
  5. package/bin/poster.js +377 -0
  6. package/dist/mcp/server.js +110 -19
  7. package/dist/server/answerAdditions.d.ts +7 -0
  8. package/dist/server/answerAdditions.js +76 -0
  9. package/dist/server/apiViews.d.ts +563 -41
  10. package/dist/server/apiViews.js +223 -74
  11. package/dist/server/app.d.ts +10 -2
  12. package/dist/server/app.js +3284 -782
  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 +638 -0
  18. package/dist/server/codeWorker.js +2 -1
  19. package/dist/server/decisionList.d.ts +45 -0
  20. package/dist/server/decisionList.js +302 -0
  21. package/dist/server/decisions.d.ts +8 -8
  22. package/dist/server/decisions.js +18 -17
  23. package/dist/server/designGuide.d.ts +10 -6
  24. package/dist/server/designGuide.js +173 -78
  25. package/dist/server/embeds.d.ts +4 -1
  26. package/dist/server/embeds.js +57 -6
  27. package/dist/server/eventLog.d.ts +2 -2
  28. package/dist/server/eventLog.js +11 -11
  29. package/dist/server/events.d.ts +9 -6
  30. package/dist/server/feedbackBatch.d.ts +38 -16
  31. package/dist/server/feedbackBatch.js +121 -50
  32. package/dist/server/fills.d.ts +4 -0
  33. package/dist/server/fills.js +55 -1
  34. package/dist/server/guidance.d.ts +23 -0
  35. package/dist/server/guidance.js +71 -0
  36. package/dist/server/handoff.d.ts +4 -1
  37. package/dist/server/handoff.js +8 -2
  38. package/dist/server/icons.js +1 -1
  39. package/dist/server/index.js +49 -11
  40. package/dist/server/jump.d.ts +21 -0
  41. package/dist/server/jump.js +175 -0
  42. package/dist/server/knobs.d.ts +3 -3
  43. package/dist/server/knobs.js +6 -6
  44. package/dist/server/mcpHttp.d.ts +8 -1
  45. package/dist/server/mcpHttp.js +44 -7
  46. package/dist/server/mcpSpec.d.ts +2 -1
  47. package/dist/server/mcpSpec.js +172 -50
  48. package/dist/server/parts.js +9 -1
  49. package/dist/server/postSurfaces.js +3 -0
  50. package/dist/server/run.d.ts +1 -1
  51. package/dist/server/run.js +31 -17
  52. package/dist/server/runApi.d.ts +3 -3
  53. package/dist/server/runApi.js +34 -24
  54. package/dist/server/sqlStore.d.ts +117 -94
  55. package/dist/server/sqlStore.js +1089 -141
  56. package/dist/server/standings.d.ts +25 -0
  57. package/dist/server/standings.js +113 -0
  58. package/dist/server/surfacePage.d.ts +6 -9
  59. package/dist/server/surfacePage.js +361 -86
  60. package/dist/server/types.d.ts +252 -69
  61. package/dist/server/types.js +87 -69
  62. package/extensions/mockpit.js +245 -65
  63. package/guide/AGENT_SETUP.md +13 -1
  64. package/guide/packs/design-craft.md +34 -0
  65. package/guide/topics/asks.md +117 -69
  66. package/guide/topics/feedback.md +156 -119
  67. package/guide/topics/html.md +19 -32
  68. package/guide/topics/http.md +43 -40
  69. package/guide/topics/knobs.md +7 -10
  70. package/guide/topics/scripts.md +24 -35
  71. package/guide/topics/surfaces.md +50 -41
  72. package/opencode/mockpit.js +120 -0
  73. package/opencode/package.json +8 -0
  74. package/package.json +4 -6
  75. package/skills/launch-video/SKILL.md +12 -12
  76. package/skills/mockpit/SKILL.md +35 -23
  77. package/viewer/dist/index.html +9 -8
package/bin/poster.js ADDED
@@ -0,0 +1,377 @@
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.
12
+
13
+ import { spawn } from "node:child_process";
14
+ import {
15
+ existsSync,
16
+ mkdirSync,
17
+ mkdtempSync,
18
+ readdirSync,
19
+ readFileSync,
20
+ rmdirSync,
21
+ rmSync,
22
+ statSync,
23
+ utimesSync,
24
+ writeFileSync,
25
+ } from "node:fs";
26
+ import { tmpdir, userInfo } from "node:os";
27
+ import { delimiter, join } from "node:path";
28
+ import { fileURLToPath } from "node:url";
29
+
30
+ const SELF = fileURLToPath(import.meta.url);
31
+
32
+ // One page's whole capture. A page that hasn't drawn by then isn't worth a browser's time.
33
+ const CAPTURE_MS = 15_000;
34
+ const LOAD_MS = 8_000;
35
+ // A job this old describes a burst long past, and its version has likely moved on.
36
+ const JOB_MAX_AGE_MS = 2 * 60_000;
37
+ // Stays up briefly for the next publish of a burst, which saves starting a browser again.
38
+ const WORKER_IDLE_MS = 3_000;
39
+ const WORKER_MAX_MS = 10 * 60_000;
40
+ // A lock not touched for longer than the longest capture belongs to a worker that died.
41
+ const LOCK_STALE_MS = CAPTURE_MS * 2;
42
+ const BROWSER_START_MS = 10_000;
43
+ // Overviews show a page small and whole; past this its top is what can be read anyway.
44
+ const MAX_HEIGHT_PER_WIDTH = 3;
45
+ const DEFAULT_WIDTH = 820;
46
+ const VIEWPORT_H = 800;
47
+ const JPEG_QUALITY = 80;
48
+
49
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
50
+
51
+ function browserCandidates() {
52
+ if (process.platform === "darwin") {
53
+ return [
54
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
55
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
56
+ ];
57
+ }
58
+ if (process.platform === "win32") {
59
+ return [process.env.PROGRAMFILES, process.env["PROGRAMFILES(X86)"], process.env.LOCALAPPDATA]
60
+ .filter(Boolean)
61
+ .map((root) => join(root, "Google", "Chrome", "Application", "chrome.exe"));
62
+ }
63
+ const names = ["google-chrome", "google-chrome-stable", "chromium", "chromium-browser"];
64
+ const dirs = (process.env.PATH ?? "").split(delimiter).filter(Boolean);
65
+ return dirs.flatMap((dir) => names.map((name) => join(dir, name)));
66
+ }
67
+
68
+ // CHROME_PATH names the browser. Set to anything that isn't one, it means there is none.
69
+ export function findBrowser() {
70
+ const chosen = process.env.CHROME_PATH;
71
+ if (chosen !== undefined) return chosen && existsSync(chosen) ? chosen : null;
72
+ return browserCandidates().find((path) => existsSync(path)) ?? null;
73
+ }
74
+
75
+ // --- the queue: a private directory, since a job holds the workspace's token ---
76
+
77
+ function queueDir() {
78
+ const who = process.getuid?.() ?? userInfo().username;
79
+ const dir = join(tmpdir(), `mockpit-posters-${who}`);
80
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
81
+ return dir;
82
+ }
83
+ const lockPath = (dir) => join(dir, "worker.lock");
84
+ const lockIsFresh = (dir) => {
85
+ try {
86
+ return Date.now() - statSync(lockPath(dir)).mtimeMs < LOCK_STALE_MS;
87
+ } catch {
88
+ return false;
89
+ }
90
+ };
91
+ const jobsIn = (dir) =>
92
+ readdirSync(dir)
93
+ .filter((name) => name.endsWith(".job"))
94
+ .sort();
95
+
96
+ const startWorker = () =>
97
+ spawn(process.execPath, [SELF, "--work"], { detached: true, stdio: "ignore" }).unref();
98
+
99
+ // { base, token, client, session, design, postId, version }. Never throws, never waits.
100
+ export function queuePoster(job) {
101
+ try {
102
+ if (!findBrowser()) return;
103
+ const dir = queueDir();
104
+ const name = `${Date.now()}-${process.pid}-${Math.random().toString(36).slice(2)}.job`;
105
+ writeFileSync(join(dir, name), JSON.stringify({ ...job, queuedAt: Date.now() }), {
106
+ mode: 0o600,
107
+ });
108
+ if (!lockIsFresh(dir)) startWorker();
109
+ } catch {
110
+ // A poster is never worth a failed publish.
111
+ }
112
+ }
113
+
114
+ // --- the browser, over the DevTools protocol on a local port ---
115
+
116
+ async function launchBrowser(path) {
117
+ const profile = mkdtempSync(join(tmpdir(), "mockpit-poster-profile-"));
118
+ const proc = spawn(
119
+ path,
120
+ [
121
+ "--headless=new",
122
+ "--remote-debugging-port=0",
123
+ `--user-data-dir=${profile}`,
124
+ "--no-first-run",
125
+ "--no-default-browser-check",
126
+ "--disable-extensions",
127
+ "--disable-background-networking",
128
+ "--disable-sync",
129
+ "--hide-scrollbars",
130
+ "--mute-audio",
131
+ // Never a keychain prompt on the user's screen for a profile thrown away after.
132
+ "--use-mock-keychain",
133
+ // A machine with no GPU still draws a WebGL page, the kind that most needs a poster.
134
+ "--enable-unsafe-swiftshader",
135
+ "about:blank",
136
+ ],
137
+ { stdio: "ignore" },
138
+ );
139
+ const stop = () => {
140
+ proc.kill();
141
+ // The browser is still writing to its profile for a moment after the signal.
142
+ setTimeout(() => rmSync(profile, { recursive: true, force: true }), 500);
143
+ };
144
+ const portFile = join(profile, "DevToolsActivePort");
145
+ const giveUp = Date.now() + BROWSER_START_MS;
146
+ while (!existsSync(portFile) || !readFileSync(portFile, "utf8").includes("\n")) {
147
+ if (proc.exitCode !== null || Date.now() > giveUp) {
148
+ stop();
149
+ return null;
150
+ }
151
+ await sleep(50);
152
+ }
153
+ const port = Number(readFileSync(portFile, "utf8").split("\n")[0]);
154
+ return { port, stop, alive: () => proc.exitCode === null };
155
+ }
156
+
157
+ async function openPage(port) {
158
+ const res = await fetch(`http://127.0.0.1:${port}/json/new?about:blank`, { method: "PUT" });
159
+ const target = await res.json();
160
+ const ws = new WebSocket(target.webSocketDebuggerUrl);
161
+ await new Promise((resolve, reject) => {
162
+ ws.onopen = resolve;
163
+ ws.onerror = reject;
164
+ });
165
+ let nextId = 0;
166
+ const pending = new Map();
167
+ const waiting = new Map();
168
+ ws.onmessage = (e) => {
169
+ const m = JSON.parse(e.data);
170
+ if (m.id !== undefined) {
171
+ const call = pending.get(m.id);
172
+ pending.delete(m.id);
173
+ if (m.error) call?.reject(new Error(m.error.message));
174
+ else call?.resolve(m.result);
175
+ } else waiting.get(m.method)?.();
176
+ };
177
+ return {
178
+ send: (method, params = {}) =>
179
+ new Promise((resolve, reject) => {
180
+ const id = ++nextId;
181
+ pending.set(id, { resolve, reject });
182
+ ws.send(JSON.stringify({ id, method, params }));
183
+ }),
184
+ event: (method) => new Promise((resolve) => waiting.set(method, resolve)),
185
+ close: async () => {
186
+ ws.close();
187
+ await fetch(`http://127.0.0.1:${port}/json/close/${target.id}`).catch(() => {});
188
+ },
189
+ };
190
+ }
191
+
192
+ // Run in the page. Waits for what's drawing itself in and doesn't wait for what loops.
193
+ const SETTLE = `(async () => {
194
+ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
195
+ await Promise.race([document.fonts ? document.fonts.ready : 0, wait(2000)]);
196
+ await wait(400);
197
+ const finite = document.getAnimations().filter((a) => {
198
+ const t = a.effect && a.effect.getComputedTiming();
199
+ return t && Number.isFinite(t.endTime) && a.playState === "running";
200
+ });
201
+ await Promise.race([Promise.allSettled(finite.map((a) => a.finished)), wait(3000)]);
202
+ await wait(300);
203
+ return document.body ? document.body.scrollHeight : 0;
204
+ })()`;
205
+
206
+ async function shoot(port, job, url, width) {
207
+ const page = await openPage(port);
208
+ try {
209
+ // A cookie and not `?key=` or a header: the page is the agent's, and a script can read
210
+ // its own URL, while extra headers would go to every CDN it loads from too.
211
+ if (job.token) {
212
+ await page.send("Network.setCookie", {
213
+ name: "mockpit_key",
214
+ value: job.token,
215
+ url,
216
+ httpOnly: true,
217
+ sameSite: "Lax",
218
+ });
219
+ }
220
+ await page.send("Emulation.setDeviceMetricsOverride", {
221
+ width,
222
+ height: VIEWPORT_H,
223
+ deviceScaleFactor: 1,
224
+ mobile: false,
225
+ });
226
+ await page.send("Page.enable");
227
+ await page.send("Runtime.enable");
228
+ const loaded = page.event("Page.loadEventFired");
229
+ await page.send("Page.navigate", { url });
230
+ await Promise.race([loaded, sleep(LOAD_MS)]);
231
+ const measured = await page.send("Runtime.evaluate", {
232
+ expression: SETTLE,
233
+ awaitPromise: true,
234
+ returnByValue: true,
235
+ });
236
+ const height = Math.min(
237
+ Math.max(Number(measured.result?.value) || VIEWPORT_H, 1),
238
+ width * MAX_HEIGHT_PER_WIDTH,
239
+ );
240
+ const { data } = await page.send("Page.captureScreenshot", {
241
+ format: "jpeg",
242
+ quality: JPEG_QUALITY,
243
+ captureBeyondViewport: true,
244
+ clip: { x: 0, y: 0, width, height, scale: 1 },
245
+ });
246
+ return data;
247
+ } finally {
248
+ await page.close();
249
+ }
250
+ }
251
+
252
+ // --- one job, end to end ---
253
+
254
+ async function capture(port, job) {
255
+ const headers = {
256
+ "content-type": "application/json",
257
+ "x-mockpit-client": job.client,
258
+ ...(job.token ? { authorization: `Bearer ${job.token}` } : {}),
259
+ };
260
+ const get = async (path) => {
261
+ const res = await fetch(`${job.base}${path}`, { headers });
262
+ if (!res.ok) throw new Error(`${path}: ${res.status}`);
263
+ return res.json();
264
+ };
265
+ const post = async (path, body) => {
266
+ const res = await fetch(`${job.base}${path}`, {
267
+ method: "POST",
268
+ headers,
269
+ body: JSON.stringify(body),
270
+ });
271
+ if (!res.ok) throw new Error(`${path}: ${res.status}`);
272
+ return res.json();
273
+ };
274
+ const design = await get(`/api/designs/${encodeURIComponent(job.design)}`);
275
+ const variant = design.variants?.find((v) => v.postId === job.postId);
276
+ const page = variant?.surfaces?.find((s) => s.kind === "html");
277
+ // Moved on already, or the agent's own still arrived: nothing to add.
278
+ const { mode } = await get("/api/theme");
279
+ if (!page || page.poster || variant.version !== job.version) return;
280
+ const rewrites = variant.rewrites ?? 0;
281
+ const query = new URLSearchParams({
282
+ surface: String(page.index),
283
+ ver: String(job.version),
284
+ ...(mode ? { mode } : {}),
285
+ ...(rewrites ? { w: String(rewrites) } : {}),
286
+ });
287
+ const width = design.widths?.[0] ?? DEFAULT_WIDTH;
288
+ const data = await shoot(
289
+ port,
290
+ job,
291
+ `${job.base}/s/${encodeURIComponent(job.postId)}?${query}`,
292
+ width,
293
+ );
294
+ const asset = await post("/api/assets", {
295
+ data,
296
+ contentType: "image/jpeg",
297
+ filename: "poster.jpg",
298
+ session: job.session,
299
+ });
300
+ await post(
301
+ `/api/designs/${encodeURIComponent(job.design)}/variants/${encodeURIComponent(job.postId)}/poster`,
302
+ { version: job.version, rewrites, poster: asset.id, mode, session: job.session },
303
+ );
304
+ }
305
+
306
+ async function work() {
307
+ const dir = queueDir();
308
+ if (lockIsFresh(dir)) return;
309
+ try {
310
+ rmdirSync(lockPath(dir));
311
+ } catch {
312
+ // Not there, which is the usual case.
313
+ }
314
+ try {
315
+ mkdirSync(lockPath(dir));
316
+ } catch {
317
+ return; // Another worker got there first.
318
+ }
319
+ const path = findBrowser();
320
+ let browser = null;
321
+ const done = () => {
322
+ browser?.stop();
323
+ try {
324
+ rmdirSync(lockPath(dir));
325
+ } catch {
326
+ // Already gone; nothing depends on it.
327
+ }
328
+ };
329
+ setTimeout(() => {
330
+ done();
331
+ setTimeout(() => process.exit(0), 600);
332
+ }, WORKER_MAX_MS).unref();
333
+
334
+ let idleSince = Date.now();
335
+ for (;;) {
336
+ const name = jobsIn(dir)[0];
337
+ if (!name) {
338
+ if (Date.now() - idleSince > WORKER_IDLE_MS) break;
339
+ await sleep(100);
340
+ continue;
341
+ }
342
+ idleSince = Date.now();
343
+ utimesSync(lockPath(dir), new Date(), new Date());
344
+ const file = join(dir, name);
345
+ let job;
346
+ try {
347
+ job = JSON.parse(readFileSync(file, "utf8"));
348
+ } catch {
349
+ job = null;
350
+ }
351
+ rmSync(file, { force: true });
352
+ if (!job || !path || Date.now() - job.queuedAt > JOB_MAX_AGE_MS) continue;
353
+ try {
354
+ if (!browser?.alive()) browser = await launchBrowser(path);
355
+ if (browser) {
356
+ await Promise.race([
357
+ capture(browser.port, job),
358
+ sleep(CAPTURE_MS).then(() => {
359
+ throw new Error("timed out");
360
+ }),
361
+ ]);
362
+ }
363
+ } catch {
364
+ // This page stays live; the next one still gets its try.
365
+ }
366
+ idleSince = Date.now();
367
+ }
368
+ done();
369
+ // A publish that saw this worker's lock in its last moment still gets its poster.
370
+ if (jobsIn(dir).length) startWorker();
371
+ await sleep(600);
372
+ process.exit(0);
373
+ }
374
+
375
+ if (process.argv[1] === SELF && process.argv[2] === "--work") {
376
+ work().catch(() => process.exit(0));
377
+ }
@@ -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",
@@ -123,9 +139,21 @@ const query = (params) => {
123
139
  let sessionId = process.env.MOCKPIT_SESSION ?? null;
124
140
  // A resumed Claude Code conversation spawns a new MCP process; its session id
125
141
  // survives the resume, so keying on it hands back the same mockpit session.
126
- const harnessKey = process.env.CLAUDE_CODE_SESSION_ID
142
+ let harnessKey = process.env.CLAUDE_CODE_SESSION_ID
127
143
  ? `claude-code:${process.env.CLAUDE_CODE_SESSION_ID}`
128
144
  : undefined;
145
+ // Codex gives a stdio server no conversation id in its environment, only on each
146
+ // call, and the key must be right before the call that creates the session.
147
+ function noteCaller(extra) {
148
+ const metadata = extra._meta?.["x-codex-turn-metadata"];
149
+ if (typeof metadata?.thread_id !== "string")
150
+ return;
151
+ const key = `codex:${metadata.thread_id}`;
152
+ if (key === harnessKey)
153
+ return;
154
+ harnessKey = key;
155
+ sessionId = process.env.MOCKPIT_SESSION ?? null;
156
+ }
129
157
  // `title` is used only when this call creates the session — once one exists
130
158
  // (here or in the viewer, where the user can rename it) it is never retitled.
131
159
  async function ensureSession(title) {
@@ -144,6 +172,26 @@ async function ensureSession(title) {
144
172
  sessionId = session.id;
145
173
  return sessionId;
146
174
  }
175
+ // A poster may name an image file: it travels as an upload, never as bytes in
176
+ // the model's context. Anything else goes through as the asset id it is.
177
+ async function posterId(value, session) {
178
+ if (typeof value !== "string")
179
+ return value;
180
+ try {
181
+ if (!statSync(value).isFile())
182
+ return value;
183
+ }
184
+ catch {
185
+ return value;
186
+ }
187
+ const asset = await post("/api/assets", {
188
+ data: readFileSync(value, "base64"),
189
+ contentType: contentTypeForPath(value),
190
+ filename: value.split(/[\\/]/).pop(),
191
+ session,
192
+ });
193
+ return asset.id;
194
+ }
147
195
  // Every tool is a pass-through to the REST route the CLI uses, mapped the way
148
196
  // server/mcpHttp.ts maps HTTP MCP: same request, same response, so the tiers
149
197
  // cannot drift. Only the session (one per conversation), the project (this
@@ -152,33 +200,52 @@ const handlers = {
152
200
  async publish(args) {
153
201
  const session = await ensureSession(args.sessionTitle);
154
202
  const html = typeof args.html === "string" ? readMaybeFile(args.html) : undefined;
155
- return post("/api/mocks", {
203
+ const taking = args.poster === undefined ? await posters : null;
204
+ const result = await post("/api/designs", {
156
205
  ...args,
157
206
  html,
158
207
  parts: readPartFiles(args.parts),
159
208
  fills: readPartFiles(args.fills),
209
+ poster: await posterId(args.poster, session),
160
210
  session,
161
211
  agent: AGENT,
162
212
  project: resolveProject(args.project),
213
+ ...(taking?.findBrowser() ? { posterComing: true } : {}),
163
214
  });
215
+ if (taking &&
216
+ result.post?.surfaces?.some((s) => s.kind === "html") &&
217
+ !result.withdrawn) {
218
+ taking.queuePoster({
219
+ base: API.replace(/\/$/, ""),
220
+ token: TOKEN,
221
+ client: CLIENT,
222
+ session,
223
+ design: result.design.id,
224
+ postId: result.post.id,
225
+ version: result.post.version,
226
+ });
227
+ }
228
+ return result;
164
229
  },
165
230
  async ask(args) {
166
231
  const session = await ensureSession();
167
- return post(`/api/mocks/${enc(args.mock)}/asks`, {
232
+ return post(`/api/designs/${enc(args.design)}/asks`, {
168
233
  ...args,
169
234
  session,
170
235
  project: resolveProject(args.project),
171
236
  });
172
237
  },
173
- read: (args) => json(args.mock === undefined && args.tree === true
174
- ? `/api/projects/${enc(resolveProject(args.project))}/tree`
175
- : args.mock === undefined
176
- ? `/api/mocks${query({ project: resolveProject(args.project) })}`
177
- : `/api/mocks/${enc(args.mock)}${query({
178
- project: resolveProject(args.project),
179
- body: args.body,
180
- history: args.history,
181
- })}`),
238
+ read: (args) => json(args.decisions === true
239
+ ? `/api/projects/${enc(resolveProject(args.project))}/decisions`
240
+ : args.design === undefined && args.tree === true
241
+ ? `/api/projects/${enc(resolveProject(args.project))}/tree`
242
+ : args.design === undefined
243
+ ? `/api/designs${query({ project: resolveProject(args.project) })}`
244
+ : `/api/designs/${enc(args.design)}${query({
245
+ project: resolveProject(args.project),
246
+ body: args.body,
247
+ history: args.history,
248
+ })}`),
182
249
  // Never waits: the server reads from the session's cursor, shared with
183
250
  // piggyback and `mockpit watch`, so each Send is delivered exactly once.
184
251
  async feedback(args) {
@@ -187,7 +254,7 @@ const handlers = {
187
254
  },
188
255
  async say(args) {
189
256
  const session = await ensureSession();
190
- return post(`/api/mocks/${enc(args.mock)}/say`, {
257
+ return post(`/api/designs/${enc(args.design)}/say`, {
191
258
  state: args.state,
192
259
  variant: args.variant,
193
260
  project: resolveProject(args.project),
@@ -195,7 +262,29 @@ const handlers = {
195
262
  message: args.message,
196
263
  });
197
264
  },
198
- export: (args) => json(`/api/mocks/${enc(args.mock)}/export${query({
265
+ async move(args) {
266
+ const session = await ensureSession();
267
+ return post(`/api/designs/${enc(args.design)}/move`, {
268
+ ...args,
269
+ project: resolveProject(args.project),
270
+ session,
271
+ });
272
+ },
273
+ async edit(args) {
274
+ const session = await ensureSession();
275
+ const project = resolveProject(args.project);
276
+ if (args.rename !== undefined || args.areaOrder !== undefined) {
277
+ if (args.design !== undefined) {
278
+ throw new Error('"rename" and "areaOrder" edit the project; edit the design without them');
279
+ }
280
+ return post(`/api/projects/${enc(project)}`, { name: args.rename, areaOrder: args.areaOrder, session }, "PATCH");
281
+ }
282
+ if (typeof args.design !== "string" || !args.design) {
283
+ throw new Error('provide "design", or "rename" / "areaOrder" to edit the project');
284
+ }
285
+ return post(`/api/designs/${enc(args.design)}`, { ...args, project, session }, "PATCH");
286
+ },
287
+ export: (args) => json(`/api/designs/${enc(args.design)}/export${query({
199
288
  project: resolveProject(args.project),
200
289
  state: args.state,
201
290
  variant: args.variant,
@@ -249,7 +338,8 @@ const server = new McpServer(MCP_SERVER_INFO, {
249
338
  });
250
339
  if (CODE_MODE) {
251
340
  const [{ name, ...config }] = STDIO_RUN_TOOLS;
252
- server.registerTool(name, config, async (args) => {
341
+ server.registerTool(name, config, async (args, extra) => {
342
+ noteCaller(extra);
253
343
  const envelope = await run(args);
254
344
  return withFeedbackImages(runToolResult(envelope), envelope, loadImage);
255
345
  });
@@ -258,7 +348,8 @@ else {
258
348
  for (const tool of STDIO_MCP_TOOLS) {
259
349
  const handler = handlers[tool.name];
260
350
  const { name, ...config } = tool;
261
- server.registerTool(name, config, async (args) => {
351
+ server.registerTool(name, config, async (args, extra) => {
352
+ noteCaller(extra);
262
353
  const value = await handler(args);
263
354
  return withFeedbackImages(toolResult(name, value), value, loadImage);
264
355
  });
@@ -0,0 +1,7 @@
1
+ import type { AnswerAddition } from "./types.ts";
2
+ export declare const MAX_ANSWER_STYLE = 20000;
3
+ export declare const MAX_ANSWER_FILL = 200000;
4
+ export declare const MAX_ANSWER_ADDITIONS = 200;
5
+ export declare function checkAnswerStyle(css: string): string | null;
6
+ export declare function checkAnswerFill(html: string): string | null;
7
+ export declare function withAnswerAdditions(html: string, additions: AnswerAddition[]): string;
@@ -0,0 +1,76 @@
1
+ // What an answer brings so it can draw itself: a little style, or a new fill of one
2
+ // part. It lives on the design, so a version the user approved is never rewritten,
3
+ // and it only takes effect while its value is chosen. Values are new and never
4
+ // redefined, so everything the user has already seen keeps looking the same.
5
+ //
6
+ // String-only and runtime-agnostic, like fills.ts.
7
+ import { addFill, cssString } from "./fills.js";
8
+ export const MAX_ANSWER_STYLE = 20_000;
9
+ export const MAX_ANSWER_FILL = 200_000;
10
+ export const MAX_ANSWER_ADDITIONS = 200;
11
+ // Allowed inside a rule. The rest either escape the value's scope or do nothing there.
12
+ const NESTED_AT_RULES = new Set(["media", "supports", "container"]);
13
+ // The scope is only as good as the braces: a stray `}` would end it and put the rest
14
+ // of the style on every render, breaking the promise above.
15
+ export function checkAnswerStyle(css) {
16
+ if (css.length > MAX_ANSWER_STYLE)
17
+ return `style is over ${MAX_ANSWER_STYLE} characters`;
18
+ if (/<\/style/i.test(css))
19
+ return "style can't contain </style";
20
+ let depth = 0;
21
+ for (let i = 0; i < css.length; i++) {
22
+ const c = css[i];
23
+ if (c === "/" && css[i + 1] === "*") {
24
+ const end = css.indexOf("*/", i + 2);
25
+ if (end < 0)
26
+ return "style has an unclosed comment";
27
+ i = end + 1;
28
+ }
29
+ else if (c === '"' || c === "'") {
30
+ let j = i + 1;
31
+ while (j < css.length && css[j] !== c)
32
+ j += css[j] === "\\" ? 2 : 1;
33
+ if (j >= css.length)
34
+ return "style has an unclosed string";
35
+ i = j;
36
+ }
37
+ else if (c === "{") {
38
+ depth++;
39
+ }
40
+ else if (c === "}") {
41
+ if (--depth < 0)
42
+ return "style has a } with no {";
43
+ }
44
+ else if (c === "@") {
45
+ const name = /^[a-z-]+/i.exec(css.slice(i + 1, i + 40))?.[0].toLowerCase();
46
+ if (!name || !NESTED_AT_RULES.has(name)) {
47
+ return `style can't use @${name ?? ""}: only @media, @supports and @container`;
48
+ }
49
+ }
50
+ }
51
+ return depth === 0 ? null : "style has a { with no }";
52
+ }
53
+ // A fill sits in the document even while it isn't chosen, so anything that acts from
54
+ // there (a script, a stylesheet) would change the page for every other answer too.
55
+ export function checkAnswerFill(html) {
56
+ if (html.length > MAX_ANSWER_FILL)
57
+ return `fill is over ${MAX_ANSWER_FILL} characters`;
58
+ const acting = /<(script|style|link)\b/i.exec(html)?.[1].toLowerCase();
59
+ if (acting) {
60
+ return `a fill can't hold <${acting}>: it would act while unchosen. Use style, or html for a whole page`;
61
+ }
62
+ return null;
63
+ }
64
+ const valueSelector = (a) => `html[data-k-${a.knob.replace(/\./g, "-")}=${cssString(a.value)}]`;
65
+ // A render of any version: its html, with what answers brought laid over it.
66
+ export function withAnswerAdditions(html, additions) {
67
+ if (additions.length === 0)
68
+ return html;
69
+ let out = html;
70
+ for (const a of additions) {
71
+ if (a.fill !== undefined)
72
+ out = addFill(out, a.knob, a.value, a.fill);
73
+ }
74
+ const css = additions.flatMap((a) => a.style === undefined ? [] : [`${valueSelector(a)}{${a.style}}`]);
75
+ return css.length ? `${out}\n<style>${css.join("\n")}</style>` : out;
76
+ }