mockpit 1.0.0 → 1.2.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 (45) hide show
  1. package/README.md +12 -3
  2. package/bin/mockpit.js +47 -21
  3. package/dist/mcp/server.js +43 -4
  4. package/dist/server/apiViews.d.ts +11 -8
  5. package/dist/server/apiViews.js +21 -16
  6. package/dist/server/app.d.ts +3 -1
  7. package/dist/server/app.js +374 -67
  8. package/dist/server/appIcon.d.ts +22 -0
  9. package/dist/server/appIcon.js +43 -0
  10. package/dist/server/appIconPng.d.ts +1 -0
  11. package/dist/server/appIconPng.js +6 -0
  12. package/dist/server/askNudges.d.ts +3 -0
  13. package/dist/server/askNudges.js +40 -0
  14. package/dist/server/base64.d.ts +1 -0
  15. package/dist/server/base64.js +8 -0
  16. package/dist/server/designGuide.js +15 -13
  17. package/dist/server/eventLog.d.ts +27 -0
  18. package/dist/server/eventLog.js +187 -0
  19. package/dist/server/feedbackBatch.d.ts +16 -4
  20. package/dist/server/feedbackBatch.js +59 -9
  21. package/dist/server/handoff.d.ts +6 -0
  22. package/dist/server/handoff.js +38 -0
  23. package/dist/server/index.js +12 -0
  24. package/dist/server/knobs.js +2 -3
  25. package/dist/server/logFile.d.ts +2 -0
  26. package/dist/server/logFile.js +27 -0
  27. package/dist/server/mcpHttp.d.ts +2 -2
  28. package/dist/server/mcpHttp.js +11 -5
  29. package/dist/server/mcpSpec.d.ts +8 -0
  30. package/dist/server/mcpSpec.js +26 -1
  31. package/dist/server/run.js +2 -2
  32. package/dist/server/runApi.d.ts +1 -1
  33. package/dist/server/runApi.js +1 -1
  34. package/dist/server/sqlStore.d.ts +3 -1
  35. package/dist/server/sqlStore.js +40 -8
  36. package/dist/server/types.d.ts +25 -2
  37. package/dist/server/types.js +47 -4
  38. package/extensions/mockpit.js +24 -9
  39. package/guide/topics/asks.md +19 -7
  40. package/guide/topics/feedback.md +17 -7
  41. package/guide/topics/knobs.md +3 -3
  42. package/guide/topics/surfaces.md +8 -8
  43. package/package.json +2 -2
  44. package/skills/mockpit/SKILL.md +4 -2
  45. package/viewer/dist/index.html +9 -9
package/README.md CHANGED
@@ -70,8 +70,10 @@ mockpit turns that into a design-decision loop:
70
70
 
71
71
  **Home** lists the project's mocks with a thumbnail, their states and how many
72
72
  questions are open, with "Answer next ›" to jump to the first one. Dark and
73
- light themes, toggled from the top bar. On a narrow screen the panel becomes a
74
- bottom sheet.
73
+ light themes, toggled from the top bar. On a phone or tablet the panel becomes a
74
+ bottom sheet, the stage scrolls under your finger and renders the design at a
75
+ chosen width (Phone 390, Tablet 744, Desktop 820, Wide 1280), and Inspect opens
76
+ the stage full screen to pan and pinch-zoom it.
75
77
 
76
78
  ## Quick start
77
79
 
@@ -155,7 +157,7 @@ and `mockpit watch` (one line per Send, for a background monitor).
155
157
  list (an entry that is only `{id}` keeps that surface), or `parts` to replace
156
158
  just the marked elements.
157
159
  - **`feedback` returns at once** with what the user sent since the agent last
158
- heard, plus `pending`: whether the viewer is open and how far the user's
160
+ heard, plus `pending`: whether each mock is open in the viewer and how far the user's
159
161
  draft has got (`2 of 3 answered`). Every write returns `feedback` too, and
160
162
  each Send is delivered exactly once across all of them.
161
163
  - **Asks are nudged.** A publish that leaves several variants with no ask
@@ -185,6 +187,13 @@ It runs locally as a small Node server (SQLite at `~/.mockpit/mockpit.db`), or o
185
187
  Cloudflare Workers when your agent and browser are on different machines. See
186
188
  **[docs/deploying.md](docs/deploying.md)**.
187
189
 
190
+ The server keeps an event log, one JSON line per agent call, viewer write and
191
+ live-feed connection (ids and shapes, never content), at
192
+ `~/.mockpit/events.jsonl` next to the database. It rolls over at 10 MB. Set
193
+ `MOCKPIT_LOG` to another path, or to `off` to disable it; on Workers it goes to
194
+ Workers Logs. `node scripts/inspect.ts` in a checkout reads it alongside the
195
+ database and Claude Code transcripts.
196
+
188
197
  The server app is importable from `mockpit/server` (`createApp`, `SqlStore`,
189
198
  `createSqliteStorage`). The embeddable viewer engine (`mockpit/viewer-embed`,
190
199
  `mountViewer`) is gone: the viewer is self-hosted only.
package/bin/mockpit.js CHANGED
@@ -19,6 +19,12 @@ const BASE = (process.env.MOCKPIT_URL ?? "http://localhost:8228").replace(/\/$/,
19
19
  const TOKEN = process.env.MOCKPIT_TOKEN;
20
20
  const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
21
21
  const PKG_VERSION = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8")).version;
22
+ // Every request names this client and its version, so the server's event log
23
+ // can tell the CLI from MCP and spot one older than the server.
24
+ const HEADERS = {
25
+ "x-mockpit-client": `cli/${PKG_VERSION}`,
26
+ ...(TOKEN ? { authorization: `Bearer ${TOKEN}` } : {}),
27
+ };
22
28
 
23
29
  const HELP = `mockpit — a live visual surface for terminal coding agents
24
30
 
@@ -31,7 +37,9 @@ the loop (never blocks on the user):
31
37
  1. mockpit init once per repo; read the brief (mockpit guide).
32
38
  2. mockpit publish once per variant; mark parts with data-part.
33
39
  3. A choice is several variants plus one ask that binds them: mockpit ask
34
- with options bound to variants. One render plus a control: --knobs.
40
+ with options bound to variants. A variant decides everything it differs
41
+ on; don't re-ask it. Visual options carry a variant or knob set. One
42
+ render plus a control: --knobs.
35
43
  4. Tell the user in one line where to look, then end your turn. The question
36
44
  itself lives in the mock (ask), never in chat. The user answers in the
37
45
  browser at their own pace. Never poll.
@@ -66,7 +74,8 @@ verbs:
66
74
  --data is a JSON tree)
67
75
  mockpit ask --mock <slug> "<question>" --option <label[=variant]> ...
68
76
  ask the user; bind options to variants
69
- --option <l[=v]> an option (repeatable); "=variant" binds it to a variant
77
+ --option <l[=v]> an option (repeatable); "=variant" binds it to a variant,
78
+ "=knob:path=value" sets one knob
70
79
  --scope <s> mock|state|part (default mock)
71
80
  --state <label> the state a state-scoped ask is about
72
81
  --part <name> the part a part-scoped ask is about
@@ -118,6 +127,7 @@ environment:
118
127
  MOCKPIT_URL server base URL (default http://localhost:8228)
119
128
  MOCKPIT_TOKEN bearer token for a deployed instance
120
129
  MOCKPIT_HOST address serve binds to (default: every interface)
130
+ MOCKPIT_LOG serve's event log file, or "off" (default ~/.mockpit/events.jsonl)
121
131
  MOCKPIT_SESSION fixed session id (overrides auto-detection)
122
132
  MOCKPIT_AGENT agent name used when creating sessions
123
133
  `;
@@ -241,7 +251,7 @@ async function api(path, init = {}, { report, fix } = {}) {
241
251
  ...init,
242
252
  headers: {
243
253
  "content-type": "application/json",
244
- ...(TOKEN ? { authorization: `Bearer ${TOKEN}` } : {}),
254
+ ...HEADERS,
245
255
  ...init.headers,
246
256
  },
247
257
  });
@@ -364,7 +374,7 @@ async function resolveSession(flags, { create = false } = {}) {
364
374
  const state = readState();
365
375
  if (state.session && !flags["new-session"]) {
366
376
  const ok = await fetch(`${BASE}/api/sessions/${state.session}`, {
367
- headers: TOKEN ? { authorization: `Bearer ${TOKEN}` } : {},
377
+ headers: HEADERS,
368
378
  }).then(
369
379
  (r) => r.ok,
370
380
  () => false,
@@ -394,7 +404,7 @@ async function resolveSession(flags, { create = false } = {}) {
394
404
  async function resolveSessionByCwd(cwd = process.cwd()) {
395
405
  try {
396
406
  const res = await fetch(`${BASE}/api/sessions`, {
397
- headers: TOKEN ? { authorization: `Bearer ${TOKEN}` } : {},
407
+ headers: HEADERS,
398
408
  });
399
409
  if (!res.ok) return null;
400
410
  const sessions = await res.json();
@@ -533,7 +543,7 @@ async function uploadBytes(bytes, { filename, contentType, session, kind } = {})
533
543
  method: "POST",
534
544
  headers: {
535
545
  "content-type": contentType ?? "application/octet-stream",
536
- ...(TOKEN ? { authorization: `Bearer ${TOKEN}` } : {}),
546
+ ...HEADERS,
537
547
  },
538
548
  body: bytes,
539
549
  });
@@ -818,19 +828,17 @@ function printFeedback(feedback) {
818
828
  if (feedback?.length) console.log(`feedback: ${JSON.stringify(feedback)}`);
819
829
  }
820
830
 
821
- // What the user is doing right now. viewerOpen is workspace-wide, so it is
822
- // said once rather than per mock.
831
+ // What the user is doing right now, per mock.
823
832
  function pendingLines(pending) {
824
- const rows = pending ?? [];
825
- const lines = rows
826
- .filter((p) => p.draft)
827
- .map(
828
- (p) =>
829
- `pending: ${p.mock} — the user is answering (${p.draft.answered} of ${p.draft.of} answered, ${p.draft.comments} comment${p.draft.comments === 1 ? "" : "s"})`,
830
- );
831
- if (lines.length === 0 && rows.some((p) => p.viewerOpen))
832
- lines.push("pending: the viewer is open");
833
- return lines;
833
+ return (pending ?? []).flatMap((p) =>
834
+ p.draft
835
+ ? [
836
+ `pending: ${p.mock} — the user is answering (${p.draft.answered} of ${p.draft.of} answered, ${p.draft.comments} comment${p.draft.comments === 1 ? "" : "s"})`,
837
+ ]
838
+ : p.viewerOpen
839
+ ? [`pending: ${p.mock} — open in the viewer`]
840
+ : [],
841
+ );
834
842
  }
835
843
 
836
844
  function parsePublishFlags() {
@@ -953,8 +961,23 @@ async function publishMock() {
953
961
  return result;
954
962
  }
955
963
 
956
- // "Quiet" is an unbound option; "Quiet=quiet" binds it to the quiet variant.
964
+ // "Quiet" is an unbound option; "Quiet=quiet" binds it to the quiet variant;
965
+ // "Above=knob:trim.position=top" sets one knob. The value is JSON when it
966
+ // parses (17, true), else a string, so slider and toggle values keep their type.
957
967
  function parseOption(raw) {
968
+ const knob = raw.indexOf("=knob:");
969
+ if (knob > 0) {
970
+ const pair = raw.slice(knob + "=knob:".length);
971
+ const eq = pair.indexOf("=");
972
+ if (eq <= 0)
973
+ die(`option "${raw}" needs knob:<path>=<value>`, "--option Above=knob:trim.position=top");
974
+ const text = pair.slice(eq + 1);
975
+ let value = text;
976
+ try {
977
+ value = JSON.parse(text);
978
+ } catch {}
979
+ return { label: raw.slice(0, knob), set: { [pair.slice(0, eq)]: value } };
980
+ }
958
981
  const at = raw.lastIndexOf("=");
959
982
  if (at <= 0) return { label: raw };
960
983
  return { label: raw.slice(0, at), variant: raw.slice(at + 1) };
@@ -981,6 +1004,7 @@ function printRun(r) {
981
1004
  function watchLines(batch) {
982
1005
  const lines = [];
983
1006
  const name = batch.mock ?? "a mock";
1007
+ if (batch.handoff) lines.push(`mockpit handoff on ${name}: ${batch.handoff}`);
984
1008
  if (batch.reply) {
985
1009
  const r = batch.reply;
986
1010
  const bits = [
@@ -999,6 +1023,7 @@ function watchLines(batch) {
999
1023
  .join(", ")}`
1000
1024
  : "",
1001
1025
  r.comments?.length ? `${r.comments.length} comment${r.comments.length === 1 ? "" : "s"}` : "",
1026
+ r.reactions?.length ? r.reactions.join(", ") : "",
1002
1027
  r.decision ? r.decision.kind : "",
1003
1028
  r.text ?? "",
1004
1029
  ].filter(Boolean);
@@ -1020,7 +1045,7 @@ async function apiSoft(path) {
1020
1045
  let res;
1021
1046
  try {
1022
1047
  res = await fetch(`${BASE}${path}`, {
1023
- headers: TOKEN ? { authorization: `Bearer ${TOKEN}` } : {},
1048
+ headers: HEADERS,
1024
1049
  });
1025
1050
  } catch {
1026
1051
  die(`cannot reach mockpit at ${BASE}`, "mockpit serve");
@@ -1375,6 +1400,7 @@ const commands = {
1375
1400
  `asked on ${result.mock}: ${ask.text} [${ask.options.map((o) => o.label).join(" | ")}]`,
1376
1401
  );
1377
1402
  }
1403
+ for (const nudge of result.nudges ?? []) console.log(`nudge: ${nudge}`);
1378
1404
  console.log(`tell the user to look at ${result.url}, then end your turn`);
1379
1405
  printFeedback(result.feedback);
1380
1406
  },
@@ -1543,7 +1569,7 @@ const commands = {
1543
1569
  try {
1544
1570
  const res = await fetch(
1545
1571
  `${BASE}/api/comments${query({ session, author: "user", after: firstAfter, wait: 60 })}`,
1546
- { headers: TOKEN ? { authorization: `Bearer ${TOKEN}` } : {} },
1572
+ { headers: HEADERS },
1547
1573
  );
1548
1574
  if (!res.ok) {
1549
1575
  await sleep(2000);
@@ -1,10 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  import { execFileSync } from "node:child_process";
3
3
  import { readFileSync, statSync } from "node:fs";
4
+ import { dirname, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
4
6
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
7
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
8
  import { ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
7
- import { MCP_INSTRUCTIONS, MCP_SERVER_INFO, runToolResult, STDIO_MCP_CATALOG, STDIO_MCP_TOOLS, STDIO_RUN_CATALOG, STDIO_RUN_TOOLS, toolResult, } from "../server/mcpSpec.js";
9
+ import { MCP_INSTRUCTIONS, MCP_SERVER_INFO, runToolResult, STDIO_MCP_CATALOG, STDIO_MCP_TOOLS, STDIO_RUN_CATALOG, STDIO_RUN_TOOLS, toolResult, withFeedbackImages, } from "../server/mcpSpec.js";
8
10
  import { RUN_INSTRUCTIONS } from "../server/runApi.js";
9
11
  // Point at a deployed instance later by setting MOCKPIT_URL.
10
12
  const API = process.env.MOCKPIT_URL ?? "http://localhost:8228";
@@ -12,8 +14,28 @@ const TOKEN = process.env.MOCKPIT_TOKEN;
12
14
  const AGENT = process.env.MOCKPIT_AGENT ?? "claude-code";
13
15
  // "code" serves the codemode catalog: one `run` tool instead of the mock tools.
14
16
  const CODE_MODE = process.env.MOCKPIT_MCP_MODE === "code";
17
+ // Names this client in the server's event log; the version is how a stdio
18
+ // server left running across an upgrade shows up there.
19
+ const CLIENT = `mcp-stdio/${packageVersion()}`;
20
+ // Source runs as mcp/server.ts, the published copy as dist/mcp/server.js.
21
+ function packageVersion() {
22
+ for (const rel of ["../package.json", "../../package.json"]) {
23
+ try {
24
+ const pkg = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), rel), "utf8"));
25
+ if (pkg.name === "mockpit")
26
+ return String(pkg.version);
27
+ }
28
+ catch {
29
+ // Not at this depth.
30
+ }
31
+ }
32
+ return "unknown";
33
+ }
15
34
  async function api(path, init = {}) {
16
- const headers = { "content-type": "application/json" };
35
+ const headers = {
36
+ "content-type": "application/json",
37
+ "x-mockpit-client": CLIENT,
38
+ };
17
39
  if (TOKEN)
18
40
  headers.authorization = `Bearer ${TOKEN}`;
19
41
  let res;
@@ -208,18 +230,35 @@ async function run(args) {
208
230
  body: JSON.stringify({ code, session, project: resolveProject(args.project), agent: AGENT }),
209
231
  });
210
232
  }
233
+ async function loadImage(ref) {
234
+ const res = await fetch(`${API}/a/${encodeURIComponent(ref.id)}`, {
235
+ headers: TOKEN ? { authorization: `Bearer ${TOKEN}` } : {},
236
+ });
237
+ if (!res.ok)
238
+ return null;
239
+ return {
240
+ data: new Uint8Array(await res.arrayBuffer()),
241
+ mimeType: res.headers.get("content-type") ?? "image/png",
242
+ };
243
+ }
211
244
  const server = new McpServer(MCP_SERVER_INFO, {
212
245
  instructions: CODE_MODE ? RUN_INSTRUCTIONS : MCP_INSTRUCTIONS,
213
246
  });
214
247
  if (CODE_MODE) {
215
248
  const [{ name, ...config }] = STDIO_RUN_TOOLS;
216
- server.registerTool(name, config, async (args) => runToolResult(await run(args)));
249
+ server.registerTool(name, config, async (args) => {
250
+ const envelope = await run(args);
251
+ return withFeedbackImages(runToolResult(envelope), envelope, loadImage);
252
+ });
217
253
  }
218
254
  else {
219
255
  for (const tool of STDIO_MCP_TOOLS) {
220
256
  const handler = handlers[tool.name];
221
257
  const { name, ...config } = tool;
222
- server.registerTool(name, config, async (args) => toolResult(name, await handler(args)));
258
+ server.registerTool(name, config, async (args) => {
259
+ const value = await handler(args);
260
+ return withFeedbackImages(toolResult(name, value), value, loadImage);
261
+ });
223
262
  }
224
263
  }
225
264
  // Replaces the SDK's own listing (registered above), so stdio advertises the
@@ -203,6 +203,15 @@ export interface StateParts {
203
203
  }
204
204
  export declare function partsByState(mock: Mock, posts: Post[]): StateParts[];
205
205
  export declare const mockSummaryView: (mock: Mock, posts: Post[]) => {
206
+ sessionId: string | null;
207
+ createdAt: string;
208
+ updatedAt: string;
209
+ thumbnail: {
210
+ postId: string;
211
+ surface: number;
212
+ version: number;
213
+ } | null;
214
+ drafted?: number | undefined;
206
215
  id: string;
207
216
  project: string;
208
217
  slug: string;
@@ -212,14 +221,6 @@ export declare const mockSummaryView: (mock: Mock, posts: Post[]) => {
212
221
  stateCount: number;
213
222
  variants: number;
214
223
  open: number;
215
- sessionId: string | null;
216
- createdAt: string;
217
- updatedAt: string;
218
- thumbnail: {
219
- postId: string;
220
- surface: number;
221
- version: number;
222
- } | null;
223
224
  };
224
225
  export declare const mockDetailView: (mock: Mock, posts: Post[], opts?: VariantViewOptions & {
225
226
  tuned?: Record<string, unknown>;
@@ -326,4 +327,6 @@ export declare const sessionRowView: (session: Session, postCount: number) => {
326
327
  lastActiveAt: string;
327
328
  agentSeq: number;
328
329
  project: string | null;
330
+ agentActiveAt: string | null;
331
+ keyed: boolean;
329
332
  };
@@ -1,7 +1,7 @@
1
1
  // Response shapes shared by every tier (REST, both MCP transports, the CLI), so
2
2
  // one mock reads the same however an agent asks for it.
3
3
  import { mergeParts, partsInSurfaces } from "./parts.js";
4
- import { isSandboxedSurfaceKind, openCount, SURFACE_CONTENT_FIELDS, } from "./types.js";
4
+ import { isSandboxedSurfaceKind, draftedCount, openCount, SURFACE_CONTENT_FIELDS, } from "./types.js";
5
5
  export const surfaceRef = (surface, index) => ({
6
6
  id: surface.id,
7
7
  kind: surface.kind,
@@ -94,21 +94,26 @@ function thumbnail(mock, posts) {
94
94
  const surface = pick.surfaces.findIndex((s) => isSandboxedSurfaceKind(s.kind));
95
95
  return { postId: pick.id, surface: Math.max(surface, 0), version: pick.version };
96
96
  }
97
- export const mockSummaryView = (mock, posts) => ({
98
- id: mock.id,
99
- project: mock.project,
100
- slug: mock.slug,
101
- title: mock.title,
102
- kind: mock.kind,
103
- states: mock.states,
104
- stateCount: Math.max(mock.states.length, 1),
105
- variants: posts.length,
106
- open: openCount(mock, posts),
107
- sessionId: mock.sessionId,
108
- createdAt: mock.createdAt,
109
- updatedAt: mock.updatedAt,
110
- thumbnail: thumbnail(mock, posts),
111
- });
97
+ export const mockSummaryView = (mock, posts) => {
98
+ const drafted = draftedCount(mock, posts);
99
+ return {
100
+ id: mock.id,
101
+ project: mock.project,
102
+ slug: mock.slug,
103
+ title: mock.title,
104
+ kind: mock.kind,
105
+ states: mock.states,
106
+ stateCount: Math.max(mock.states.length, 1),
107
+ variants: posts.length,
108
+ open: openCount(mock, posts),
109
+ // Only once the user has started one, so a list with no drafts costs nothing.
110
+ ...(drafted === null ? {} : { drafted }),
111
+ sessionId: mock.sessionId,
112
+ createdAt: mock.createdAt,
113
+ updatedAt: mock.updatedAt,
114
+ thumbnail: thumbnail(mock, posts),
115
+ };
116
+ };
112
117
  // One mock as anyone outside the viewer's own draft sees it: the draft is the
113
118
  // user's unsent work and never leaves through this view.
114
119
  export const mockDetailView = (mock, posts, opts = {}) => ({
@@ -2,6 +2,7 @@ import { Hono } from "hono";
2
2
  import { type FeedEvent } from "./events.ts";
3
3
  import { type GuideTopic } from "./designGuide.ts";
4
4
  import { type Executor, type RunLimits } from "./run.ts";
5
+ import { type LogSink } from "./eventLog.ts";
5
6
  import { type Store } from "./types.ts";
6
7
  export type { FeedEvent } from "./events.ts";
7
8
  export type { FeedbackBatch } from "./feedbackBatch.ts";
@@ -25,6 +26,7 @@ export interface AppOptions {
25
26
  maxHoldConnections?: number;
26
27
  executor?: Executor;
27
28
  runLimits?: Partial<RunLimits>;
29
+ log?: LogSink;
28
30
  }
29
31
  export interface LatestRelease {
30
32
  version: string;
@@ -50,4 +52,4 @@ export interface FlowContext {
50
52
  request?: Request;
51
53
  signal?: AbortSignal;
52
54
  }
53
- export declare function createApp({ store, viewerHtml, setupText, topics, authenticate, authToken, basePath, publicRead, screenshots, version, upgradeCommand, fetchLatestRelease, onEvent, maxHoldConnections, executor, runLimits, }: AppOptions): Hono<import("hono/types").BlankEnv, import("hono/types").BlankSchema, "/">;
55
+ export declare function createApp({ store, viewerHtml, setupText, topics, authenticate, authToken, basePath, publicRead, screenshots, version, upgradeCommand, fetchLatestRelease, onEvent, maxHoldConnections, executor, runLimits, log, }: AppOptions): Hono<import("hono/types").BlankEnv, import("hono/types").BlankSchema, "/">;