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
@@ -0,0 +1,38 @@
1
+ // Orphaned replies: a Send lands on the session that owns the mock, and when
2
+ // that agent restarts into a new session (a new conversation, /clear, a crash)
3
+ // nobody would ever read it. The rule below decides when a newer session may
4
+ // take such a reply instead. Runtime-agnostic (no node imports).
5
+ //
6
+ // The danger is the other way round: two agents can work in one repo at once,
7
+ // and an agent waiting on the user looks exactly like a dead one — both are
8
+ // silent. So each clause narrows who counts as "the same agent, restarted":
9
+ //
10
+ // - Same project, same cwd, same agent name. A restart reopens the same harness
11
+ // in the same checkout; a different worktree or tool is a parallel worker.
12
+ // - The owner was created first and has made no call since the taker appeared
13
+ // (agentActiveAt, which the user's own Sends don't bump). An owner that kept
14
+ // working after the taker started is running alongside it, not gone.
15
+ // - The owner has no feedback long-poll open right now (`mockpit watch`).
16
+ //
17
+ // A reply also waits HANDOFF_GRACE_MS after the Send, so the owner — the agent
18
+ // the user is most likely about to tell — gets first claim. The one exception:
19
+ // a keyless owner (no harness conversation id) can never come back, so a reply
20
+ // it got before the taker existed goes at once — the user answered, then
21
+ // restarted. A keyed owner always gets the grace: its conversation may resume.
22
+ export const HANDOFF_GRACE_MS = 2 * 60_000;
23
+ export const HANDOFF_NOTE = "Sent to an earlier agent session in this project that has gone quiet; it is yours now. Read the mock before acting.";
24
+ export const ownerActiveAt = (s) => s.agentActiveAt ?? s.lastActiveAt;
25
+ export function supersedes(taker, owner, listening) {
26
+ return (owner.id !== taker.id &&
27
+ owner.project !== null &&
28
+ owner.project === taker.project &&
29
+ owner.cwd === taker.cwd &&
30
+ owner.agent === taker.agent &&
31
+ owner.createdAt < taker.createdAt &&
32
+ ownerActiveAt(owner) < taker.createdAt &&
33
+ !listening);
34
+ }
35
+ export function handoffReady(comment, owner, taker, now) {
36
+ return ((!owner.keyed && comment.createdAt < taker.createdAt) ||
37
+ now - Date.parse(comment.createdAt) >= HANDOFF_GRACE_MS);
38
+ }
@@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url";
6
6
  import { createApp } from "./app.js";
7
7
  import { createNodeExecutor } from "./codeRunner.js";
8
8
  import { GUIDE_TOPICS } from "./designGuide.js";
9
+ import { createFileLog } from "./logFile.js";
9
10
  import { SqlStore } from "./sqlStore.js";
10
11
  import { createSqliteStorage } from "./sqliteStorage.js";
11
12
  // Source layout puts this file at server/index.ts; the published package runs
@@ -33,6 +34,16 @@ const publicRead = pr === "session" || pr === "full" ? pr : undefined;
33
34
  const dbPath = process.env.MOCKPIT_DB ?? join(homedir(), ".mockpit", "mockpit.db");
34
35
  const store = new SqlStore(createSqliteStorage(dbPath));
35
36
  console.log(`mockpit store: SQLite at ${dbPath}`);
37
+ // The event log is on by default, next to the database, so a session can be
38
+ // diagnosed after the fact without having planned to (`scripts/inspect.ts`).
39
+ // MOCKPIT_LOG names another file; "off" disables it. An in-memory database
40
+ // gets none unless asked: it is a throwaway run.
41
+ const logSetting = process.env.MOCKPIT_LOG;
42
+ const logPath = logSetting === "off"
43
+ ? undefined
44
+ : logSetting || (dbPath === ":memory:" ? undefined : join(dirname(dbPath), "events.jsonl"));
45
+ if (logPath)
46
+ console.log(`mockpit event log: ${logPath}`);
36
47
  const app = createApp({
37
48
  store,
38
49
  viewerHtml,
@@ -46,6 +57,7 @@ const app = createApp({
46
57
  upgradeCommand: "npm install -g mockpit",
47
58
  // `run` scripts execute in QuickJS inside a worker thread per run.
48
59
  executor: createNodeExecutor(),
60
+ log: logPath ? createFileLog(logPath) : undefined,
49
61
  });
50
62
  const port = Number(process.env.PORT ?? 8228);
51
63
  // MOCKPIT_HOST (or `serve --host`) restricts the listener to one address.
@@ -320,11 +320,10 @@ export function checkKnobValues(raw, mock, posts, what) {
320
320
  return { ok: true, value: out };
321
321
  }
322
322
  // How many discrete choices a knob offers, or null when it is continuous. A knob
323
- // with only a handful is usually a decision dressed as a control (plan D12).
323
+ // with only a handful is usually a decision dressed as a control (plan D12). A
324
+ // toggle is null: on/off reads fine on one render.
324
325
  export function discreteChoices(config) {
325
326
  const knob = explicitKnob(config);
326
- if (knob.type === "toggle")
327
- return 2;
328
327
  if (knob.type === "select")
329
328
  return knob.options.length;
330
329
  if (knob.type === "image" && knob.options)
@@ -0,0 +1,2 @@
1
+ import type { LogSink } from "./eventLog.ts";
2
+ export declare function createFileLog(path: string, maxBytes?: number): LogSink;
@@ -0,0 +1,27 @@
1
+ import { appendFileSync, mkdirSync, renameSync, statSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ // One file plus one rotated predecessor caps the log at twice this, so leaving
4
+ // it on by default costs a bounded slice of disk.
5
+ const DEFAULT_MAX_BYTES = 10 * 1024 * 1024;
6
+ // Appends JSONL to `path`, rolling it to `<path>.1` past `maxBytes`. Writes are
7
+ // synchronous so lines never interleave and a crash loses nothing already
8
+ // answered; the loop's call rate makes that cost negligible.
9
+ export function createFileLog(path, maxBytes = DEFAULT_MAX_BYTES) {
10
+ mkdirSync(dirname(path), { recursive: true });
11
+ let size = 0;
12
+ try {
13
+ size = statSync(path).size;
14
+ }
15
+ catch {
16
+ // No file yet.
17
+ }
18
+ return (entry) => {
19
+ const line = `${JSON.stringify(entry)}\n`;
20
+ if (size > 0 && size + line.length > maxBytes) {
21
+ renameSync(path, `${path}.1`);
22
+ size = 0;
23
+ }
24
+ appendFileSync(path, line);
25
+ size += Buffer.byteLength(line);
26
+ };
27
+ }
@@ -23,11 +23,11 @@ export interface McpFlows {
23
23
  },
24
24
  ctx: FlowContext
25
25
  ]>;
26
- say: Flow<[ref: unknown, body: any]>;
26
+ say: Flow<[ref: unknown, body: any, ctx: FlowContext]>;
27
27
  feedback: Flow<[query: {
28
28
  session?: unknown;
29
29
  project?: unknown;
30
- }]>;
30
+ }, ctx: FlowContext]>;
31
31
  }
32
32
  export interface McpDeps {
33
33
  store: Store;
@@ -1,6 +1,6 @@
1
1
  import { decodeBase64 } from "./base64.js";
2
2
  import { isGuideTopic, unknownTopicMessage } from "./designGuide.js";
3
- import { HTTP_MCP_TOOLS, HTTP_RUN_TOOLS, MCP_INSTRUCTIONS, MCP_SERVER_INFO, runToolResult, toolResult, } from "./mcpSpec.js";
3
+ import { HTTP_MCP_TOOLS, HTTP_RUN_TOOLS, MCP_INSTRUCTIONS, MCP_SERVER_INFO, runToolResult, toolResult, withFeedbackImages, } from "./mcpSpec.js";
4
4
  import { RUN_INSTRUCTIONS } from "./runApi.js";
5
5
  // A flow's error status becomes a tool error carrying the same JSON body the
6
6
  // REST tier returns, so the agent sees the hint (states, variants) either way.
@@ -24,6 +24,10 @@ export function registerMcp(app, deps) {
24
24
  const projects = await store.listProjects();
25
25
  return projects[0]?.name ?? "workspace";
26
26
  }
27
+ async function loadImage(ref) {
28
+ const asset = await store.getAsset(ref.id);
29
+ return asset ? { data: asset.data, mimeType: asset.contentType } : null;
30
+ }
27
31
  async function callTool(name, args, ctx) {
28
32
  switch (name) {
29
33
  case "publish":
@@ -39,9 +43,9 @@ export function registerMcp(app, deps) {
39
43
  history: args.history === true,
40
44
  }));
41
45
  case "feedback":
42
- return unwrap(await flows.feedback({ session: args.session, project: args.project }));
46
+ return unwrap(await flows.feedback({ session: args.session, project: args.project }, ctx));
43
47
  case "say":
44
- return unwrap(await flows.say(args.mock, args));
48
+ return unwrap(await flows.say(args.mock, args, ctx));
45
49
  case "export":
46
50
  return unwrap(await flows.exportMock(args.mock, args, ctx));
47
51
  case "upload": {
@@ -124,9 +128,11 @@ export function registerMcp(app, deps) {
124
128
  const name = msg.params?.name;
125
129
  if (name === "run") {
126
130
  const args = msg.params?.arguments ?? {};
127
- return rpc(msg.id, runToolResult(unwrap(await deps.run(args, ctx))));
131
+ const envelope = unwrap(await deps.run(args, ctx));
132
+ return rpc(msg.id, await withFeedbackImages(runToolResult(envelope), envelope, loadImage));
128
133
  }
129
- return rpc(msg.id, toolResult(name, await callTool(name, msg.params?.arguments ?? {}, ctx)));
134
+ const value = await callTool(name, msg.params?.arguments ?? {}, ctx);
135
+ return rpc(msg.id, await withFeedbackImages(toolResult(name, value), value, loadImage));
130
136
  }
131
137
  catch (err) {
132
138
  return rpc(msg.id, {
@@ -1,4 +1,5 @@
1
1
  import * as z from "zod/v4";
2
+ import { type ImageRef } from "./feedbackBatch.ts";
2
3
  export declare const MCP_SERVER_INFO: {
3
4
  name: string;
4
5
  version: string;
@@ -180,6 +181,13 @@ export declare function toolResult(name: string, value: unknown): {
180
181
  text: string;
181
182
  }[];
182
183
  };
184
+ export declare const MAX_RESULT_IMAGES = 8;
185
+ export declare function withFeedbackImages<T extends {
186
+ content: unknown[];
187
+ }>(result: T, value: unknown, load: (ref: ImageRef) => Promise<{
188
+ data: Uint8Array;
189
+ mimeType: string;
190
+ } | null>): Promise<T>;
183
191
  export declare function runToolResult(envelope: unknown): {
184
192
  isError?: boolean | undefined;
185
193
  structuredContent?: Record<string, unknown> | undefined;
@@ -1,5 +1,7 @@
1
1
  import * as z from "zod/v4";
2
+ import { encodeBase64 } from "./base64.js";
2
3
  import { GUIDE_TOPICS } from "./designGuide.js";
4
+ import { feedbackImages } from "./feedbackBatch.js";
3
5
  import { KIT_IDS } from "./kits.js";
4
6
  import { RUN_DESCRIPTION } from "./runApi.js";
5
7
  import { SURFACE_KINDS } from "./types.js";
@@ -245,6 +247,7 @@ const feedbackSchema = o({
245
247
  mix: bag,
246
248
  tuned: bag,
247
249
  comments: z.array(o({ part: nullStr, text: str })),
250
+ reactions: strs.optional().describe('One-tap reactions, e.g. 👍 part "hero" (variant B)'),
248
251
  })
249
252
  .nullable()
250
253
  .describe("The user's Send; null if only comments"),
@@ -288,7 +291,13 @@ const OUTPUT_SCHEMAS = {
288
291
  session: nullStr,
289
292
  },
290
293
  publish: writeOutput,
291
- ask: { asks: z.array(o({ id: str, text: str })), open: num, url: str, feedback: feedbackRide },
294
+ ask: {
295
+ asks: z.array(o({ id: str, text: str })),
296
+ open: num,
297
+ url: str,
298
+ nudges: strs.optional(),
299
+ feedback: feedbackRide,
300
+ },
292
301
  feedback: {
293
302
  feedback: z.array(feedbackSchema).describe("One batch per mock; empty when nothing is new"),
294
303
  pending: z.array(pendingSchema),
@@ -394,6 +403,22 @@ export function toolResult(name, value) {
394
403
  ...(structured ? { structuredContent: value } : {}),
395
404
  };
396
405
  }
406
+ // The images a result's feedback carries, inlined as image content so the
407
+ // model sees what the user attached instead of only its URL. Capped per result:
408
+ // the rest stay reachable by URL in the JSON.
409
+ export const MAX_RESULT_IMAGES = 8;
410
+ export async function withFeedbackImages(result, value, load) {
411
+ const batches = value?.feedback;
412
+ if (!Array.isArray(batches))
413
+ return result;
414
+ const images = [];
415
+ for (const ref of feedbackImages(batches).slice(0, MAX_RESULT_IMAGES)) {
416
+ const bytes = await load(ref).catch(() => null);
417
+ if (bytes)
418
+ images.push({ type: "image", data: encodeBase64(bytes.data), mimeType: bytes.mimeType });
419
+ }
420
+ return images.length ? { ...result, content: [...result.content, ...images] } : result;
421
+ }
397
422
  // A run that failed is still a full envelope (calls, feedback); isError tells
398
423
  // the client the script did not finish.
399
424
  export function runToolResult(envelope) {
@@ -170,13 +170,13 @@ export function createRunFlow(deps) {
170
170
  return [value, `${value.mock} ${value.asks.length} ask(s)`];
171
171
  },
172
172
  async feedback() {
173
- const value = await take(flows.feedback(await mine({})));
173
+ const value = await take(flows.feedback(await mine({}), flowCtx));
174
174
  const n = value.feedback.length;
175
175
  return [value, n ? `${n} batch(es)` : "no feedback"];
176
176
  },
177
177
  async say([v, message]) {
178
178
  const r = plain(v);
179
- await take(flows.say(r.mock, { ...(await mine(r)), message }));
179
+ await take(flows.say(r.mock, { ...(await mine(r)), message }, flowCtx));
180
180
  return [null, String(r.mock ?? "?")];
181
181
  },
182
182
  async upload([data, opts]) {
@@ -1,4 +1,4 @@
1
- export declare const RUN_API = "// The body of an async function, in plain JavaScript (no TypeScript syntax).\n// `await mockpit.*`, `return` a JSON value, print() adds an output line.\n// No network, timers, imports or process. Session, project and agent are fixed\n// for the run; pass the returned session to the next run.\ntype Ref = { mock: string; state?: string; variant?: string };\ntype Knobs = Record<string, unknown>; // path \u2192 [default,min,max,step] | boolean | {type:\"select\",options} | \"#hex\"\ntype Surface =\n | { kind: \"html\"; html: string; kits?: string[] }\n | { kind: \"markdown\"; markdown: string }\n | { kind: \"diff\"; patch?: string; files?: { filename: string; before: string; after: string }[]; layout?: \"unified\" | \"split\" }\n | { kind: \"image\"; assetId: string; alt?: string; caption?: string }\n | { kind: \"terminal\"; text: string; cols?: number; title?: string }\n | { kind: \"mermaid\"; mermaid: string }\n | { kind: \"json\"; data: unknown; title?: string }\n | { kind: \"code\"; code: string; language?: string; lineStart?: number };\ntype Ask = {\n id?: string; text: string; scope?: \"mock\" | \"state\" | \"part\"; state?: string; part?: string; multi?: boolean;\n options: { id?: string; label: string; variant?: string; set?: Knobs }[];\n};\ntype Written = {\n post: { state: string | null; variant: string; version: number }; url: string;\n parts: { state: string | null; parts: { name: string }[] }[];\n partChanges?: { vanished: string[]; renamed: { from: string; to: string }[] };\n nudges?: string[]; suggestedAsk?: Ask; // send it with ask when nudged\n};\ntype Feedback = {\n mock: string | null;\n reply: null | { asks: { ask: string; chosen: { id: string; label: string; other?: true }[]; note?: string }[]; mix: Knobs; tuned: Knobs; comments: { part: string | null; text: string }[] };\n comments: { text: string; state: string | null; variant: string | null }[];\n accepted: { state: string | null; variant: string }[]; archived: { state: string | null; variant: string }[];\n};\ntype Pending = { mock: string; viewerOpen: boolean; draft: null | { answered: number; of: number; comments: number; touchedAt: string } };\ndeclare const mockpit: {\n guide(topic?: \"knobs\" | \"asks\" | \"surfaces\" | \"html\" | \"feedback\" | \"http\" | \"scripts\"): Promise<string>;\n /** No mock: every mock plus pending. With one: its states, variants, asks, parts, knobs. */\n read(mock?: string, opts?: { body?: boolean; history?: boolean }): Promise<unknown>;\n /** Creates or versions (mock, state, variant). One of: html; surfaces, the full ordered list\n * ({id} alone keeps that surface, a missing id is removed); parts {\"name\"|\"name#key\": outer html}. */\n publish(v: Ref & { title?: string; kind?: \"component\" | \"page\"; html?: string; surfaces?: (Surface | { id: string })[]; parts?: Record<string, string>; knobs?: Knobs; variantKnobs?: Knobs; from?: number }): Promise<Written>;\n ask(mock: string, asks: Ask[]): Promise<{ asks: { id: string; text: string }[]; url: string }>;\n /** Never waits: what the user sent since you last heard, and what they are doing now. */\n feedback(): Promise<{ feedback: Feedback[]; pending: Pending[] }>;\n say(v: Ref, message: string): Promise<void>;\n export(v: Ref): Promise<{ states: { state: string | null; variant: string; version: number; html: string }[]; reply: unknown }>;\n /** data: base64 bytes. Use id as an image surface's assetId. */\n upload(data: string, opts: { contentType: string; filename?: string; kind?: \"image\" | \"file\" }): Promise<{ id: string; url: string }>;\n};\ndeclare function print(...values: unknown[]): void;";
1
+ export declare const RUN_API = "// The body of an async function, in plain JavaScript (no TypeScript syntax).\n// `await mockpit.*`, `return` a JSON value, print() adds an output line.\n// No network, timers, imports or process. Session, project and agent are fixed\n// for the run; pass the returned session to the next run.\ntype Ref = { mock: string; state?: string; variant?: string };\ntype Knobs = Record<string, unknown>; // path \u2192 [default,min,max,step] | boolean | {type:\"select\",options} | \"#hex\"\ntype Surface =\n | { kind: \"html\"; html: string; kits?: string[] }\n | { kind: \"markdown\"; markdown: string }\n | { kind: \"diff\"; patch?: string; files?: { filename: string; before: string; after: string }[]; layout?: \"unified\" | \"split\" }\n | { kind: \"image\"; assetId: string; alt?: string; caption?: string }\n | { kind: \"terminal\"; text: string; cols?: number; title?: string }\n | { kind: \"mermaid\"; mermaid: string }\n | { kind: \"json\"; data: unknown; title?: string }\n | { kind: \"code\"; code: string; language?: string; lineStart?: number };\ntype Ask = {\n id?: string; text: string; scope?: \"mock\" | \"state\" | \"part\"; state?: string; part?: string; multi?: boolean;\n options: { id?: string; label: string; variant?: string; set?: Knobs }[];\n};\ntype Written = {\n post: { state: string | null; variant: string; version: number }; url: string;\n parts: { state: string | null; parts: { name: string }[] }[];\n partChanges?: { vanished: string[]; renamed: { from: string; to: string }[] };\n nudges?: string[]; suggestedAsk?: Ask; // send it with ask when nudged\n};\ntype Feedback = {\n mock: string | null;\n reply: null | { asks: { ask: string; chosen: { id: string; label: string; other?: true }[]; note?: string }[]; mix: Knobs; tuned: Knobs; comments: { part: string | null; text: string }[] };\n comments: { text: string; state: string | null; variant: string | null }[];\n accepted: { state: string | null; variant: string }[]; archived: { state: string | null; variant: string }[];\n};\ntype Pending = { mock: string; viewerOpen: boolean; draft: null | { answered: number; of: number; comments: number; touchedAt: string } };\ndeclare const mockpit: {\n guide(topic?: \"knobs\" | \"asks\" | \"surfaces\" | \"html\" | \"feedback\" | \"http\" | \"scripts\"): Promise<string>;\n /** No mock: every mock plus pending. With one: its states, variants, asks, parts, knobs. */\n read(mock?: string, opts?: { body?: boolean; history?: boolean }): Promise<unknown>;\n /** Creates or versions (mock, state, variant). One of: html; surfaces, the full ordered list\n * ({id} alone keeps that surface, a missing id is removed); parts {\"name\"|\"name#key\": outer html}. */\n publish(v: Ref & { title?: string; kind?: \"component\" | \"page\"; html?: string; surfaces?: (Surface | { id: string })[]; parts?: Record<string, string>; knobs?: Knobs; variantKnobs?: Knobs; from?: number }): Promise<Written>;\n ask(mock: string, asks: Ask[]): Promise<{ asks: { id: string; text: string }[]; url: string; nudges?: string[] }>;\n /** Never waits: what the user sent since you last heard, and what they are doing now. */\n feedback(): Promise<{ feedback: Feedback[]; pending: Pending[] }>;\n say(v: Ref, message: string): Promise<void>;\n export(v: Ref): Promise<{ states: { state: string | null; variant: string; version: number; html: string }[]; reply: unknown }>;\n /** data: base64 bytes. Use id as an image surface's assetId. */\n upload(data: string, opts: { contentType: string; filename?: string; kind?: \"image\" | \"file\" }): Promise<{ id: string; url: string }>;\n};\ndeclare function print(...values: unknown[]): void;";
2
2
  export declare const RUN_FUNCTIONS: readonly ["guide", "read", "publish", "ask", "feedback", "say", "export", "upload"];
3
3
  export type RunFunction = (typeof RUN_FUNCTIONS)[number];
4
4
  export declare const RUN_EXAMPLE = "const html = (tone) => `<section data-part=\"hero\" class=\"${tone}\"><h1>Writer</h1></section>`;\nawait mockpit.publish({ mock: \"writer\", variant: \"calm\", html: html(\"calm\") });\nawait mockpit.publish({ mock: \"writer\", variant: \"bold\", html: html(\"bold\") });\nconst { url } = await mockpit.ask(\"writer\", [{ id: \"look\", text: \"Which look?\",\n options: [{ label: \"Calm\", variant: \"calm\" }, { label: \"Bold\", variant: \"bold\" }] }]);\n// Tell the user where to look and end the turn; a later run reads mockpit.feedback().\nreturn url;";
@@ -43,7 +43,7 @@ declare const mockpit: {
43
43
  /** Creates or versions (mock, state, variant). One of: html; surfaces, the full ordered list
44
44
  * ({id} alone keeps that surface, a missing id is removed); parts {"name"|"name#key": outer html}. */
45
45
  publish(v: Ref & { title?: string; kind?: "component" | "page"; html?: string; surfaces?: (Surface | { id: string })[]; parts?: Record<string, string>; knobs?: Knobs; variantKnobs?: Knobs; from?: number }): Promise<Written>;
46
- ask(mock: string, asks: Ask[]): Promise<{ asks: { id: string; text: string }[]; url: string }>;
46
+ ask(mock: string, asks: Ask[]): Promise<{ asks: { id: string; text: string }[]; url: string; nudges?: string[] }>;
47
47
  /** Never waits: what the user sent since you last heard, and what they are doing now. */
48
48
  feedback(): Promise<{ feedback: Feedback[]; pending: Pending[] }>;
49
49
  say(v: Ref, message: string): Promise<void>;
@@ -26,7 +26,7 @@ export declare class SqlStore implements Store {
26
26
  private rowToPost;
27
27
  private rowToAsset;
28
28
  private rowToComment;
29
- listSessions(): Promise<Session[]>;
29
+ listSessions(project?: string): Promise<Session[]>;
30
30
  getSession(id: string): Promise<Session | null>;
31
31
  getSessionByKey(key: string): Promise<Session | null>;
32
32
  createSession(input: CreateSessionInput): Promise<Session>;
@@ -34,6 +34,8 @@ export declare class SqlStore implements Store {
34
34
  removeSession(id: string): Promise<boolean>;
35
35
  private touch;
36
36
  markAgentSeen(sessionId: string, seq: number): Promise<void>;
37
+ touchAgent(sessionId: string): Promise<void>;
38
+ handOff(sessionId: string, seen: Pick<Session, "agentSeq" | "agentActiveAt">, toSeq: number): Promise<boolean>;
37
39
  getSetting(key: string): Promise<string | null>;
38
40
  setSetting(key: string, value: string): Promise<void>;
39
41
  listProjects(): Promise<import("./types.ts").ProjectSummary[]>;
@@ -1,4 +1,4 @@
1
- import { collectAssetIds, DEFAULT_PROJECT, DEFAULT_VARIANT, hashAssetId, HISTORY_LIMIT, htmlSurface, MAX_WORKSPACE_ASSET_BYTES, newId, normalizeSurfaceIds, projectFromCwd, reservedAgent, selectEvictions, slugify, stripNul, summarizeProjects, } from "./types.js";
1
+ import { collectAssetIds, collectAttachedIds, DEFAULT_PROJECT, DEFAULT_VARIANT, hashAssetId, HISTORY_LIMIT, htmlSurface, MAX_WORKSPACE_ASSET_BYTES, newId, normalizeSurfaceIds, projectFromCwd, reservedAgent, selectEvictions, slugify, stripNul, summarizeProjects, } from "./types.js";
2
2
  function parseJson(raw, fallback) {
3
3
  if (typeof raw !== "string" || !raw)
4
4
  return fallback;
@@ -65,7 +65,7 @@ export class SqlStore {
65
65
  CREATE TABLE IF NOT EXISTS sessions (
66
66
  id TEXT PRIMARY KEY, agent TEXT NOT NULL, title TEXT, cwd TEXT,
67
67
  createdAt TEXT NOT NULL, lastActiveAt TEXT NOT NULL,
68
- agentSeq INTEGER NOT NULL DEFAULT 0, project TEXT, key TEXT
68
+ agentSeq INTEGER NOT NULL DEFAULT 0, project TEXT, key TEXT, agentActiveAt TEXT
69
69
  );
70
70
  CREATE TABLE IF NOT EXISTS mocks (
71
71
  id TEXT PRIMARY KEY, project TEXT NOT NULL, slug TEXT NOT NULL, title TEXT NOT NULL,
@@ -100,6 +100,7 @@ export class SqlStore {
100
100
  agentSeq: "INTEGER NOT NULL DEFAULT 0",
101
101
  project: "TEXT",
102
102
  key: "TEXT",
103
+ agentActiveAt: "TEXT",
103
104
  });
104
105
  if (legacy)
105
106
  this.migrateLegacy();
@@ -381,6 +382,8 @@ export class SqlStore {
381
382
  lastActiveAt: r.lastActiveAt,
382
383
  agentSeq: r.agentSeq ?? 0,
383
384
  project: r.project ?? null,
385
+ agentActiveAt: r.agentActiveAt ?? null,
386
+ keyed: r.key != null,
384
387
  };
385
388
  }
386
389
  rowToMock(r) {
@@ -458,11 +461,13 @@ export class SqlStore {
458
461
  };
459
462
  }
460
463
  // --- sessions ---
461
- async listSessions() {
462
- return this.sql
463
- .exec("SELECT * FROM sessions ORDER BY lastActiveAt DESC")
464
- .toArray()
465
- .map((r) => this.rowToSession(r));
464
+ async listSessions(project) {
465
+ const rows = project === undefined
466
+ ? this.sql.exec("SELECT * FROM sessions ORDER BY lastActiveAt DESC").toArray()
467
+ : this.sql
468
+ .exec("SELECT * FROM sessions WHERE project = ? ORDER BY lastActiveAt DESC", project)
469
+ .toArray();
470
+ return rows.map((r) => this.rowToSession(r));
466
471
  }
467
472
  async getSession(id) {
468
473
  const rows = this.sql.exec("SELECT * FROM sessions WHERE id = ?", id).toArray();
@@ -485,8 +490,10 @@ export class SqlStore {
485
490
  lastActiveAt: now,
486
491
  agentSeq: 0,
487
492
  project: stripNul(input.project)?.trim() || projectFromCwd(stripNul(input.cwd ?? null)) || null,
493
+ agentActiveAt: now,
494
+ keyed: !!stripNul(input.key)?.trim(),
488
495
  };
489
- this.sql.exec("INSERT INTO sessions (id, agent, title, cwd, createdAt, lastActiveAt, agentSeq, project, key) VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?)", session.id, session.agent, session.title, session.cwd, session.createdAt, session.lastActiveAt, session.project, stripNul(input.key)?.trim() || null);
496
+ this.sql.exec("INSERT INTO sessions (id, agent, title, cwd, createdAt, lastActiveAt, agentSeq, project, key, agentActiveAt) VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?)", session.id, session.agent, session.title, session.cwd, session.createdAt, session.lastActiveAt, session.project, stripNul(input.key)?.trim() || null, session.agentActiveAt);
490
497
  return session;
491
498
  }
492
499
  async renameSession(id, title) {
@@ -528,6 +535,14 @@ export class SqlStore {
528
535
  async markAgentSeen(sessionId, seq) {
529
536
  this.sql.exec("UPDATE sessions SET agentSeq = ? WHERE id = ? AND agentSeq < ?", seq, sessionId, seq);
530
537
  }
538
+ async touchAgent(sessionId) {
539
+ this.sql.exec("UPDATE sessions SET agentActiveAt = ? WHERE id = ?", new Date().toISOString(), sessionId);
540
+ }
541
+ async handOff(sessionId, seen, toSeq) {
542
+ return (this.sql
543
+ .exec("UPDATE sessions SET agentSeq = ? WHERE id = ? AND agentSeq = ? AND agentActiveAt IS ? AND agentSeq < ? RETURNING id", toSeq, sessionId, seen.agentSeq, seen.agentActiveAt, toSeq)
544
+ .toArray().length > 0);
545
+ }
531
546
  // --- settings ---
532
547
  async getSetting(key) {
533
548
  return this.settingSync(key);
@@ -593,6 +608,8 @@ export class SqlStore {
593
608
  updatedAt: new Date().toISOString(),
594
609
  };
595
610
  this.sql.exec("UPDATE mocks SET title = ?, kind = ?, states = ?, asks = ?, knobs = ?, sessionId = ?, updatedAt = ? WHERE id = ?", next.title, next.kind, JSON.stringify(next.states), JSON.stringify(next.asks), JSON.stringify(next.knobs), next.sessionId, next.updatedAt, id);
611
+ if (patch.asks !== undefined)
612
+ this.invalidateAssetRefs();
596
613
  return next;
597
614
  }
598
615
  async removeMock(id) {
@@ -612,6 +629,7 @@ export class SqlStore {
612
629
  if (!mock)
613
630
  return null;
614
631
  this.sql.exec("UPDATE mocks SET draft = ? WHERE id = ?", draft ? JSON.stringify(draft) : null, mockId);
632
+ this.invalidateAssetRefs();
615
633
  return { ...mock, draft };
616
634
  }
617
635
  touchMock(mockId, at) {
@@ -826,6 +844,8 @@ export class SqlStore {
826
844
  return null;
827
845
  const comment = this.rowToComment(rows[0]);
828
846
  this.sql.exec("DELETE FROM comments WHERE id = ?", id);
847
+ if (comment.kind === "reply")
848
+ this.invalidateAssetRefs();
829
849
  this.touch(comment.sessionId);
830
850
  return comment;
831
851
  }
@@ -851,6 +871,7 @@ export class SqlStore {
851
871
  for (const id of input.archive) {
852
872
  this.sql.exec("UPDATE posts SET status = 'archived' WHERE id = ? AND mockId = ?", id, input.mockId);
853
873
  }
874
+ this.invalidateAssetRefs();
854
875
  return comment;
855
876
  });
856
877
  }
@@ -865,6 +886,17 @@ export class SqlStore {
865
886
  for (const r of this.sql.exec("SELECT surfaces FROM post_versions").toArray()) {
866
887
  collectAssetIds(parseJson(r.surfaces, []), out);
867
888
  }
889
+ // The user's attachments: in a draft, in a sent reply, on an answered ask.
890
+ for (const r of this.sql.exec("SELECT asks, draft FROM mocks").toArray()) {
891
+ collectAttachedIds(parseJson(r.draft, null), out);
892
+ for (const ask of parseJson(r.asks, []))
893
+ collectAttachedIds(ask, out);
894
+ }
895
+ for (const r of this.sql
896
+ .exec("SELECT payload FROM comments WHERE kind = 'reply' AND payload IS NOT NULL")
897
+ .toArray()) {
898
+ collectAttachedIds(parseJson(r.payload, null), out);
899
+ }
868
900
  // An installed icon set is referenced by its project's design, not by a
869
901
  // surface; evicting it would blank that project's icons.
870
902
  for (const r of this.sql
@@ -8,6 +8,8 @@ export interface Session {
8
8
  lastActiveAt: string;
9
9
  agentSeq: number;
10
10
  project: string | null;
11
+ agentActiveAt: string | null;
12
+ keyed: boolean;
11
13
  }
12
14
  export declare const SURFACE_KINDS: readonly ["html", "diff", "image", "markdown", "terminal", "mermaid", "json", "code"];
13
15
  export type SurfaceKind = (typeof SURFACE_KINDS)[number];
@@ -214,6 +216,8 @@ export interface Ask {
214
216
  answer?: AskAnswer;
215
217
  other?: string;
216
218
  note?: string;
219
+ otherImages?: string[];
220
+ noteImages?: string[];
217
221
  at: string;
218
222
  }
219
223
  export declare const OTHER_ID = "other";
@@ -222,12 +226,17 @@ export interface PartCommentAnchor {
222
226
  quote?: string;
223
227
  selector?: string;
224
228
  box?: number[];
229
+ width?: number;
225
230
  }
231
+ export declare const REACTIONS: readonly ["up", "down", "more"];
232
+ export type Reaction = (typeof REACTIONS)[number];
226
233
  export interface PartComment {
227
234
  part: string | null;
228
235
  state: string | null;
229
236
  text: string;
230
237
  anchor?: PartCommentAnchor;
238
+ reaction?: Reaction;
239
+ variant?: string;
231
240
  }
232
241
  export interface ReplyDecision {
233
242
  kind: "accept" | "revise" | "drop";
@@ -242,6 +251,8 @@ export interface Draft {
242
251
  comments: PartComment[];
243
252
  others?: Record<string, string>;
244
253
  notes?: Record<string, string>;
254
+ otherImages?: Record<string, string[]>;
255
+ noteImages?: Record<string, string[]>;
245
256
  updatedAt: string;
246
257
  }
247
258
  export interface Reply {
@@ -253,6 +264,8 @@ export interface Reply {
253
264
  comments: PartComment[];
254
265
  others?: Record<string, string>;
255
266
  notes?: Record<string, string>;
267
+ otherImages?: Record<string, string[]>;
268
+ noteImages?: Record<string, string[]>;
256
269
  text?: string;
257
270
  decision?: ReplyDecision;
258
271
  }
@@ -523,13 +536,15 @@ export interface PostQuery {
523
536
  sessionId?: string;
524
537
  }
525
538
  export interface Store {
526
- listSessions(): Promise<Session[]>;
539
+ listSessions(project?: string): Promise<Session[]>;
527
540
  getSession(id: string): Promise<Session | null>;
528
541
  getSessionByKey(key: string): Promise<Session | null>;
529
542
  createSession(input: CreateSessionInput): Promise<Session>;
530
543
  renameSession(id: string, title: string): Promise<Session | null>;
531
544
  removeSession(id: string): Promise<boolean>;
532
545
  markAgentSeen(sessionId: string, seq: number): Promise<void>;
546
+ touchAgent(sessionId: string): Promise<void>;
547
+ handOff(sessionId: string, seen: Pick<Session, "agentSeq" | "agentActiveAt">, toSeq: number): Promise<boolean>;
533
548
  getSetting(key: string): Promise<string | null>;
534
549
  setSetting(key: string, value: string): Promise<void>;
535
550
  listProjects(): Promise<ProjectSummary[]>;
@@ -572,6 +587,8 @@ export declare const HISTORY_LIMIT = 20;
572
587
  export declare function reservedAgent(name: string): string;
573
588
  export declare function stripNul<T extends string | null | undefined>(s: T): T;
574
589
  export declare const MAX_ASSET_BYTES: number;
590
+ export declare const ATTACHABLE_IMAGE_TYPES: ReadonlySet<string>;
591
+ export declare const MAX_ASK_IMAGES = 6;
575
592
  export declare const MAX_WORKSPACE_ASSET_BYTES: number;
576
593
  export declare const newId: () => string;
577
594
  export declare function hashAssetId(data: Uint8Array): Promise<string>;
@@ -583,13 +600,19 @@ export declare function projectFromCwd(cwd: string | null | undefined): string |
583
600
  export declare const htmlSurface: (html: string, kits?: unknown) => HtmlSurface;
584
601
  export declare const askAnswered: (a: Pick<Ask, "answer" | "other" | "note">) => boolean;
585
602
  export declare const openAsks: (mock: Pick<Mock, "asks">) => Ask[];
586
- export declare function builtinAskCount(mock: Pick<Mock, "asks" | "states">, posts: Pick<Post, "mock" | "state" | "variant" | "status">[], mockId: string): number;
603
+ export declare function builtinAskIds(mock: Pick<Mock, "asks" | "states">, posts: Pick<Post, "mock" | "state" | "variant" | "status">[], mockId: string): string[];
604
+ export declare const builtinAskCount: (mock: Pick<Mock, "asks" | "states">, posts: Pick<Post, "mock" | "state" | "variant" | "status">[], mockId: string) => number;
587
605
  export declare const openCount: (mock: Pick<Mock, "id" | "asks" | "states">, posts: Pick<Post, "mock" | "state" | "variant" | "status">[]) => number;
606
+ export declare function draftedCount(mock: Pick<Mock, "id" | "asks" | "states" | "draft">, posts: Pick<Post, "mock" | "state" | "variant" | "status">[]): number | null;
588
607
  export declare const BUILTIN_ASK_ID = "variant";
589
608
  export declare function builtinAsk(mock: Pick<Mock, "states">, posts: Pick<Post, "state" | "variant">[], id: string): Ask | undefined;
590
609
  export declare function summarizeProjects(mocks: Mock[], sessions: Session[], posts?: Post[]): ProjectSummary[];
591
610
  export declare function surfacesByteLength(surfaces: Surface[]): number;
592
611
  export declare function collectAssetIds(surfaces: Surface[], out: Set<string>): void;
612
+ export declare function collectAttachedIds(holder: {
613
+ otherImages?: unknown;
614
+ noteImages?: unknown;
615
+ } | null | undefined, out: Set<string>): void;
593
616
  export interface EvictionCandidate {
594
617
  id: string;
595
618
  byteLength: number;
@@ -54,6 +54,10 @@ export function isSandboxedSurfaceKind(kind) {
54
54
  // The viewer's write-in option on every ask. Reserved, so a delivered
55
55
  // `{id: "other", other: true}` choice never shadows an option the agent declared.
56
56
  export const OTHER_ID = "other";
57
+ // A one-tap verdict on a part (or, with part null, on the variant itself),
58
+ // for a reviewer on a phone who would rather not type: like, dislike, or
59
+ // "more like this". Rides as a part comment, so it travels in the one reply.
60
+ export const REACTIONS = ["up", "down", "more"];
57
61
  export const HISTORY_LIMIT = 20;
58
62
  // "user" is the reserved trust label for genuine human comments. A session
59
63
  // agent with that name could otherwise have its programmatic comments delivered
@@ -73,6 +77,15 @@ export function stripNul(s) {
73
77
  // budget the store evicts down to. One Durable Object holds the whole workspace, so
74
78
  // the budget sits well under its ~10 GB SQLite ceiling.
75
79
  export const MAX_ASSET_BYTES = 5 * 1024 * 1024;
80
+ // What the user may attach to a write-in or a note: raster formats a model can
81
+ // read. SVG is active content, so it is never attachable.
82
+ export const ATTACHABLE_IMAGE_TYPES = new Set([
83
+ "image/png",
84
+ "image/jpeg",
85
+ "image/webp",
86
+ "image/gif",
87
+ ]);
88
+ export const MAX_ASK_IMAGES = 6;
76
89
  export const MAX_WORKSPACE_ASSET_BYTES = 2 * 1024 * 1024 * 1024;
77
90
  // Short, unguessable id: 8 random bytes (64 bits) as 11 url-safe base64 chars.
78
91
  // These double as bearer capabilities: in publicRead mode `/s/:id` and
@@ -145,7 +158,7 @@ export const openAsks = (mock) => mock.asks.filter((a) => !askAnswered(a));
145
158
  // rule as viewer/src/logic.ts builtinAsks, so Home, `read` and `pending` count
146
159
  // the question the user actually sees. Answering it archives the losers, which
147
160
  // is what makes it stop counting.
148
- export function builtinAskCount(mock, posts, mockId) {
161
+ export function builtinAskIds(mock, posts, mockId) {
149
162
  const live = (state) => posts
150
163
  .filter((p) => p.mock === mockId && p.state === state && p.status !== "archived")
151
164
  .map((p) => p.variant);
@@ -159,14 +172,30 @@ export function builtinAskCount(mock, posts, mockId) {
159
172
  const states = mock.states.length ? mock.states : [null];
160
173
  const needing = states.filter(needs);
161
174
  if (!needing.length)
162
- return 0;
175
+ return [];
163
176
  const key = (state) => [...live(state)].sort().join("\n");
164
177
  const first = key(needing[0]);
165
178
  return needing.length === states.length && states.every((st) => key(st) === first)
166
- ? 1
167
- : needing.length;
179
+ ? [BUILTIN_ASK_ID]
180
+ : needing.map((st) => (st === null ? BUILTIN_ASK_ID : `${BUILTIN_ASK_ID}:${st}`));
168
181
  }
182
+ export const builtinAskCount = (mock, posts, mockId) => builtinAskIds(mock, posts, mockId).length;
169
183
  export const openCount = (mock, posts) => openAsks(mock).length + builtinAskCount(mock, posts, mock.id);
184
+ // How many of the open asks the user's unsent draft already speaks for (a
185
+ // pick, a write-in or a note), so Home can show a started mock as "draft 2/4";
186
+ // null when there is no draft. A count only: the draft's content stays private.
187
+ export function draftedCount(mock, posts) {
188
+ const d = mock.draft;
189
+ if (!d)
190
+ return null;
191
+ const open = [...openAsks(mock).map((a) => a.id), ...builtinAskIds(mock, posts, mock.id)];
192
+ const said = (id) => {
193
+ const a = d.answers[id];
194
+ const picked = Array.isArray(a) ? a.length > 0 : a !== undefined;
195
+ return picked || d.others?.[id] !== undefined || d.notes?.[id] !== undefined;
196
+ };
197
+ return open.filter(said).length;
198
+ }
170
199
  // The viewer's built-in "Which one?" for variants no agent ask binds: `variant`
171
200
  // mock-wide, `variant:<state>` per state. Never stored; synthesized from the
172
201
  // variants so a reply answering it validates, flips and reads like any ask. An
@@ -275,6 +304,20 @@ export function collectAssetIds(surfaces, out) {
275
304
  out.add(p.assetId);
276
305
  }
277
306
  }
307
+ // The images a draft, a reply or a sent ask carries (all keep the user's
308
+ // attachments in otherImages / noteImages).
309
+ export function collectAttachedIds(holder, out) {
310
+ for (const map of [holder?.otherImages, holder?.noteImages]) {
311
+ if (!map || typeof map !== "object")
312
+ continue;
313
+ for (const ids of Array.isArray(map) ? [map] : Object.values(map)) {
314
+ if (Array.isArray(ids))
315
+ for (const id of ids)
316
+ if (typeof id === "string")
317
+ out.add(id);
318
+ }
319
+ }
320
+ }
278
321
  // Pick the assets to evict so `incomingBytes` fits under `budget`. Oldest
279
322
  // (lastAccessedAt) first, but unreferenced assets go before referenced ones —
280
323
  // a live embed is only evicted as a last resort, once unreferenced candidates