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
@@ -2,11 +2,15 @@ import { Hono } from "hono";
2
2
  import { bodyLimit } from "hono/body-limit";
3
3
  import { getCookie, setCookie } from "hono/cookie";
4
4
  import { streamSSE } from "hono/streaming";
5
+ import { ICON_SIZES, ICON_SVG, themeColor, webManifest } from "./appIcon.js";
6
+ import { ICON_PNG_BASE64 } from "./appIconPng.js";
5
7
  import { decodeBase64 } from "./base64.js";
6
8
  import { mockDetailView, mockSummaryView, sessionRowView, surfaceRef } from "./apiViews.js";
9
+ import { askNudges } from "./askNudges.js";
7
10
  import { EventBus } from "./events.js";
8
11
  import { GUIDE_TOPICS, isGuideTopic, renderBriefGuide, unknownTopicMessage, } from "./designGuide.js";
9
12
  import { buildFeedbackBatches } from "./feedbackBatch.js";
13
+ import { HANDOFF_NOTE, handoffReady, supersedes } from "./handoff.js";
10
14
  import { bundledIconSets, expandIcons, iconCount, isIconName, mayHaveIcons, parseIconSet, resolverFor, } from "./icons.js";
11
15
  import { checkProjectKit, KIT_IDS, kitSummaries } from "./kits.js";
12
16
  import { checkKnobs, checkKnobValues, discreteChoices } from "./knobs.js";
@@ -16,10 +20,11 @@ import { addHook, addSubscription, isPushSubscription, listHooks, notify, remove
16
20
  import { expandSlots, parseSlotTags } from "./slots.js";
17
21
  import { registerMcp } from "./mcpHttp.js";
18
22
  import { createRunFlow, DEFAULT_RUN_LIMITS } from "./run.js";
23
+ import { logMiddleware, writeLog } from "./eventLog.js";
19
24
  import { RUN_API } from "./runApi.js";
20
25
  import { escapeHtml, renderHtmlPage, renderMermaidPage, renderSandboxedPart, STATIC_ASSET_PREFIX, staticAsset, } from "./surfacePage.js";
21
26
  import { DEFAULT_MODE, DEFAULT_THEME_ID, isMode, themeById } from "./themes.js";
22
- import { BUILTIN_ASK_ID, builtinAsk, DEFAULT_PROJECT, DEFAULT_VARIANT, htmlSurface, isSandboxedSurfaceKind, MAX_ASSET_BYTES, newId, openCount, OTHER_ID, projectFromCwd, reservedAgent, slugify, surfacesByteLength, } from "./types.js";
27
+ import { builtinAsk, DEFAULT_PROJECT, DEFAULT_VARIANT, htmlSurface, isSandboxedSurfaceKind, ATTACHABLE_IMAGE_TYPES, MAX_ASK_IMAGES, MAX_ASSET_BYTES, newId, openCount, OTHER_ID, REACTIONS, projectFromCwd, reservedAgent, slugify, surfacesByteLength, } from "./types.js";
23
28
  import { coerceSurfaces, validateSurfaces, } from "./postSurfaces.js";
24
29
  const MAX_SURFACE_BYTES = 2 * 1024 * 1024;
25
30
  // A held long-poll (watch, the viewer) re-arms rather than pinning a socket
@@ -165,8 +170,14 @@ const MAX_LABEL = 120;
165
170
  const MAX_ASKS = 20;
166
171
  const MAX_OPTIONS = 20;
167
172
  const MAX_DRAFT_COMMENTS = 50;
173
+ const APP_FILES = new Set([
174
+ "/manifest.webmanifest",
175
+ "/icon.svg",
176
+ "/apple-touch-icon.png",
177
+ ...ICON_SIZES.map((s) => `/icon-${s}.png`),
178
+ ]);
168
179
  const titleFromSlug = (slug) => slug.replace(/-/g, " ").replace(/^./, (ch) => ch.toUpperCase());
169
- export function createApp({ store, viewerHtml, setupText, topics = {}, authenticate, authToken, basePath, publicRead, screenshots, version, upgradeCommand, fetchLatestRelease, onEvent, maxHoldConnections = DEFAULT_MAX_HOLD_CONNECTIONS, executor, runLimits, }) {
180
+ export function createApp({ store, viewerHtml, setupText, topics = {}, authenticate, authToken, basePath, publicRead, screenshots, version, upgradeCommand, fetchLatestRelease, onEvent, maxHoldConnections = DEFAULT_MAX_HOLD_CONNECTIONS, executor, runLimits, log, }) {
170
181
  const app = new Hono();
171
182
  // The scripts topic carries the run API from runApi.ts, its one source.
172
183
  if (topics.scripts)
@@ -188,6 +199,8 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
188
199
  c.header("Referrer-Policy", "no-referrer");
189
200
  return next();
190
201
  });
202
+ if (log)
203
+ app.use("*", logMiddleware(log));
191
204
  const bus = new EventBus();
192
205
  if (onEvent) {
193
206
  bus.subscribe((event) => {
@@ -203,9 +216,10 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
203
216
  // Each holder increments on entry and releases exactly once via a guarded
204
217
  // release() wired to every exit (stream abort, request abort, normal return).
205
218
  let holdConnections = 0;
206
- // Only the viewer holds /api/events (watch long-polls), so an attached SSE
207
- // client means someone has the workspace open.
208
- let viewerClients = 0;
219
+ // Only the viewer holds /api/events (watch long-polls). Each tab names the
220
+ // mock it has on screen (`?viewing=`), so pending.viewerOpen is per mock: a
221
+ // tab on Home or on another mock doesn't read as "looking at this one".
222
+ const viewing = new Map();
209
223
  const acquireHold = () => {
210
224
  if (holdConnections >= maxHoldConnections)
211
225
  return false;
@@ -327,31 +341,93 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
327
341
  return value;
328
342
  }
329
343
  // --- shared flows (used by both the REST API and the MCP endpoint) ---
344
+ // Sessions with an author=user long-poll held open right now: a waiting
345
+ // `mockpit watch` is a live agent, whatever its agentActiveAt says.
346
+ const listening = new Map();
330
347
  // User comments the agent has not seen yet ride along on its next write, so
331
348
  // agents hear feedback without blocking on the long-poll. The cursor also
332
349
  // advances past the agent's own comments to keep reads cheap.
333
- async function collectFeedback(sessionId) {
350
+ async function collectFeedback(sessionId, base) {
351
+ // Touch before reading the cursor: a taker's handOff checks agentActiveAt,
352
+ // so it can't move this cursor under a read already in flight.
353
+ await store.touchAgent(sessionId);
334
354
  const session = await store.getSession(sessionId);
335
355
  if (!session)
336
356
  return [];
357
+ const handedOff = await takeOrphaned(session);
337
358
  const fresh = await store.listComments({ sessionId, afterSeq: session.agentSeq });
338
- if (fresh.length === 0)
339
- return [];
340
- await store.markAgentSeen(sessionId, fresh[fresh.length - 1].seq);
341
- bus.broadcast({ type: "comment-delivered", sessionId, seq: fresh[fresh.length - 1].seq });
359
+ if (fresh.length > 0) {
360
+ await store.markAgentSeen(sessionId, fresh[fresh.length - 1].seq);
361
+ bus.broadcast({ type: "comment-delivered", sessionId, seq: fresh[fresh.length - 1].seq });
362
+ }
342
363
  const feedback = fresh.filter((cm) => cm.author === "user");
343
- return feedback.length > 0 ? await buildFeedbackBatches(store, feedback) : [];
364
+ return feedbackWithHandoffs(feedback, handedOff, base);
365
+ }
366
+ async function feedbackWithHandoffs(own, handedOff, base) {
367
+ const mine = own.length > 0 ? await buildFeedbackBatches(store, own, base) : [];
368
+ if (handedOff.length === 0)
369
+ return mine;
370
+ const theirs = await buildFeedbackBatches(store, handedOff, base);
371
+ return [...mine, ...theirs.map((b) => ({ ...b, handoff: HANDOFF_NOTE }))];
372
+ }
373
+ // The user comments still owed to sessions `taker` supersedes (handoff.ts),
374
+ // taken for `taker`: each owner's cursor moves past them in one conditional
375
+ // write, so delivery stays exactly-once across sessions.
376
+ async function takeOrphaned(taker) {
377
+ if (!taker.project)
378
+ return [];
379
+ const now = Date.now();
380
+ const taken = [];
381
+ for (const owner of await store.listSessions(taker.project)) {
382
+ if (!supersedes(taker, owner, listening.has(owner.id)))
383
+ continue;
384
+ const owed = await store.listComments({ sessionId: owner.id, afterSeq: owner.agentSeq });
385
+ // Seq order is time order, so the ready comments are a prefix; stop at the
386
+ // first one still inside its grace and leave the rest owed.
387
+ let upto = owner.agentSeq;
388
+ const take = [];
389
+ for (const c of owed) {
390
+ if (c.author === "user" && !handoffReady(c, owner, taker, now))
391
+ break;
392
+ upto = c.seq;
393
+ if (c.author === "user")
394
+ take.push(c);
395
+ }
396
+ if (take.length === 0)
397
+ continue;
398
+ if (!(await store.handOff(owner.id, owner, upto)))
399
+ continue;
400
+ bus.broadcast({ type: "comment-delivered", sessionId: owner.id, seq: upto });
401
+ taken.push(...take);
402
+ }
403
+ return taken;
344
404
  }
345
405
  // Per-comment delivery state for the viewer: `delivered` once the session's
346
- // agentSeq has passed the comment. Computed, never stored.
406
+ // agentSeq has passed the comment. Computed, never stored. `ownerGone` marks
407
+ // an undelivered user comment whose session a newer one already supersedes,
408
+ // so the Thread can say the next agent here will get it.
347
409
  async function withDelivered(comments) {
348
- const cursors = new Map();
410
+ const sessions = new Map();
349
411
  for (const c of comments) {
350
- if (!cursors.has(c.sessionId)) {
351
- cursors.set(c.sessionId, (await store.getSession(c.sessionId))?.agentSeq ?? 0);
412
+ if (!sessions.has(c.sessionId))
413
+ sessions.set(c.sessionId, await store.getSession(c.sessionId));
414
+ }
415
+ const gone = new Map();
416
+ const ownerGone = async (owner) => {
417
+ if (!gone.has(owner.id)) {
418
+ const peers = owner.project ? await store.listSessions(owner.project) : [];
419
+ gone.set(owner.id, peers.some((t) => supersedes(t, owner, listening.has(owner.id))));
352
420
  }
421
+ return gone.get(owner.id);
422
+ };
423
+ const out = [];
424
+ for (const c of comments) {
425
+ const owner = sessions.get(c.sessionId);
426
+ const delivered = c.seq <= (owner?.agentSeq ?? 0);
427
+ const waiting = !delivered && !!owner && c.author === "user" && (await ownerGone(owner));
428
+ out.push({ ...c, delivered, ...(waiting ? { ownerGone: true } : {}) });
353
429
  }
354
- return comments.map((c) => ({ ...c, delivered: c.seq <= (cursors.get(c.sessionId) ?? 0) }));
430
+ return out;
355
431
  }
356
432
  // The workspace's light/dark choice. Older workspaces stored a theme id under
357
433
  // `theme`; that registry is gone, so they start from the default mode.
@@ -665,8 +741,8 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
665
741
  const out = [];
666
742
  for (const [path, config] of Object.entries(knobs)) {
667
743
  const n = discreteChoices(config);
668
- if (n !== null && n <= 3) {
669
- out.push(`knob "${path}" has ${n} discrete options: if each option needs its own render, ask instead (ask with options bound to variants); keep a knob when one render plus a control shows it`);
744
+ if (n !== null && n >= 2 && n <= 3) {
745
+ out.push(`knob "${path}": a 2–3 option knob is a choice. Ask with options that \`set\` it.`);
670
746
  }
671
747
  }
672
748
  return out;
@@ -680,35 +756,67 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
680
756
  const names = live(state);
681
757
  if (names.length < 2)
682
758
  return null;
759
+ const applies = (a) => a.scope !== "part" && !(a.scope === "state" && a.state !== state);
683
760
  const bound = mock.asks.some((a) => {
684
- if (a.scope === "part" || (a.scope === "state" && a.state !== state))
761
+ if (!applies(a))
685
762
  return false;
686
763
  const variants = new Set(a.options.map((o) => o.variant));
687
764
  return names.every((n) => variants.has(n));
688
765
  });
689
766
  if (bound)
690
767
  return null;
768
+ const where = state === null ? mock.slug : `${mock.slug} state "${state}"`;
691
769
  const key = (list) => [...list].sort().join("\n");
692
770
  const states = mock.states.length ? mock.states : [null];
693
771
  const wide = states.every((s) => key(live(s)) === key(names));
772
+ // An ask that already picks between some of these variants is the question:
773
+ // a second, generic "Which one?" would make the user choose the look twice.
774
+ // A mock-wide one only fits while every state has the same variants; else a
775
+ // variant only some states have would leave the others no real choice.
776
+ const existing = mock.asks.find((a) => applies(a) && a.options.some((o) => o.variant) && (a.scope === "state" || wide));
777
+ if (existing) {
778
+ const have = new Set(existing.options.map((o) => o.variant));
779
+ const missing = names.filter((n) => !have.has(n));
780
+ const ask = {
781
+ id: existing.id,
782
+ text: existing.text,
783
+ scope: existing.scope,
784
+ ...(existing.state ? { state: existing.state } : {}),
785
+ ...(existing.multi ? { multi: true } : {}),
786
+ options: [
787
+ ...existing.options.map((o) => ({
788
+ id: o.id,
789
+ label: o.label,
790
+ ...(o.variant ? { variant: o.variant } : {}),
791
+ ...(o.set ? { set: o.set } : {}),
792
+ })),
793
+ ...missing.map((v) => ({ label: v, variant: v })),
794
+ ],
795
+ };
796
+ return {
797
+ nudge: `${where}: bind ${missing.map((m) => `"${m}"`).join(", ")} to your existing ask "${existing.id}": call ask with suggestedAsk. Never ask in chat.`,
798
+ ask,
799
+ };
800
+ }
694
801
  const options = names.map((v) => ({ label: v, variant: v }));
802
+ // Not the built-in's reserved id: a stored ask under it would collide with the
803
+ // built-in the viewer synthesizes once a new variant goes unbound. Nor any id
804
+ // already taken, which sending this would silently replace.
805
+ const base = wide ? "look" : `look-${slugify(state ?? "")}`;
806
+ let id = base;
807
+ for (let n = 2; mock.asks.some((a) => a.id === id); n++)
808
+ id = `${base}-${n}`;
695
809
  const ask = wide
696
- ? { id: BUILTIN_ASK_ID, text: "Which one?", scope: "mock", options }
697
- : {
698
- id: `${BUILTIN_ASK_ID}-${slugify(state ?? "")}`,
699
- text: "Which one?",
700
- scope: "state",
701
- state,
702
- options,
703
- };
704
- const where = state === null ? mock.slug : `${mock.slug} state "${state}"`;
810
+ ? { id, text: "Which one?", scope: "mock", options }
811
+ : { id, text: "Which one?", scope: "state", state, options };
705
812
  return {
706
- nudge: `${where} has ${names.length} variants and no ask binds them: call ask with suggestedAsk (or your own question with options bound to the variants) so the user can choose in one Send; never ask in chat`,
813
+ nudge: `${where} has ${names.length} variants and no ask binds them: call ask with suggestedAsk. Never ask in chat.`,
707
814
  ask,
708
815
  };
709
816
  }
710
817
  // The answer to every write: what was published, the parts found per state,
711
- // what moved since the previous version, and any feedback waiting.
818
+ // what moved since the previous version, and any feedback waiting — for the
819
+ // session that wrote, which on a new version may not be the variant's own.
712
820
  async function writeResult(mock, post, ctx, extra = {}) {
713
821
  const posts = await store.listPosts({ mockId: mock.id });
714
822
  const detail = mockDetailView(mock, posts);
@@ -719,7 +827,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
719
827
  partChanges = diffParts(partsInSurfaces(extra.previous), partsInSurfaces(post.surfaces));
720
828
  }
721
829
  const warnings = await iconWarnings(mock.project, post.surfaces);
722
- const feedback = await collectFeedback(post.sessionId);
830
+ const feedback = await collectFeedback(extra.writer?.id ?? post.sessionId, ctx.base);
723
831
  return ok({
724
832
  mock: {
725
833
  id: mock.id,
@@ -943,7 +1051,12 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
943
1051
  announcePost(mock, updated, false);
944
1052
  if (prompt && ctx.request)
945
1053
  notifyPublish(mock, updated, prompt, ctx);
946
- return writeResult(mock, updated, ctx, { previous: target.surfaces, applied, nudges });
1054
+ return writeResult(mock, updated, ctx, {
1055
+ previous: target.surfaces,
1056
+ applied,
1057
+ nudges,
1058
+ writer: session,
1059
+ });
947
1060
  }
948
1061
  // `parts`: new outer html per part ("name" or "name#key"), spliced into the
949
1062
  // variant's html so a tweak costs the tweak rather than the whole document.
@@ -1227,7 +1340,8 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1227
1340
  text: added.map((a) => a.text).join(" · "),
1228
1341
  url: mockUrl(ctx.base, mock),
1229
1342
  });
1230
- const feedback = session ? await collectFeedback(session.id) : [];
1343
+ const feedback = session ? await collectFeedback(session.id, ctx.base) : [];
1344
+ const nudges = askNudges(added, mock.asks);
1231
1345
  return ok({
1232
1346
  mock: mock.slug,
1233
1347
  mockId: mock.id,
@@ -1235,11 +1349,12 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1235
1349
  asks: added,
1236
1350
  open: openCount(mock, posts),
1237
1351
  url: mockUrl(ctx.base, mock),
1352
+ ...(nudges.length ? { nudges } : {}),
1238
1353
  feedback,
1239
1354
  });
1240
1355
  }
1241
1356
  // --- drafts and replies (the user's side; viewer origin only) ---
1242
- function sanitizePartComments(raw, mock) {
1357
+ function sanitizePartComments(raw, mock, posts) {
1243
1358
  if (raw === undefined || raw === null)
1244
1359
  return [];
1245
1360
  if (!Array.isArray(raw))
@@ -1248,16 +1363,39 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1248
1363
  return `at most ${MAX_DRAFT_COMMENTS} comments`;
1249
1364
  const out = [];
1250
1365
  for (const c of raw) {
1366
+ const reaction = c?.reaction;
1367
+ if (reaction !== undefined && !REACTIONS.includes(reaction))
1368
+ return `reaction must be one of ${REACTIONS.join(", ")}`;
1251
1369
  const text = str(c?.text, MAX_COMMENT_TEXT);
1252
- if (!text)
1370
+ if (!text && !reaction)
1253
1371
  return "every comment needs text";
1254
1372
  const state = stateArg(c.state) ?? null;
1255
1373
  if (state !== null && !mock.states.includes(state))
1256
1374
  return `${mock.slug} has no state "${state}"`;
1257
1375
  const part = str(c.part, 200) ?? null;
1376
+ const variant = str(c.variant, 200);
1377
+ if (variant && !posts.some((p) => p.variant === variant && p.state === state))
1378
+ return `${mock.slug} has no variant "${variant}"${state ? ` in "${state}"` : ""}`;
1258
1379
  const a = c.anchor && typeof c.anchor === "object" ? c.anchor : null;
1259
1380
  const anchor = a ? sanitizePartAnchor(a) : undefined;
1260
- out.push({ part, state, text, ...(anchor ? { anchor } : {}) });
1381
+ const next = {
1382
+ part,
1383
+ state,
1384
+ text: text ?? "",
1385
+ ...(anchor ? { anchor } : {}),
1386
+ ...(reaction ? { reaction: reaction } : {}),
1387
+ ...(variant ? { variant } : {}),
1388
+ };
1389
+ // One reaction per target: a later tap on the same part replaces the earlier one.
1390
+ if (reaction) {
1391
+ const same = out.findIndex((o) => o.reaction &&
1392
+ o.part === part &&
1393
+ o.state === state &&
1394
+ (o.variant ?? null) === (variant ?? null));
1395
+ if (same >= 0)
1396
+ out.splice(same, 1);
1397
+ }
1398
+ out.push(next);
1261
1399
  }
1262
1400
  return out;
1263
1401
  }
@@ -1270,6 +1408,9 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1270
1408
  : undefined;
1271
1409
  const quote = str(a.quote, 200);
1272
1410
  const selector = str(a.selector, 500);
1411
+ const width = typeof a.width === "number" && Number.isFinite(a.width) && a.width >= 1 && a.width <= 10000
1412
+ ? Math.round(a.width)
1413
+ : undefined;
1273
1414
  if (!offset && !box && !quote && !selector)
1274
1415
  return undefined;
1275
1416
  return {
@@ -1277,6 +1418,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1277
1418
  ...(quote ? { quote } : {}),
1278
1419
  ...(selector ? { selector } : {}),
1279
1420
  ...(box ? { box } : {}),
1421
+ ...(width ? { width } : {}),
1280
1422
  };
1281
1423
  }
1282
1424
  // A stored ask, else the viewer's built-in variant ask under its reserved id.
@@ -1325,6 +1467,37 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1325
1467
  }
1326
1468
  return out;
1327
1469
  }
1470
+ // askId → attached image ids: known asks, ids of stored raster images only,
1471
+ // deduped and capped, empty lists dropped.
1472
+ async function sanitizeAskImages(raw, mock, posts, key) {
1473
+ if (raw === undefined || raw === null)
1474
+ return {};
1475
+ if (typeof raw !== "object" || Array.isArray(raw))
1476
+ return `"${key}" must be an object`;
1477
+ const out = {};
1478
+ for (const [askId, value] of Object.entries(raw)) {
1479
+ if (!askById(mock, posts, askId))
1480
+ return `${mock.slug} has no ask "${askId}"`;
1481
+ if (!Array.isArray(value) || value.some((id) => typeof id !== "string")) {
1482
+ return `${key} "${askId}" must be a list of asset ids`;
1483
+ }
1484
+ const ids = [...new Set(value)];
1485
+ if (ids.length > MAX_ASK_IMAGES) {
1486
+ return `${key} "${askId}" takes at most ${MAX_ASK_IMAGES} images`;
1487
+ }
1488
+ for (const id of ids) {
1489
+ const asset = await store.getAsset(id);
1490
+ if (!asset)
1491
+ return `asset "${id}" not found`;
1492
+ if (!ATTACHABLE_IMAGE_TYPES.has(asset.contentType)) {
1493
+ return `asset "${id}" is not an attachable image`;
1494
+ }
1495
+ }
1496
+ if (ids.length)
1497
+ out[askId] = ids;
1498
+ }
1499
+ return out;
1500
+ }
1328
1501
  function sanitizeMix(raw, posts) {
1329
1502
  if (raw === undefined || raw === null)
1330
1503
  return {};
@@ -1344,7 +1517,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1344
1517
  }
1345
1518
  // A draft, validated against the mock as it is now: unknown asks, options,
1346
1519
  // variants, knob paths or out-of-range values are refused, never stored.
1347
- function sanitizeDraft(raw, mock, posts, base) {
1520
+ async function sanitizeDraft(raw, mock, posts, base) {
1348
1521
  const pick = (key) => (raw?.[key] !== undefined ? raw[key] : base?.[key]);
1349
1522
  const answers = sanitizeAnswers(pick("answers"), mock, posts);
1350
1523
  if (typeof answers === "string")
@@ -1355,7 +1528,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1355
1528
  const tuned = checkKnobValues(pick("tuned"), mock, posts, "tuned");
1356
1529
  if (!tuned.ok)
1357
1530
  return tuned.error;
1358
- const comments = sanitizePartComments(pick("comments"), mock);
1531
+ const comments = sanitizePartComments(pick("comments"), mock, posts);
1359
1532
  if (typeof comments === "string")
1360
1533
  return comments;
1361
1534
  const others = sanitizeAskTexts(pick("others"), mock, posts, "others");
@@ -1364,6 +1537,16 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1364
1537
  const notes = sanitizeAskTexts(pick("notes"), mock, posts, "notes");
1365
1538
  if (typeof notes === "string")
1366
1539
  return notes;
1540
+ const otherImages = await sanitizeAskImages(pick("otherImages"), mock, posts, "otherImages");
1541
+ if (typeof otherImages === "string")
1542
+ return otherImages;
1543
+ const noteImages = await sanitizeAskImages(pick("noteImages"), mock, posts, "noteImages");
1544
+ if (typeof noteImages === "string")
1545
+ return noteImages;
1546
+ for (const askId of Object.keys(otherImages))
1547
+ others[askId] ??= "";
1548
+ for (const askId of Object.keys(noteImages))
1549
+ notes[askId] ??= "";
1367
1550
  for (const askId of Object.keys(others)) {
1368
1551
  const ask = askById(mock, posts, askId);
1369
1552
  if (!ask?.multi && answers[askId] !== undefined)
@@ -1381,6 +1564,8 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1381
1564
  comments,
1382
1565
  others,
1383
1566
  notes,
1567
+ ...(Object.keys(otherImages).length ? { otherImages } : {}),
1568
+ ...(Object.keys(noteImages).length ? { noteImages } : {}),
1384
1569
  updatedAt: new Date().toISOString(),
1385
1570
  };
1386
1571
  }
@@ -1402,7 +1587,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1402
1587
  if (isResult(mock))
1403
1588
  return mock;
1404
1589
  const posts = await store.listPosts({ mockId: mock.id });
1405
- const draft = sanitizeDraft(body, mock, posts, null);
1590
+ const draft = await sanitizeDraft(body, mock, posts, null);
1406
1591
  if (typeof draft === "string")
1407
1592
  return fail(400, draft);
1408
1593
  await store.putDraft(mock.id, draft);
@@ -1487,13 +1672,22 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1487
1672
  if (answer !== undefined || other !== undefined) {
1488
1673
  delete next.answer;
1489
1674
  delete next.other;
1675
+ delete next.otherImages;
1490
1676
  if (answer !== undefined)
1491
1677
  next.answer = answer;
1492
1678
  if (other !== undefined)
1493
1679
  next.other = other;
1680
+ const images = draft.otherImages?.[a.id];
1681
+ if (other !== undefined && images)
1682
+ next.otherImages = images;
1494
1683
  }
1495
- if (note !== undefined)
1684
+ if (note !== undefined) {
1496
1685
  next.note = note;
1686
+ delete next.noteImages;
1687
+ const images = draft.noteImages?.[a.id];
1688
+ if (images)
1689
+ next.noteImages = images;
1690
+ }
1497
1691
  return next;
1498
1692
  }
1499
1693
  // Send: the user's one batched answer becomes one kind:"reply" comment on the
@@ -1506,7 +1700,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1506
1700
  if (isResult(mock))
1507
1701
  return mock;
1508
1702
  const posts = await store.listPosts({ mockId: mock.id });
1509
- const draft = sanitizeDraft(body ?? {}, mock, posts, mock.draft);
1703
+ const draft = await sanitizeDraft(body ?? {}, mock, posts, mock.draft);
1510
1704
  if (typeof draft === "string")
1511
1705
  return fail(400, draft);
1512
1706
  const text = str(body?.text, MAX_COMMENT_TEXT);
@@ -1536,6 +1730,8 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1536
1730
  comments: draft.comments,
1537
1731
  ...(Object.keys(draft.others ?? {}).length ? { others: draft.others } : {}),
1538
1732
  ...(Object.keys(draft.notes ?? {}).length ? { notes: draft.notes } : {}),
1733
+ ...(draft.otherImages ? { otherImages: draft.otherImages } : {}),
1734
+ ...(draft.noteImages ? { noteImages: draft.noteImages } : {}),
1539
1735
  ...(text ? { text } : {}),
1540
1736
  ...(decision ? { decision } : {}),
1541
1737
  };
@@ -1861,7 +2057,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1861
2057
  }
1862
2058
  // The agent's plain-text message in a mock's thread. Stored as text and
1863
2059
  // rendered as a text node, never as HTML. A write, so feedback rides along.
1864
- async function sayFlow(ref, body) {
2060
+ async function sayFlow(ref, body, ctx) {
1865
2061
  if (!body || typeof body !== "object")
1866
2062
  return fail(400, "invalid JSON body");
1867
2063
  const text = typeof body.message === "string" ? str(body.message, MAX_COMMENT_TEXT) : undefined;
@@ -1885,10 +2081,15 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1885
2081
  if (!comment)
1886
2082
  return fail(404, "session not found");
1887
2083
  announceComment(comment);
1888
- return ok({ feedback: await collectFeedback(comment.sessionId) }, 201);
2084
+ return ok({ feedback: await collectFeedback(comment.sessionId, ctx.base) }, 201);
1889
2085
  }
1890
2086
  // Long-poll: resolves as soon as a matching comment lands, or at timeout.
1891
2087
  async function waitForComments(q, signal) {
2088
+ // The agent listening for its feedback: it is alive, and it may take
2089
+ // replies owed to a session it superseded (handoff.ts).
2090
+ const listener = q.agent && q.author === "user" ? q.sessionId : undefined;
2091
+ if (listener)
2092
+ await store.touchAgent(listener);
1892
2093
  // An author=user session wait with no explicit cursor resumes from the
1893
2094
  // session's agentSeq — "where the agent left off" lives server-side so the
1894
2095
  // CLI, both MCP transports, and piggyback share one exactly-once stream.
@@ -1896,12 +2097,19 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1896
2097
  if (afterSeq === undefined && q.author === "user" && q.sessionId) {
1897
2098
  afterSeq = (await store.getSession(q.sessionId))?.agentSeq;
1898
2099
  }
2100
+ const takeOrphanedFor = async (id) => {
2101
+ const taker = id ? await store.getSession(id) : null;
2102
+ return taker ? await takeOrphaned(taker) : [];
2103
+ };
2104
+ let handedOff = await takeOrphanedFor(listener);
1899
2105
  const query = { sessionId: q.sessionId, mockId: q.mockId, postId: q.postId, afterSeq };
1900
2106
  const matches = (list) => q.author ? list.filter((cm) => cm.author === q.author) : list;
1901
2107
  const wait = Math.min(Math.max(q.waitSeconds, 0), MAX_WAIT_SECONDS);
1902
2108
  let all = await store.listComments(query);
1903
2109
  let comments = matches(all);
1904
- if (comments.length === 0 && wait > 0) {
2110
+ if (comments.length === 0 && handedOff.length === 0 && wait > 0) {
2111
+ if (listener)
2112
+ listening.set(listener, (listening.get(listener) ?? 0) + 1);
1905
2113
  await new Promise((resolve) => {
1906
2114
  const timer = setTimeout(done, wait * 1000);
1907
2115
  const unsubscribe = bus.subscribe((event) => {
@@ -1930,10 +2138,20 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1930
2138
  resolve();
1931
2139
  }
1932
2140
  });
2141
+ if (listener) {
2142
+ const n = (listening.get(listener) ?? 1) - 1;
2143
+ if (n > 0)
2144
+ listening.set(listener, n);
2145
+ else
2146
+ listening.delete(listener);
2147
+ }
1933
2148
  // A caller that gave up can't receive this batch, so delivering it would
1934
2149
  // advance the cursor past feedback no agent ever saw.
1935
2150
  if (signal?.aborted)
1936
- return { comments: [], lastSeq: afterSeq ?? 0 };
2151
+ return { comments: [], lastSeq: afterSeq ?? 0, handedOff: [] };
2152
+ if (listener)
2153
+ await store.touchAgent(listener);
2154
+ handedOff = await takeOrphanedFor(listener);
1937
2155
  all = await store.listComments(query);
1938
2156
  comments = matches(all);
1939
2157
  }
@@ -1946,13 +2164,13 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1946
2164
  await store.markAgentSeen(q.sessionId, lastSeq);
1947
2165
  bus.broadcast({ type: "comment-delivered", sessionId: q.sessionId, seq: lastSeq });
1948
2166
  }
1949
- return { comments, lastSeq };
2167
+ return { comments, lastSeq, handedOff };
1950
2168
  }
1951
2169
  // The watch long-poll's answer: comments plus the batched feedback built
1952
2170
  // from them.
1953
- async function listenFlow(q, signal) {
1954
- const result = await waitForComments({ ...q, agent: true }, signal);
1955
- const feedback = await buildFeedbackBatches(store, result.comments);
2171
+ async function listenFlow(q, base, signal) {
2172
+ const { handedOff, ...result } = await waitForComments({ ...q, agent: true }, signal);
2173
+ const feedback = await feedbackWithHandoffs(result.comments, handedOff, base);
1956
2174
  return ok({ ...result, feedback });
1957
2175
  }
1958
2176
  // What the user is doing right now, per mock: derived from the SSE registry
@@ -1960,7 +2178,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1960
2178
  // tells the agent something ("2 of 3 answered", or nobody is looking).
1961
2179
  const pendingOf = (mock, posts) => ({
1962
2180
  mock: mock.slug,
1963
- viewerOpen: viewerClients > 0,
2181
+ viewerOpen: viewing.has(mock.id),
1964
2182
  draft: mock.draft
1965
2183
  ? {
1966
2184
  answered: Object.keys(mock.draft.answers).length,
@@ -1972,14 +2190,14 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1972
2190
  });
1973
2191
  // The feedback verb: never blocks. Takes what the session has not heard yet
1974
2192
  // (the same cursor as piggyback and watch) plus `pending` for the project.
1975
- async function feedbackFlow(query) {
2193
+ async function feedbackFlow(query, ctx) {
1976
2194
  if (typeof query.session !== "string" || !query.session) {
1977
2195
  return fail(400, 'provide "session": the session id a write returned');
1978
2196
  }
1979
2197
  const session = await store.getSession(query.session);
1980
2198
  if (!session)
1981
2199
  return fail(404, `session "${query.session}" not found`);
1982
- const { comments } = await waitForComments({
2200
+ const { comments, handedOff } = await waitForComments({
1983
2201
  sessionId: session.id,
1984
2202
  author: "user",
1985
2203
  waitSeconds: 0,
@@ -1987,13 +2205,14 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1987
2205
  });
1988
2206
  const project = str(query.project, MAX_TITLE) ?? session.project ?? undefined;
1989
2207
  return ok({
1990
- feedback: await buildFeedbackBatches(store, comments),
2208
+ feedback: await feedbackWithHandoffs(comments, handedOff, ctx.base),
1991
2209
  pending: await Promise.all((await store.listMocks(project)).map(async (m) => pendingOf(m, await store.listPosts({ mockId: m.id })))),
1992
2210
  });
1993
2211
  }
1994
2212
  // Store an uploaded blob. An explicit session is validated and a missing one
1995
- // is auto-created so an upload can precede the first publish. The asset's
1996
- // data is dropped from the result (it's bytes).
2213
+ // is auto-created so an upload can precede the first publish. An image the
2214
+ // user attaches names its mock instead, and belongs to the session that mock
2215
+ // replies to. The asset's data is dropped from the result (it's bytes).
1997
2216
  async function uploadAsset(input) {
1998
2217
  if (input.data.byteLength === 0)
1999
2218
  return { error: "empty upload", status: 400 };
@@ -2001,6 +2220,19 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
2001
2220
  return { error: `asset exceeds ${MAX_ASSET_BYTES} bytes`, status: 413 };
2002
2221
  }
2003
2222
  let sessionId = input.session;
2223
+ if (input.mock !== undefined) {
2224
+ if (!ATTACHABLE_IMAGE_TYPES.has(input.contentType)) {
2225
+ return { error: "attach a png, jpeg, webp or gif image", status: 415 };
2226
+ }
2227
+ const mock = await store.getMock(input.mock);
2228
+ if (!mock)
2229
+ return { error: `mock "${input.mock}" not found`, status: 404 };
2230
+ const posts = await store.listPosts({ mockId: mock.id });
2231
+ const latest = [...posts].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))[0];
2232
+ sessionId = mock.sessionId ?? latest?.sessionId;
2233
+ if (!sessionId)
2234
+ return { error: `${mock.slug} has no agent session`, status: 409 };
2235
+ }
2004
2236
  if (sessionId && !(await store.getSession(sessionId))) {
2005
2237
  return { error: `session "${sessionId}" not found`, status: 404 };
2006
2238
  }
@@ -2058,6 +2290,11 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
2058
2290
  return next();
2059
2291
  if (path.startsWith(STATIC_ASSET_PREFIX))
2060
2292
  return next();
2293
+ // The installable-app files carry no workspace data, and a browser may
2294
+ // fetch them without the cookie; the manifest adds the key only for a
2295
+ // caller that already holds it.
2296
+ if (APP_FILES.has(path))
2297
+ return next();
2061
2298
  const key = c.req.query("key");
2062
2299
  if (key === authToken) {
2063
2300
  setCookie(c, "mockpit_key", authToken, {
@@ -2131,6 +2368,21 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
2131
2368
  ].join("");
2132
2369
  return injectHead(text, `<script>${config}</script>`);
2133
2370
  };
2371
+ // The tags that make the viewer installable to a home screen. `use-credentials`
2372
+ // sends the cookie with the manifest fetch, which browsers otherwise omit.
2373
+ const withAppHead = (request, mode) => (text) => {
2374
+ const base = escapeHtml(requestBasePath(request));
2375
+ return injectHead(text, [
2376
+ `<link rel="manifest" href="${base}/manifest.webmanifest" crossorigin="use-credentials">`,
2377
+ `<link rel="icon" href="${base}/icon.svg" type="image/svg+xml">`,
2378
+ `<link rel="apple-touch-icon" href="${base}/apple-touch-icon.png">`,
2379
+ `<meta name="theme-color" content="${themeColor(mode)}">`,
2380
+ `<meta name="mobile-web-app-capable" content="yes">`,
2381
+ `<meta name="apple-mobile-web-app-capable" content="yes">`,
2382
+ `<meta name="apple-mobile-web-app-title" content="mockpit">`,
2383
+ `<meta name="apple-mobile-web-app-status-bar-style" content="default">`,
2384
+ ].join("\n"));
2385
+ };
2134
2386
  // --- viewer pages ---
2135
2387
  // Link previews for a mock page: the first open variant's first surface,
2136
2388
  // with every pixel-affecting input pinned in the image URL so the Worker can
@@ -2171,13 +2423,42 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
2171
2423
  // sandboxed surface documents at /s/:id are *meant* to be framed and carry
2172
2424
  // their own `sandbox` CSP header instead, so they never pass through here.
2173
2425
  c.header("Content-Security-Policy", "frame-ancestors 'self'");
2174
- const html = withDocumentTitle(withViewerConfig(withOrigin(viewerHtml, { req: { url: c.req.url } }), c.req.raw, !!publicRead && !isAuthenticated(c), opts.title), opts.title);
2426
+ const html = withAppHead(c.req.raw, await workspaceMode())(withDocumentTitle(withViewerConfig(withOrigin(viewerHtml, { req: { url: c.req.url } }), c.req.raw, !!publicRead && !isAuthenticated(c), opts.title), opts.title));
2175
2427
  if (!opts.preview)
2176
2428
  return html;
2177
2429
  const { mock, post } = opts.preview;
2178
2430
  return injectHead(html, mockPreviewHead(mock, post, c.req.raw, await workspaceMode(), version ?? "dev"));
2179
2431
  };
2180
2432
  app.get("/", async (c) => c.html(await configuredViewerHtml(c)));
2433
+ // --- the installable app: manifest and icons ---
2434
+ app.get("/manifest.webmanifest", async (c) => {
2435
+ // A tokened workspace bakes the key into start_url for a caller that holds
2436
+ // it (the viewer links the manifest with credentials), so a home-screen
2437
+ // launch, which may not share the browser's cookies, signs itself in.
2438
+ const keyed = !!authToken && isAuthenticated(c);
2439
+ const manifest = webManifest(await workspaceMode(), keyed ? `./?key=${encodeURIComponent(authToken)}` : "./");
2440
+ c.header("Content-Type", "application/manifest+json; charset=utf-8");
2441
+ c.header("X-Content-Type-Options", "nosniff");
2442
+ c.header("Cache-Control", keyed ? "private, no-store" : "no-cache");
2443
+ return c.body(JSON.stringify(manifest));
2444
+ });
2445
+ app.get("/icon.svg", (c) => {
2446
+ c.header("Content-Type", "image/svg+xml");
2447
+ // Our own art, but SVG is a document: opened top-level it runs at this origin.
2448
+ c.header("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox");
2449
+ c.header("X-Content-Type-Options", "nosniff");
2450
+ c.header("Cache-Control", "public, max-age=86400");
2451
+ return c.body(ICON_SVG);
2452
+ });
2453
+ const iconPng = (size) => (c) => {
2454
+ c.header("Content-Type", "image/png");
2455
+ c.header("X-Content-Type-Options", "nosniff");
2456
+ c.header("Cache-Control", "public, max-age=86400");
2457
+ return c.body(decodeBase64(ICON_PNG_BASE64[size]).buffer);
2458
+ };
2459
+ for (const size of ICON_SIZES)
2460
+ app.get(`/icon-${size}.png`, iconPng(size));
2461
+ app.get("/apple-touch-icon.png", iconPng(180));
2181
2462
  // The engine reads the project/mock out of the URL itself; these render the
2182
2463
  // same shell as "/".
2183
2464
  app.get("/project/:name", async (c) => c.html(await configuredViewerHtml(c, { title: c.req.param("name") })));
@@ -2225,8 +2506,11 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
2225
2506
  const cwd = str(body.cwd, 4096);
2226
2507
  const key = str(body.key, MAX_TITLE);
2227
2508
  const existing = key ? await store.getSessionByKey(key) : null;
2228
- if (existing)
2229
- return c.json(existing, 200);
2509
+ if (existing) {
2510
+ // A resumed conversation is that agent back, not a new one superseding it.
2511
+ await store.touchAgent(existing.id);
2512
+ return c.json((await store.getSession(existing.id)) ?? existing, 200);
2513
+ }
2230
2514
  const session = await store.createSession({
2231
2515
  agent: str(body.agent, MAX_TITLE) ?? "agent",
2232
2516
  title: str(body.title, MAX_TITLE),
@@ -2297,8 +2581,8 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
2297
2581
  app.post("/api/mocks/:id/reply", async (c) => send(c, await replyFlow(c.req.param("id"), await jsonBody(c), flowCtx(c))));
2298
2582
  app.post("/api/mocks/:id/restore", async (c) => send(c, await restoreFlow(c.req.param("id"), await jsonBody(c))));
2299
2583
  app.post("/api/mocks/:id/variants/:postId/restore", async (c) => send(c, await restoreVersionFlow(c.req.param("id"), c.req.param("postId"), await jsonBody(c), flowCtx(c))));
2300
- app.post("/api/mocks/:id/say", async (c) => send(c, await sayFlow(c.req.param("id"), await jsonBody(c))));
2301
- app.get("/api/feedback", async (c) => send(c, await feedbackFlow({ session: c.req.query("session"), project: c.req.query("project") })));
2584
+ app.post("/api/mocks/:id/say", async (c) => send(c, await sayFlow(c.req.param("id"), await jsonBody(c), flowCtx(c))));
2585
+ app.get("/api/feedback", async (c) => send(c, await feedbackFlow({ session: c.req.query("session"), project: c.req.query("project") }, flowCtx(c))));
2302
2586
  // --- comments ---
2303
2587
  app.post("/api/comments", async (c) => send(c, await commentFlow(await jsonBody(c), flowCtx(c))));
2304
2588
  app.delete("/api/comments/:id", async (c) => {
@@ -2363,8 +2647,8 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
2363
2647
  query.agent = isAgentRead;
2364
2648
  const respond = async (signal) => {
2365
2649
  if (isAgentRead)
2366
- return send(c, await listenFlow(query, signal));
2367
- const result = await waitForComments(query, signal);
2650
+ return send(c, await listenFlow(query, flowCtx(c).base, signal));
2651
+ const { handedOff: _none, ...result } = await waitForComments(query, signal);
2368
2652
  return c.json({ ...result, comments: await withDelivered(result.comments) });
2369
2653
  };
2370
2654
  if (waitSeconds <= 0)
@@ -2931,11 +3215,16 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
2931
3215
  kind: isAssetKind(kindQ) ? kindQ : undefined,
2932
3216
  session: c.req.query("session"),
2933
3217
  agent: c.req.query("agent"),
3218
+ mock: c.req.query("mock"),
2934
3219
  };
2935
3220
  }
2936
3221
  catch (err) {
2937
3222
  return c.json({ error: err instanceof Error ? err.message : "invalid upload" }, 400);
2938
3223
  }
3224
+ // An attachment on a mock is the user's, so only the viewer may make one.
3225
+ if (body.mock !== undefined && !flowCtx(c).viewer) {
3226
+ return c.json({ error: "only the viewer can attach images to a mock" }, 403);
3227
+ }
2939
3228
  const result = await uploadAsset(body);
2940
3229
  if ("error" in result)
2941
3230
  return c.json({ error: result.error }, result.status);
@@ -2986,12 +3275,30 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
2986
3275
  }
2987
3276
  if (!acquireHold())
2988
3277
  return c.json({ error: "too many concurrent connections" }, 503);
2989
- viewerClients++;
3278
+ const openedAt = Date.now();
3279
+ const viewingId = str(c.req.query("viewing"), MAX_TITLE);
3280
+ if (viewingId)
3281
+ viewing.set(viewingId, (viewing.get(viewingId) ?? 0) + 1);
2990
3282
  let attached = true;
2991
3283
  const releaseHold = makeRelease();
2992
3284
  const release = () => {
2993
- if (attached)
2994
- viewerClients--;
3285
+ if (attached) {
3286
+ if (viewingId) {
3287
+ const left = (viewing.get(viewingId) ?? 1) - 1;
3288
+ if (left > 0)
3289
+ viewing.set(viewingId, left);
3290
+ else
3291
+ viewing.delete(viewingId);
3292
+ }
3293
+ writeLog(log, {
3294
+ t: new Date().toISOString(),
3295
+ op: "sse close",
3296
+ client: "viewer",
3297
+ status: 200,
3298
+ ms: Date.now() - openedAt,
3299
+ ...(viewingId ? { viewing: viewingId } : {}),
3300
+ });
3301
+ }
2995
3302
  attached = false;
2996
3303
  releaseHold();
2997
3304
  };
@@ -3078,8 +3385,8 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
3078
3385
  get: (ref, query) => getMockFlow(ref, query),
3079
3386
  ask: (ref, body, ctx) => askFlow(ref, body, ctx),
3080
3387
  exportMock: (ref, query, ctx) => exportFlow(ref, query, ctx),
3081
- say: (ref, body) => sayFlow(ref, body),
3082
- feedback: (query) => feedbackFlow(query),
3388
+ say: (ref, body, ctx) => sayFlow(ref, body, ctx),
3389
+ feedback: (query, ctx) => feedbackFlow(query, ctx),
3083
3390
  };
3084
3391
  const guide = (project, topic) => topic ? (topics[topic] ?? "") : briefFor(project);
3085
3392
  // --- run: an agent script over the same flows (server/run.ts) ---