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.
- package/README.md +66 -202
- package/bin/demoData.js +5 -5
- package/bin/initDesign.js +6 -6
- package/bin/mockpit.js +687 -192
- package/bin/poster.js +377 -0
- package/dist/mcp/server.js +110 -19
- package/dist/server/answerAdditions.d.ts +7 -0
- package/dist/server/answerAdditions.js +76 -0
- package/dist/server/apiViews.d.ts +563 -41
- package/dist/server/apiViews.js +223 -74
- package/dist/server/app.d.ts +10 -2
- package/dist/server/app.js +3284 -782
- package/dist/server/appIcon.js +15 -5
- package/dist/server/appIconPng.js +3 -3
- package/dist/server/askNudges.js +1 -1
- package/dist/server/cdnInline.d.ts +8 -0
- package/dist/server/cdnInline.js +638 -0
- package/dist/server/codeWorker.js +2 -1
- package/dist/server/decisionList.d.ts +45 -0
- package/dist/server/decisionList.js +302 -0
- package/dist/server/decisions.d.ts +8 -8
- package/dist/server/decisions.js +18 -17
- package/dist/server/designGuide.d.ts +10 -6
- package/dist/server/designGuide.js +173 -78
- package/dist/server/embeds.d.ts +4 -1
- package/dist/server/embeds.js +57 -6
- package/dist/server/eventLog.d.ts +2 -2
- package/dist/server/eventLog.js +11 -11
- package/dist/server/events.d.ts +9 -6
- package/dist/server/feedbackBatch.d.ts +38 -16
- package/dist/server/feedbackBatch.js +121 -50
- package/dist/server/fills.d.ts +4 -0
- package/dist/server/fills.js +55 -1
- package/dist/server/guidance.d.ts +23 -0
- package/dist/server/guidance.js +71 -0
- package/dist/server/handoff.d.ts +4 -1
- package/dist/server/handoff.js +8 -2
- package/dist/server/icons.js +1 -1
- package/dist/server/index.js +49 -11
- package/dist/server/jump.d.ts +21 -0
- package/dist/server/jump.js +175 -0
- package/dist/server/knobs.d.ts +3 -3
- package/dist/server/knobs.js +6 -6
- package/dist/server/mcpHttp.d.ts +8 -1
- package/dist/server/mcpHttp.js +44 -7
- package/dist/server/mcpSpec.d.ts +2 -1
- package/dist/server/mcpSpec.js +172 -50
- package/dist/server/parts.js +9 -1
- package/dist/server/postSurfaces.js +3 -0
- package/dist/server/run.d.ts +1 -1
- package/dist/server/run.js +31 -17
- package/dist/server/runApi.d.ts +3 -3
- package/dist/server/runApi.js +34 -24
- package/dist/server/sqlStore.d.ts +117 -94
- package/dist/server/sqlStore.js +1089 -141
- package/dist/server/standings.d.ts +25 -0
- package/dist/server/standings.js +113 -0
- package/dist/server/surfacePage.d.ts +6 -9
- package/dist/server/surfacePage.js +361 -86
- package/dist/server/types.d.ts +252 -69
- package/dist/server/types.js +87 -69
- package/extensions/mockpit.js +245 -65
- package/guide/AGENT_SETUP.md +13 -1
- package/guide/packs/design-craft.md +34 -0
- package/guide/topics/asks.md +117 -69
- package/guide/topics/feedback.md +156 -119
- package/guide/topics/html.md +19 -32
- package/guide/topics/http.md +43 -40
- package/guide/topics/knobs.md +7 -10
- package/guide/topics/scripts.md +24 -35
- package/guide/topics/surfaces.md +50 -41
- package/opencode/mockpit.js +120 -0
- package/opencode/package.json +8 -0
- package/package.json +4 -6
- package/skills/launch-video/SKILL.md +12 -12
- package/skills/mockpit/SKILL.md +35 -23
- 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
|
+
}
|
package/dist/mcp/server.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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/
|
|
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.
|
|
174
|
-
? `/api/projects/${enc(resolveProject(args.project))}/
|
|
175
|
-
: args.
|
|
176
|
-
? `/api/
|
|
177
|
-
:
|
|
178
|
-
project: resolveProject(args.project)
|
|
179
|
-
|
|
180
|
-
|
|
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/
|
|
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
|
-
|
|
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
|
+
}
|