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.
- package/README.md +59 -223
- package/bin/demoData.js +2 -2
- package/bin/initDesign.js +17 -22
- package/bin/mockpit.js +626 -216
- package/bin/poster.js +434 -0
- package/dist/mcp/server.js +127 -29
- package/dist/server/answerAdditions.d.ts +7 -0
- package/dist/server/answerAdditions.js +76 -0
- package/dist/server/apiViews.d.ts +627 -385
- package/dist/server/apiViews.js +198 -81
- package/dist/server/app.d.ts +7 -2
- package/dist/server/app.js +3514 -984
- 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 +640 -0
- package/dist/server/codeWorker.js +2 -2
- package/dist/server/contrast.d.ts +4 -0
- package/dist/server/contrast.js +157 -0
- package/dist/server/decisionList.d.ts +48 -0
- package/dist/server/decisionList.js +340 -0
- package/dist/server/decisions.d.ts +4 -4
- package/dist/server/decisions.js +9 -9
- package/dist/server/designGuide.d.ts +14 -7
- package/dist/server/designGuide.js +314 -88
- package/dist/server/embeds.d.ts +3 -1
- package/dist/server/embeds.js +31 -6
- package/dist/server/eventLog.d.ts +2 -2
- package/dist/server/eventLog.js +11 -11
- package/dist/server/events.d.ts +15 -6
- package/dist/server/feedbackBatch.d.ts +39 -13
- package/dist/server/feedbackBatch.js +129 -47
- package/dist/server/fills.d.ts +4 -0
- package/dist/server/fills.js +55 -1
- package/dist/server/guidance.d.ts +1 -1
- package/dist/server/guidance.js +2 -2
- 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 +39 -10
- package/dist/server/jump.d.ts +21 -0
- package/dist/server/jump.js +175 -0
- package/dist/server/kits.d.ts +2 -2
- package/dist/server/kits.js +54 -1
- package/dist/server/knobs.d.ts +3 -3
- package/dist/server/knobs.js +6 -6
- package/dist/server/mcpHttp.d.ts +7 -1
- package/dist/server/mcpHttp.js +29 -12
- package/dist/server/mcpSpec.d.ts +27 -27
- package/dist/server/mcpSpec.js +196 -56
- package/dist/server/parts.js +9 -1
- package/dist/server/run.d.ts +1 -1
- package/dist/server/run.js +47 -17
- package/dist/server/runApi.d.ts +3 -3
- package/dist/server/runApi.js +34 -21
- package/dist/server/sqlStore.d.ts +148 -103
- package/dist/server/sqlStore.js +1052 -181
- package/dist/server/standings.d.ts +25 -0
- package/dist/server/standings.js +113 -0
- package/dist/server/surfacePage.d.ts +8 -9
- package/dist/server/surfacePage.js +423 -100
- package/dist/server/system.d.ts +234 -0
- package/dist/server/system.js +1508 -0
- package/dist/server/systemChecks.d.ts +15 -0
- package/dist/server/systemChecks.js +224 -0
- package/dist/server/systemSpecimen.d.ts +2 -0
- package/dist/server/systemSpecimen.js +49 -0
- package/dist/server/themes.d.ts +9 -7
- package/dist/server/themes.js +43 -90
- package/dist/server/types.d.ts +266 -93
- package/dist/server/types.js +76 -79
- package/extensions/mockpit.js +89 -72
- package/guide/AGENT_SETUP.md +15 -1
- package/guide/packs/design-craft.md +2 -3
- package/guide/packs/foundations.md +35 -0
- package/guide/topics/asks.md +108 -63
- package/guide/topics/feedback.md +129 -120
- package/guide/topics/html.md +18 -25
- package/guide/topics/http.md +43 -41
- package/guide/topics/knobs.md +7 -10
- package/guide/topics/scripts.md +22 -33
- package/guide/topics/surfaces.md +39 -48
- package/guide/topics/system.md +157 -0
- package/opencode/mockpit.js +120 -0
- package/opencode/package.json +8 -0
- package/package.json +15 -14
- package/skills/launch-video/SKILL.md +11 -11
- package/skills/mockpit/SKILL.md +33 -26
- 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
|
+
}
|
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",
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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/
|
|
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.
|
|
195
|
-
? `/api/projects/${enc(resolveProject(args.project))}/
|
|
196
|
-
: args.
|
|
197
|
-
? `/api/
|
|
198
|
-
:
|
|
199
|
-
project: resolveProject(args.project)
|
|
200
|
-
|
|
201
|
-
|
|
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/
|
|
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.
|
|
224
|
-
throw new Error('"rename" and "areaOrder" edit the project; edit the
|
|
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.
|
|
229
|
-
throw new Error('provide "
|
|
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/
|
|
301
|
+
return post(`/api/designs/${enc(args.design)}`, { ...args, project, session }, "PATCH");
|
|
232
302
|
},
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
:
|
|
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
|
});
|