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.
- package/README.md +4 -2
- package/bin/mockpit.js +24 -3
- package/dist/mcp/server.js +20 -3
- package/dist/server/apiViews.d.ts +11 -8
- package/dist/server/apiViews.js +21 -16
- package/dist/server/app.js +344 -59
- package/dist/server/appIcon.d.ts +22 -0
- package/dist/server/appIcon.js +43 -0
- package/dist/server/appIconPng.d.ts +1 -0
- package/dist/server/appIconPng.js +6 -0
- package/dist/server/askNudges.d.ts +3 -0
- package/dist/server/askNudges.js +40 -0
- package/dist/server/base64.d.ts +1 -0
- package/dist/server/base64.js +8 -0
- package/dist/server/designGuide.js +15 -13
- package/dist/server/feedbackBatch.d.ts +16 -4
- package/dist/server/feedbackBatch.js +59 -9
- package/dist/server/handoff.d.ts +6 -0
- package/dist/server/handoff.js +38 -0
- package/dist/server/knobs.js +2 -3
- package/dist/server/mcpHttp.d.ts +2 -2
- package/dist/server/mcpHttp.js +11 -5
- package/dist/server/mcpSpec.d.ts +8 -0
- package/dist/server/mcpSpec.js +26 -1
- package/dist/server/run.js +2 -2
- package/dist/server/runApi.d.ts +1 -1
- package/dist/server/runApi.js +1 -1
- package/dist/server/sqlStore.d.ts +3 -1
- package/dist/server/sqlStore.js +40 -8
- package/dist/server/types.d.ts +25 -2
- package/dist/server/types.js +47 -4
- package/extensions/mockpit.js +1 -0
- package/guide/topics/asks.md +19 -7
- package/guide/topics/feedback.md +15 -5
- package/guide/topics/knobs.md +3 -3
- package/guide/topics/surfaces.md +8 -8
- package/package.json +1 -1
- package/skills/mockpit/SKILL.md +4 -2
- package/viewer/dist/index.html +9 -9
package/dist/server/app.js
CHANGED
|
@@ -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 {
|
|
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
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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
|
|
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
|
|
410
|
+
const sessions = new Map();
|
|
353
411
|
for (const c of comments) {
|
|
354
|
-
if (!
|
|
355
|
-
|
|
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
|
|
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}"
|
|
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 (
|
|
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
|
|
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
|
|
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, {
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
2000
|
-
//
|
|
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
|
-
|
|
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) ---
|