mockpit 1.1.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.
@@ -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";
@@ -20,7 +24,7 @@ import { logMiddleware, writeLog } from "./eventLog.js";
20
24
  import { RUN_API } from "./runApi.js";
21
25
  import { escapeHtml, renderHtmlPage, renderMermaidPage, renderSandboxedPart, STATIC_ASSET_PREFIX, staticAsset, } from "./surfacePage.js";
22
26
  import { DEFAULT_MODE, DEFAULT_THEME_ID, isMode, themeById } from "./themes.js";
23
- 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";
24
28
  import { coerceSurfaces, validateSurfaces, } from "./postSurfaces.js";
25
29
  const MAX_SURFACE_BYTES = 2 * 1024 * 1024;
26
30
  // A held long-poll (watch, the viewer) re-arms rather than pinning a socket
@@ -166,6 +170,12 @@ const MAX_LABEL = 120;
166
170
  const MAX_ASKS = 20;
167
171
  const MAX_OPTIONS = 20;
168
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
+ ]);
169
179
  const titleFromSlug = (slug) => slug.replace(/-/g, " ").replace(/^./, (ch) => ch.toUpperCase());
170
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, }) {
171
181
  const app = new Hono();
@@ -331,31 +341,93 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
331
341
  return value;
332
342
  }
333
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();
334
347
  // User comments the agent has not seen yet ride along on its next write, so
335
348
  // agents hear feedback without blocking on the long-poll. The cursor also
336
349
  // advances past the agent's own comments to keep reads cheap.
337
- 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);
338
354
  const session = await store.getSession(sessionId);
339
355
  if (!session)
340
356
  return [];
357
+ const handedOff = await takeOrphaned(session);
341
358
  const fresh = await store.listComments({ sessionId, afterSeq: session.agentSeq });
342
- if (fresh.length === 0)
343
- return [];
344
- await store.markAgentSeen(sessionId, fresh[fresh.length - 1].seq);
345
- 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
+ }
346
363
  const feedback = fresh.filter((cm) => cm.author === "user");
347
- 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;
348
404
  }
349
405
  // Per-comment delivery state for the viewer: `delivered` once the session's
350
- // 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.
351
409
  async function withDelivered(comments) {
352
- const cursors = new Map();
410
+ const sessions = new Map();
353
411
  for (const c of comments) {
354
- if (!cursors.has(c.sessionId)) {
355
- 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))));
356
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 } : {}) });
357
429
  }
358
- return comments.map((c) => ({ ...c, delivered: c.seq <= (cursors.get(c.sessionId) ?? 0) }));
430
+ return out;
359
431
  }
360
432
  // The workspace's light/dark choice. Older workspaces stored a theme id under
361
433
  // `theme`; that registry is gone, so they start from the default mode.
@@ -669,8 +741,8 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
669
741
  const out = [];
670
742
  for (const [path, config] of Object.entries(knobs)) {
671
743
  const n = discreteChoices(config);
672
- if (n !== null && n <= 3) {
673
- 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.`);
674
746
  }
675
747
  }
676
748
  return out;
@@ -684,35 +756,67 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
684
756
  const names = live(state);
685
757
  if (names.length < 2)
686
758
  return null;
759
+ const applies = (a) => a.scope !== "part" && !(a.scope === "state" && a.state !== state);
687
760
  const bound = mock.asks.some((a) => {
688
- if (a.scope === "part" || (a.scope === "state" && a.state !== state))
761
+ if (!applies(a))
689
762
  return false;
690
763
  const variants = new Set(a.options.map((o) => o.variant));
691
764
  return names.every((n) => variants.has(n));
692
765
  });
693
766
  if (bound)
694
767
  return null;
768
+ const where = state === null ? mock.slug : `${mock.slug} state "${state}"`;
695
769
  const key = (list) => [...list].sort().join("\n");
696
770
  const states = mock.states.length ? mock.states : [null];
697
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
+ }
698
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}`;
699
809
  const ask = wide
700
- ? { id: BUILTIN_ASK_ID, text: "Which one?", scope: "mock", options }
701
- : {
702
- id: `${BUILTIN_ASK_ID}-${slugify(state ?? "")}`,
703
- text: "Which one?",
704
- scope: "state",
705
- state,
706
- options,
707
- };
708
- 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 };
709
812
  return {
710
- 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.`,
711
814
  ask,
712
815
  };
713
816
  }
714
817
  // The answer to every write: what was published, the parts found per state,
715
- // 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.
716
820
  async function writeResult(mock, post, ctx, extra = {}) {
717
821
  const posts = await store.listPosts({ mockId: mock.id });
718
822
  const detail = mockDetailView(mock, posts);
@@ -723,7 +827,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
723
827
  partChanges = diffParts(partsInSurfaces(extra.previous), partsInSurfaces(post.surfaces));
724
828
  }
725
829
  const warnings = await iconWarnings(mock.project, post.surfaces);
726
- const feedback = await collectFeedback(post.sessionId);
830
+ const feedback = await collectFeedback(extra.writer?.id ?? post.sessionId, ctx.base);
727
831
  return ok({
728
832
  mock: {
729
833
  id: mock.id,
@@ -947,7 +1051,12 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
947
1051
  announcePost(mock, updated, false);
948
1052
  if (prompt && ctx.request)
949
1053
  notifyPublish(mock, updated, prompt, ctx);
950
- 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
+ });
951
1060
  }
952
1061
  // `parts`: new outer html per part ("name" or "name#key"), spliced into the
953
1062
  // variant's html so a tweak costs the tweak rather than the whole document.
@@ -1231,7 +1340,8 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1231
1340
  text: added.map((a) => a.text).join(" · "),
1232
1341
  url: mockUrl(ctx.base, mock),
1233
1342
  });
1234
- const feedback = session ? await collectFeedback(session.id) : [];
1343
+ const feedback = session ? await collectFeedback(session.id, ctx.base) : [];
1344
+ const nudges = askNudges(added, mock.asks);
1235
1345
  return ok({
1236
1346
  mock: mock.slug,
1237
1347
  mockId: mock.id,
@@ -1239,11 +1349,12 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1239
1349
  asks: added,
1240
1350
  open: openCount(mock, posts),
1241
1351
  url: mockUrl(ctx.base, mock),
1352
+ ...(nudges.length ? { nudges } : {}),
1242
1353
  feedback,
1243
1354
  });
1244
1355
  }
1245
1356
  // --- drafts and replies (the user's side; viewer origin only) ---
1246
- function sanitizePartComments(raw, mock) {
1357
+ function sanitizePartComments(raw, mock, posts) {
1247
1358
  if (raw === undefined || raw === null)
1248
1359
  return [];
1249
1360
  if (!Array.isArray(raw))
@@ -1252,16 +1363,39 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1252
1363
  return `at most ${MAX_DRAFT_COMMENTS} comments`;
1253
1364
  const out = [];
1254
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(", ")}`;
1255
1369
  const text = str(c?.text, MAX_COMMENT_TEXT);
1256
- if (!text)
1370
+ if (!text && !reaction)
1257
1371
  return "every comment needs text";
1258
1372
  const state = stateArg(c.state) ?? null;
1259
1373
  if (state !== null && !mock.states.includes(state))
1260
1374
  return `${mock.slug} has no state "${state}"`;
1261
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}"` : ""}`;
1262
1379
  const a = c.anchor && typeof c.anchor === "object" ? c.anchor : null;
1263
1380
  const anchor = a ? sanitizePartAnchor(a) : undefined;
1264
- 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);
1265
1399
  }
1266
1400
  return out;
1267
1401
  }
@@ -1274,6 +1408,9 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1274
1408
  : undefined;
1275
1409
  const quote = str(a.quote, 200);
1276
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;
1277
1414
  if (!offset && !box && !quote && !selector)
1278
1415
  return undefined;
1279
1416
  return {
@@ -1281,6 +1418,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1281
1418
  ...(quote ? { quote } : {}),
1282
1419
  ...(selector ? { selector } : {}),
1283
1420
  ...(box ? { box } : {}),
1421
+ ...(width ? { width } : {}),
1284
1422
  };
1285
1423
  }
1286
1424
  // A stored ask, else the viewer's built-in variant ask under its reserved id.
@@ -1329,6 +1467,37 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1329
1467
  }
1330
1468
  return out;
1331
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
+ }
1332
1501
  function sanitizeMix(raw, posts) {
1333
1502
  if (raw === undefined || raw === null)
1334
1503
  return {};
@@ -1348,7 +1517,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1348
1517
  }
1349
1518
  // A draft, validated against the mock as it is now: unknown asks, options,
1350
1519
  // variants, knob paths or out-of-range values are refused, never stored.
1351
- function sanitizeDraft(raw, mock, posts, base) {
1520
+ async function sanitizeDraft(raw, mock, posts, base) {
1352
1521
  const pick = (key) => (raw?.[key] !== undefined ? raw[key] : base?.[key]);
1353
1522
  const answers = sanitizeAnswers(pick("answers"), mock, posts);
1354
1523
  if (typeof answers === "string")
@@ -1359,7 +1528,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1359
1528
  const tuned = checkKnobValues(pick("tuned"), mock, posts, "tuned");
1360
1529
  if (!tuned.ok)
1361
1530
  return tuned.error;
1362
- const comments = sanitizePartComments(pick("comments"), mock);
1531
+ const comments = sanitizePartComments(pick("comments"), mock, posts);
1363
1532
  if (typeof comments === "string")
1364
1533
  return comments;
1365
1534
  const others = sanitizeAskTexts(pick("others"), mock, posts, "others");
@@ -1368,6 +1537,16 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1368
1537
  const notes = sanitizeAskTexts(pick("notes"), mock, posts, "notes");
1369
1538
  if (typeof notes === "string")
1370
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] ??= "";
1371
1550
  for (const askId of Object.keys(others)) {
1372
1551
  const ask = askById(mock, posts, askId);
1373
1552
  if (!ask?.multi && answers[askId] !== undefined)
@@ -1385,6 +1564,8 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1385
1564
  comments,
1386
1565
  others,
1387
1566
  notes,
1567
+ ...(Object.keys(otherImages).length ? { otherImages } : {}),
1568
+ ...(Object.keys(noteImages).length ? { noteImages } : {}),
1388
1569
  updatedAt: new Date().toISOString(),
1389
1570
  };
1390
1571
  }
@@ -1406,7 +1587,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1406
1587
  if (isResult(mock))
1407
1588
  return mock;
1408
1589
  const posts = await store.listPosts({ mockId: mock.id });
1409
- const draft = sanitizeDraft(body, mock, posts, null);
1590
+ const draft = await sanitizeDraft(body, mock, posts, null);
1410
1591
  if (typeof draft === "string")
1411
1592
  return fail(400, draft);
1412
1593
  await store.putDraft(mock.id, draft);
@@ -1491,13 +1672,22 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1491
1672
  if (answer !== undefined || other !== undefined) {
1492
1673
  delete next.answer;
1493
1674
  delete next.other;
1675
+ delete next.otherImages;
1494
1676
  if (answer !== undefined)
1495
1677
  next.answer = answer;
1496
1678
  if (other !== undefined)
1497
1679
  next.other = other;
1680
+ const images = draft.otherImages?.[a.id];
1681
+ if (other !== undefined && images)
1682
+ next.otherImages = images;
1498
1683
  }
1499
- if (note !== undefined)
1684
+ if (note !== undefined) {
1500
1685
  next.note = note;
1686
+ delete next.noteImages;
1687
+ const images = draft.noteImages?.[a.id];
1688
+ if (images)
1689
+ next.noteImages = images;
1690
+ }
1501
1691
  return next;
1502
1692
  }
1503
1693
  // Send: the user's one batched answer becomes one kind:"reply" comment on the
@@ -1510,7 +1700,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1510
1700
  if (isResult(mock))
1511
1701
  return mock;
1512
1702
  const posts = await store.listPosts({ mockId: mock.id });
1513
- const draft = sanitizeDraft(body ?? {}, mock, posts, mock.draft);
1703
+ const draft = await sanitizeDraft(body ?? {}, mock, posts, mock.draft);
1514
1704
  if (typeof draft === "string")
1515
1705
  return fail(400, draft);
1516
1706
  const text = str(body?.text, MAX_COMMENT_TEXT);
@@ -1540,6 +1730,8 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1540
1730
  comments: draft.comments,
1541
1731
  ...(Object.keys(draft.others ?? {}).length ? { others: draft.others } : {}),
1542
1732
  ...(Object.keys(draft.notes ?? {}).length ? { notes: draft.notes } : {}),
1733
+ ...(draft.otherImages ? { otherImages: draft.otherImages } : {}),
1734
+ ...(draft.noteImages ? { noteImages: draft.noteImages } : {}),
1543
1735
  ...(text ? { text } : {}),
1544
1736
  ...(decision ? { decision } : {}),
1545
1737
  };
@@ -1865,7 +2057,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1865
2057
  }
1866
2058
  // The agent's plain-text message in a mock's thread. Stored as text and
1867
2059
  // rendered as a text node, never as HTML. A write, so feedback rides along.
1868
- async function sayFlow(ref, body) {
2060
+ async function sayFlow(ref, body, ctx) {
1869
2061
  if (!body || typeof body !== "object")
1870
2062
  return fail(400, "invalid JSON body");
1871
2063
  const text = typeof body.message === "string" ? str(body.message, MAX_COMMENT_TEXT) : undefined;
@@ -1889,10 +2081,15 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1889
2081
  if (!comment)
1890
2082
  return fail(404, "session not found");
1891
2083
  announceComment(comment);
1892
- return ok({ feedback: await collectFeedback(comment.sessionId) }, 201);
2084
+ return ok({ feedback: await collectFeedback(comment.sessionId, ctx.base) }, 201);
1893
2085
  }
1894
2086
  // Long-poll: resolves as soon as a matching comment lands, or at timeout.
1895
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);
1896
2093
  // An author=user session wait with no explicit cursor resumes from the
1897
2094
  // session's agentSeq — "where the agent left off" lives server-side so the
1898
2095
  // CLI, both MCP transports, and piggyback share one exactly-once stream.
@@ -1900,12 +2097,19 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1900
2097
  if (afterSeq === undefined && q.author === "user" && q.sessionId) {
1901
2098
  afterSeq = (await store.getSession(q.sessionId))?.agentSeq;
1902
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);
1903
2105
  const query = { sessionId: q.sessionId, mockId: q.mockId, postId: q.postId, afterSeq };
1904
2106
  const matches = (list) => q.author ? list.filter((cm) => cm.author === q.author) : list;
1905
2107
  const wait = Math.min(Math.max(q.waitSeconds, 0), MAX_WAIT_SECONDS);
1906
2108
  let all = await store.listComments(query);
1907
2109
  let comments = matches(all);
1908
- 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);
1909
2113
  await new Promise((resolve) => {
1910
2114
  const timer = setTimeout(done, wait * 1000);
1911
2115
  const unsubscribe = bus.subscribe((event) => {
@@ -1934,10 +2138,20 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1934
2138
  resolve();
1935
2139
  }
1936
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
+ }
1937
2148
  // A caller that gave up can't receive this batch, so delivering it would
1938
2149
  // advance the cursor past feedback no agent ever saw.
1939
2150
  if (signal?.aborted)
1940
- 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);
1941
2155
  all = await store.listComments(query);
1942
2156
  comments = matches(all);
1943
2157
  }
@@ -1950,13 +2164,13 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1950
2164
  await store.markAgentSeen(q.sessionId, lastSeq);
1951
2165
  bus.broadcast({ type: "comment-delivered", sessionId: q.sessionId, seq: lastSeq });
1952
2166
  }
1953
- return { comments, lastSeq };
2167
+ return { comments, lastSeq, handedOff };
1954
2168
  }
1955
2169
  // The watch long-poll's answer: comments plus the batched feedback built
1956
2170
  // from them.
1957
- async function listenFlow(q, signal) {
1958
- const result = await waitForComments({ ...q, agent: true }, signal);
1959
- 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);
1960
2174
  return ok({ ...result, feedback });
1961
2175
  }
1962
2176
  // What the user is doing right now, per mock: derived from the SSE registry
@@ -1976,14 +2190,14 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1976
2190
  });
1977
2191
  // The feedback verb: never blocks. Takes what the session has not heard yet
1978
2192
  // (the same cursor as piggyback and watch) plus `pending` for the project.
1979
- async function feedbackFlow(query) {
2193
+ async function feedbackFlow(query, ctx) {
1980
2194
  if (typeof query.session !== "string" || !query.session) {
1981
2195
  return fail(400, 'provide "session": the session id a write returned');
1982
2196
  }
1983
2197
  const session = await store.getSession(query.session);
1984
2198
  if (!session)
1985
2199
  return fail(404, `session "${query.session}" not found`);
1986
- const { comments } = await waitForComments({
2200
+ const { comments, handedOff } = await waitForComments({
1987
2201
  sessionId: session.id,
1988
2202
  author: "user",
1989
2203
  waitSeconds: 0,
@@ -1991,13 +2205,14 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
1991
2205
  });
1992
2206
  const project = str(query.project, MAX_TITLE) ?? session.project ?? undefined;
1993
2207
  return ok({
1994
- feedback: await buildFeedbackBatches(store, comments),
2208
+ feedback: await feedbackWithHandoffs(comments, handedOff, ctx.base),
1995
2209
  pending: await Promise.all((await store.listMocks(project)).map(async (m) => pendingOf(m, await store.listPosts({ mockId: m.id })))),
1996
2210
  });
1997
2211
  }
1998
2212
  // Store an uploaded blob. An explicit session is validated and a missing one
1999
- // is auto-created so an upload can precede the first publish. The asset's
2000
- // 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).
2001
2216
  async function uploadAsset(input) {
2002
2217
  if (input.data.byteLength === 0)
2003
2218
  return { error: "empty upload", status: 400 };
@@ -2005,6 +2220,19 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
2005
2220
  return { error: `asset exceeds ${MAX_ASSET_BYTES} bytes`, status: 413 };
2006
2221
  }
2007
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
+ }
2008
2236
  if (sessionId && !(await store.getSession(sessionId))) {
2009
2237
  return { error: `session "${sessionId}" not found`, status: 404 };
2010
2238
  }
@@ -2062,6 +2290,11 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
2062
2290
  return next();
2063
2291
  if (path.startsWith(STATIC_ASSET_PREFIX))
2064
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();
2065
2298
  const key = c.req.query("key");
2066
2299
  if (key === authToken) {
2067
2300
  setCookie(c, "mockpit_key", authToken, {
@@ -2135,6 +2368,21 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
2135
2368
  ].join("");
2136
2369
  return injectHead(text, `<script>${config}</script>`);
2137
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
+ };
2138
2386
  // --- viewer pages ---
2139
2387
  // Link previews for a mock page: the first open variant's first surface,
2140
2388
  // with every pixel-affecting input pinned in the image URL so the Worker can
@@ -2175,13 +2423,42 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
2175
2423
  // sandboxed surface documents at /s/:id are *meant* to be framed and carry
2176
2424
  // their own `sandbox` CSP header instead, so they never pass through here.
2177
2425
  c.header("Content-Security-Policy", "frame-ancestors 'self'");
2178
- 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));
2179
2427
  if (!opts.preview)
2180
2428
  return html;
2181
2429
  const { mock, post } = opts.preview;
2182
2430
  return injectHead(html, mockPreviewHead(mock, post, c.req.raw, await workspaceMode(), version ?? "dev"));
2183
2431
  };
2184
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));
2185
2462
  // The engine reads the project/mock out of the URL itself; these render the
2186
2463
  // same shell as "/".
2187
2464
  app.get("/project/:name", async (c) => c.html(await configuredViewerHtml(c, { title: c.req.param("name") })));
@@ -2229,8 +2506,11 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
2229
2506
  const cwd = str(body.cwd, 4096);
2230
2507
  const key = str(body.key, MAX_TITLE);
2231
2508
  const existing = key ? await store.getSessionByKey(key) : null;
2232
- if (existing)
2233
- 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
+ }
2234
2514
  const session = await store.createSession({
2235
2515
  agent: str(body.agent, MAX_TITLE) ?? "agent",
2236
2516
  title: str(body.title, MAX_TITLE),
@@ -2301,8 +2581,8 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
2301
2581
  app.post("/api/mocks/:id/reply", async (c) => send(c, await replyFlow(c.req.param("id"), await jsonBody(c), flowCtx(c))));
2302
2582
  app.post("/api/mocks/:id/restore", async (c) => send(c, await restoreFlow(c.req.param("id"), await jsonBody(c))));
2303
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))));
2304
- app.post("/api/mocks/:id/say", async (c) => send(c, await sayFlow(c.req.param("id"), await jsonBody(c))));
2305
- 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))));
2306
2586
  // --- comments ---
2307
2587
  app.post("/api/comments", async (c) => send(c, await commentFlow(await jsonBody(c), flowCtx(c))));
2308
2588
  app.delete("/api/comments/:id", async (c) => {
@@ -2367,8 +2647,8 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
2367
2647
  query.agent = isAgentRead;
2368
2648
  const respond = async (signal) => {
2369
2649
  if (isAgentRead)
2370
- return send(c, await listenFlow(query, signal));
2371
- 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);
2372
2652
  return c.json({ ...result, comments: await withDelivered(result.comments) });
2373
2653
  };
2374
2654
  if (waitSeconds <= 0)
@@ -2935,11 +3215,16 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
2935
3215
  kind: isAssetKind(kindQ) ? kindQ : undefined,
2936
3216
  session: c.req.query("session"),
2937
3217
  agent: c.req.query("agent"),
3218
+ mock: c.req.query("mock"),
2938
3219
  };
2939
3220
  }
2940
3221
  catch (err) {
2941
3222
  return c.json({ error: err instanceof Error ? err.message : "invalid upload" }, 400);
2942
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
+ }
2943
3228
  const result = await uploadAsset(body);
2944
3229
  if ("error" in result)
2945
3230
  return c.json({ error: result.error }, result.status);
@@ -3100,8 +3385,8 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
3100
3385
  get: (ref, query) => getMockFlow(ref, query),
3101
3386
  ask: (ref, body, ctx) => askFlow(ref, body, ctx),
3102
3387
  exportMock: (ref, query, ctx) => exportFlow(ref, query, ctx),
3103
- say: (ref, body) => sayFlow(ref, body),
3104
- feedback: (query) => feedbackFlow(query),
3388
+ say: (ref, body, ctx) => sayFlow(ref, body, ctx),
3389
+ feedback: (query, ctx) => feedbackFlow(query, ctx),
3105
3390
  };
3106
3391
  const guide = (project, topic) => topic ? (topics[topic] ?? "") : briefFor(project);
3107
3392
  // --- run: an agent script over the same flows (server/run.ts) ---