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.
@@ -0,0 +1,22 @@
1
+ import { type Mode } from "./themes.ts";
2
+ export declare const ICON_SVG: string;
3
+ export declare const ICON_SIZES: readonly [180, 192, 512];
4
+ export type IconSize = (typeof ICON_SIZES)[number];
5
+ export declare const themeColor: (mode: Mode) => string;
6
+ export declare function webManifest(mode: Mode, startUrl?: string): {
7
+ name: string;
8
+ short_name: string;
9
+ description: string;
10
+ id: string;
11
+ start_url: string;
12
+ scope: string;
13
+ display: string;
14
+ background_color: string;
15
+ theme_color: string;
16
+ icons: {
17
+ src: string;
18
+ sizes: string;
19
+ type: string;
20
+ purpose: string;
21
+ }[];
22
+ };
@@ -0,0 +1,43 @@
1
+ // The installable app: its web app manifest and its icon. Runtime-agnostic (no
2
+ // `node:` imports) so the Worker serves the same bytes. The PNG renders of the
3
+ // icon are generated from ICON_SVG by scripts/app-icons.ts into appIconPng.ts,
4
+ // because iOS takes no SVG for a home-screen icon.
5
+ import { DIALKIT } from "./themes.js";
6
+ const BG = DIALKIT.dark.bg;
7
+ const ACCENT = DIALKIT.dark.info.text;
8
+ // A stage frame with a comment pin on its corner. Full-bleed background and the
9
+ // mark inside the central 80% circle, so the same art works as `maskable`.
10
+ export const ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
11
+ <rect width="512" height="512" fill="${BG}"/>
12
+ <rect x="126" y="142" width="260" height="196" rx="34" fill="none" stroke="${ACCENT}" stroke-width="28"/>
13
+ <path d="M170 206h120M170 262h76" stroke="${ACCENT}" stroke-width="24" stroke-linecap="round" opacity="0.55"/>
14
+ <circle cx="366" cy="338" r="58" fill="${ACCENT}" stroke="${BG}" stroke-width="22"/>
15
+ </svg>`;
16
+ export const ICON_SIZES = [180, 192, 512];
17
+ export const themeColor = (mode) => DIALKIT[mode].bg;
18
+ // Relative URLs resolve against the manifest's own URL, so the app works under
19
+ // any base path. `startUrl` carries `?key=` on a tokened workspace: a
20
+ // home-screen app may not share Safari's cookie jar, and the key is how the
21
+ // viewer gets its cookie back on first launch.
22
+ export function webManifest(mode, startUrl = "./") {
23
+ return {
24
+ name: "mockpit",
25
+ short_name: "mockpit",
26
+ description: "Answer your agent's design questions.",
27
+ id: "./",
28
+ start_url: startUrl,
29
+ scope: "./",
30
+ display: "standalone",
31
+ background_color: themeColor(mode),
32
+ theme_color: themeColor(mode),
33
+ icons: [
34
+ { src: "icon.svg", sizes: "any", type: "image/svg+xml", purpose: "any" },
35
+ ...ICON_SIZES.filter((s) => s !== 180).map((s) => ({
36
+ src: `icon-${s}.png`,
37
+ sizes: `${s}x${s}`,
38
+ type: "image/png",
39
+ purpose: "any maskable",
40
+ })),
41
+ ],
42
+ };
43
+ }
@@ -0,0 +1 @@
1
+ export declare const ICON_PNG_BASE64: Record<number, string>;
@@ -0,0 +1,6 @@
1
+ // Generated by scripts/app-icons.ts from ICON_SVG; do not edit.
2
+ export const ICON_PNG_BASE64 = {
3
+ 180: "iVBORw0KGgoAAAANSUhEUgAAALQAAAC0CAIAAACyr5FlAAAKnklEQVR4nOzdfXAU9R3H8W9yyT0k95BcEhJCHgiJEXkIjTRqRAYUpDyNQBGKteq0xZbOtDqdtuPUGTt22k7/czr9wz+EtsoMijwjT/IgIGIxahRJCCQBQsjzcy6Xu9xDHlzMwDDmvktCbve3e/m8Jn/dLoTk3uz+9rd7uzEOh4MAQokmAAbiABbiABbiABbiABbiABbiABbiABbiABbiABbiABbiABbiABbiABbiABbiABbiABbiABbiABbiABbiABbiABbiABbiABbiABbiABbiABbiABbiABbiABbiABbiABbiABbiABbiABbiABbiABbiABbiABbiABbiABbiABbiABbiAFYMaVV+zvL7p62clDTDbEq0mJ1mk40ihc/v7vN1+vxdLe0XK68drL5+hDQpSmtPTbDFT55f9Mqs+9cbY+NoYggEveWVO858/s9ebwtpiYbiMBntCx95rajgRZqoSs6/KSXiD7hJG7QSh92a8eyqfUmJeTSxtXVceveDtW5PI2mAwWw2k2hpKXNeWPuhw5ZBE158XMqs/Kdr6j/2eFtJNPFHK4mOac+tORBnSSL4jjU+9fkfH3Im5JJoguMwmxzS3kQabRDcQfqF/PSpPcZYwQdoIuOIjo7ZsHJngj2LYIQEe/b6Fe9GRYl8g0SOOYoKfl048wUChtSHx9vW1PoVCSJyEqy48OVRrtnT29jlqhkaGiL9i4qKSnTk2K3po1l53g//UFq+hQQRFscDuats1jT5dXz+nsOnf3+19rg/0EORRRps5WY9uWzhG2aT3HjLbp0szRRX1RwmEYTt0qbnrZJfobntwlvvPVpRvTvyyqCb3bsuVu+SfsDWjovyaz6Qt5oEERNHdHRsfs4ymRXcnuZ3di/t6a2niCb9gG/vWur2NMmsk5+zVPp1kQhi4piW+URsjEVmhX3HNgb7vTQBBILufcd+JbOCdFibk7mQRBATR2rKbJmlja1f1zacpQmjtuGTxpbzMiukJReQCGLiMMvOetU1nqMJpr65RGap0ShmNkxMHPJTonVNn9EEI/8jW0xiTo6KOZQ1GIwyS6VZDZpgXO46maUGg4lEwGWCwEIcwEIcwEIcwEIcwNLuRxNGMsbastIftcanGgRNJw8LBD2u3vobE2CaTjdx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",
4
+ 192: "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",
5
+ 512: "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",
6
+ };
@@ -0,0 +1,3 @@
1
+ import type { Ask } from "./types.ts";
2
+ export declare const looksVisual: (label: string) => boolean;
3
+ export declare function askNudges(added: Ask[], all: Ask[]): string[];
@@ -0,0 +1,40 @@
1
+ // Ask discipline on `ask`: the two habits that make the user type or re-answer
2
+ // what they could have seen. Pure, so the rules are tested on their own.
3
+ // Words that name how something looks, matched on option labels only: a
4
+ // question's text names the topic, not the options. Words with a common
5
+ // non-visual sense ("size", "background", "compact", "white") are left out, so
6
+ // a pill about file size or a background job is not flagged.
7
+ const VISUAL = /\b(colou?rs?|borders?|radius|rounded|corners?|spacing|padding|fonts?|typeface|serif|shadows?|tint|hue|opacity|red|orange|yellow|green|olive|teal|blue|navy|purple|violet|pink|gr[ae]y|\d+\s?(px|rem|em|pt))\b|#[0-9a-f]{3,8}\b/i;
8
+ const plain = (o) => !o.variant && !(o.set && Object.keys(o.set).length);
9
+ export const looksVisual = (label) => VISUAL.test(label);
10
+ // A Look-style ask: one whose options choose between variants.
11
+ const bindsVariants = (a) => a.options.some((o) => o.variant);
12
+ const quoted = (opts) => opts.map((o) => `"${o.label}"`).join(", ");
13
+ export function askNudges(added, all) {
14
+ const out = [];
15
+ for (const ask of added) {
16
+ const pills = ask.options.filter(plain);
17
+ if (!pills.length)
18
+ continue;
19
+ const visual = pills.filter((o) => looksVisual(o.label));
20
+ const look = ask.scope === "part" || bindsVariants(ask)
21
+ ? undefined
22
+ : all.find((a) => a.id !== ask.id &&
23
+ a.scope !== "part" &&
24
+ bindsVariants(a) &&
25
+ (a.scope === "mock" || ask.scope === "mock" || a.state === ask.state));
26
+ // Only a pill that names a look or reads visual overlaps the variants; a
27
+ // plain question on another axis ("Drawer or Margin?") stands beside them.
28
+ const names = new Set(look?.options.flatMap((o) => [o.label, o.variant ?? ""].map((n) => n.toLowerCase())));
29
+ const reask = look
30
+ ? pills.filter((o) => names.has(o.label.toLowerCase()) || looksVisual(o.label))
31
+ : [];
32
+ if (look && reask.length) {
33
+ out.push(`ask "${ask.id}": ${quoted(reask)} may re-ask what "${look.id}" decides. Drop it or use knob sets.`);
34
+ }
35
+ else if (visual.length) {
36
+ out.push(`ask "${ask.id}": ${quoted(visual)} look visual. Give each a knob set or variant.`);
37
+ }
38
+ }
39
+ return out;
40
+ }
@@ -1 +1,2 @@
1
1
  export declare function decodeBase64(b64: string): Uint8Array;
2
+ export declare function encodeBase64(bytes: Uint8Array): string;
@@ -14,3 +14,11 @@ export function decodeBase64(b64) {
14
14
  bytes[i] = bin.charCodeAt(i);
15
15
  return bytes;
16
16
  }
17
+ // bytes -> base64, in chunks so a multi-MB image never spreads into one call.
18
+ export function encodeBase64(bytes) {
19
+ let bin = "";
20
+ for (let i = 0; i < bytes.length; i += 0x8000) {
21
+ bin += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
22
+ }
23
+ return btoa(bin);
24
+ }
@@ -29,7 +29,7 @@ export function iconsLine(design) {
29
29
  }
30
30
  const TOPIC_SUMMARY = {
31
31
  knobs: "every knob shape, how values reach html",
32
- asks: "the ask JSON, scopes, Look, Which one?, multi",
32
+ asks: "the ask JSON, scopes, the Look ask, multi",
33
33
  surfaces: "markdown, diff, mermaid, code, terminal, json, image; uploads",
34
34
  html: "the contract, finish rules, tokens, kits, Tailwind, icons",
35
35
  feedback: "the feedback JSON, pending, delivery, export",
@@ -227,7 +227,7 @@ project › mock › state › variant › version. A project is the repo; a moc
227
227
  page or component, by slug; a state is one moment of it in the user's words
228
228
  ("Writing"), omitted for a single-state mock; a variant is a parallel design of
229
229
  a state; versions are its history. A part is an element marked \`data-part\`.
230
- Asks are questions, knobs are values; the user presses Send once, you get one reply.
230
+ The user answers asks, tunes knobs, presses Send once; you get one reply.
231
231
 
232
232
  ## The loop
233
233
 
@@ -245,7 +245,7 @@ mockpit export --mock writer # the accepted html per state
245
245
  \`\`\`
246
246
 
247
247
  Server: \`$MOCKPIT_URL\` (default http://localhost:8228; \`mockpit serve\`).
248
- MCP tools have the verb names (\`publish\`, \`feedback\`, …).
248
+ MCP tools share the verb names.
249
249
 
250
250
  ## Parts
251
251
 
@@ -256,8 +256,10 @@ declare geometry; the viewer measures it.
256
256
 
257
257
  ## Ask or knob
258
258
 
259
- A choice is several variants plus one ask that binds them. Look question
260
- first. One render plus a control: declare a knob with \`--knobs '{...}'\`.
259
+ A choice is several variants plus one ask that binds them. Ask it first. A
260
+ variant decides everything it differs on; don't re-ask it. Visual options
261
+ carry a \`variant\` or knob \`set\`. One render plus a control: a knob,
262
+ \`--knobs '{...}'\`.
261
263
 
262
264
  | knob | control |
263
265
  | --- | --- |
@@ -267,19 +269,19 @@ first. One render plus a control: declare a knob with \`--knobs '{...}'\`.
267
269
  | \`"trim.position": {"type": "select", "options": ["top", "bottom"]}\` | select |
268
270
 
269
271
  Values reach the html unitless: \`calc(var(--k-body-size, 17) * 1px)\`, and as
270
- \`data-k-trim-position\` on \`<html>\`. Topics \`knobs\` and \`asks\` have the rest.
272
+ \`data-k-trim-position\` on \`<html>\`.
271
273
 
272
274
  ## Feedback
273
275
 
274
276
  \`feedback\` returns \`{feedback, pending}\`. A batch is \`{mock, reply: {asks, tuned,
275
- mix, comments, decision, text}, accepted, archived}\`. In order: answers decide
276
- structure (a variant answer already accepted it and archived its siblings);
277
- tuned values become new defaults; mix names a part to take from another variant;
278
- address each comment on its part; decision (accept, revise, drop) stands in for
279
- answers when there were no asks. Each Send arrives exactly once: in a write's
277
+ mix, comments, reactions, decision, text}, accepted, archived}\`. In order: answers
278
+ decide structure (a variant answer already accepted it); tuned values become
279
+ defaults; mix names a part to take from another variant (if it conflicts with an
280
+ answer, the answer wins); address each comment and reaction on its part; decision
281
+ (accept, revise, drop) replaces answers when there were no asks. Each Send arrives exactly once: in a write's
280
282
  \`feedback\`, \`mockpit feedback\` or a \`mockpit watch\` line.
281
- A \`pending\` draft means the user is still answering. Variants no ask binds
282
- earn a \`suggestedAsk\` on publish: send it with \`ask\`.
283
+ A \`pending\` draft means the user is still answering. Send a nudge's
284
+ \`suggestedAsk\` with \`ask\`.
283
285
 
284
286
  ## HTML
285
287
 
@@ -1,4 +1,4 @@
1
- import { type AskOption, type Comment, type CommentAnchor, type Mock, OTHER_ID, type Post, type Reply, type Store } from "./types.ts";
1
+ import { type AskOption, type Comment, type CommentAnchor, type Mock, OTHER_ID, type PartComment, type Post, type Reaction, type Reply, type Store } from "./types.ts";
2
2
  export interface VariantRef {
3
3
  state: string | null;
4
4
  variant: string;
@@ -13,21 +13,28 @@ export interface FeedbackComment {
13
13
  anchors?: Comment["anchors"];
14
14
  viewport?: number;
15
15
  }
16
+ export interface ImageRef {
17
+ id: string;
18
+ url: string;
19
+ }
16
20
  export interface OtherChoice {
17
21
  id: typeof OTHER_ID;
18
22
  label: string;
19
23
  other: true;
24
+ images?: ImageRef[];
20
25
  }
21
26
  export interface AnsweredAsk {
22
27
  ask: string;
23
28
  text: string;
24
29
  chosen: (AskOption | OtherChoice)[];
25
30
  note?: string;
31
+ noteImages?: ImageRef[];
26
32
  }
27
- export interface FeedbackReply extends Omit<Reply, "others" | "notes"> {
33
+ export interface FeedbackReply extends Omit<Reply, "others" | "notes" | "otherImages" | "noteImages"> {
28
34
  seq: number;
29
35
  at: string;
30
36
  asks: AnsweredAsk[];
37
+ reactions?: string[];
31
38
  }
32
39
  export interface FeedbackBatch {
33
40
  mockId: string | null;
@@ -38,6 +45,11 @@ export interface FeedbackBatch {
38
45
  comments: FeedbackComment[];
39
46
  accepted: VariantRef[];
40
47
  archived: VariantRef[];
48
+ handoff?: string;
41
49
  }
42
- export declare function groupFeedback(comments: Comment[], mocks: Map<string, Mock>, posts: Map<string, Post[]>): FeedbackBatch[];
43
- export declare function buildFeedbackBatches(store: Store, comments: Comment[]): Promise<FeedbackBatch[]>;
50
+ export declare function reactionLine(c: PartComment & {
51
+ reaction: Reaction;
52
+ }): string;
53
+ export declare function groupFeedback(comments: Comment[], mocks: Map<string, Mock>, posts: Map<string, Post[]>, base?: string): FeedbackBatch[];
54
+ export declare function buildFeedbackBatches(store: Store, comments: Comment[], base: string): Promise<FeedbackBatch[]>;
55
+ export declare function feedbackImages(batches: FeedbackBatch[]): ImageRef[];
@@ -2,11 +2,35 @@
2
2
  // The `feedback` verb, the watch long-poll and the write piggyback all return this,
3
3
  // so an agent reads one grouped object instead of re-deriving which mock a flat
4
4
  // comment list belongs to. Runtime-agnostic (no node imports).
5
- import { builtinAsk, OTHER_ID, } from "./types.js";
5
+ import { builtinAsk, DEFAULT_VARIANT, OTHER_ID, } from "./types.js";
6
6
  const ref = (p) => ({ state: p.state, variant: p.variant });
7
- function answered(reply, mock, posts) {
7
+ const REACTION_LEAD = {
8
+ up: "👍",
9
+ down: "👎",
10
+ more: "more like this:",
11
+ };
12
+ // `👍 part "hero" (variant B, state Mobile)`, `👎 variant B`, `more like this: the page`.
13
+ export function reactionLine(c) {
14
+ // A mock published without variant names has only "default": naming it is noise.
15
+ const variant = c.variant && c.variant !== DEFAULT_VARIANT ? c.variant : null;
16
+ const where = [
17
+ c.part !== null && variant ? `variant ${variant}` : "",
18
+ c.state ? `state ${c.state}` : "",
19
+ ].filter(Boolean);
20
+ const target = c.part !== null
21
+ ? `part ${JSON.stringify(c.part)}`
22
+ : variant
23
+ ? `variant ${variant}`
24
+ : "the page";
25
+ const tail = where.length ? ` (${where.join(", ")})` : "";
26
+ const note = c.text ? ` “${c.text}”` : "";
27
+ return `${REACTION_LEAD[c.reaction]} ${target}${tail}${note}`;
28
+ }
29
+ const isReaction = (c) => !!c.reaction;
30
+ function answered(reply, mock, posts, base) {
8
31
  const others = reply.others ?? {};
9
32
  const notes = reply.notes ?? {};
33
+ const refs = (ids) => (ids ?? []).map((id) => ({ id, url: `${base}/a/${encodeURIComponent(id)}` }));
10
34
  const askIds = new Set([
11
35
  ...Object.keys(reply.answers),
12
36
  ...Object.keys(others),
@@ -18,13 +42,21 @@ function answered(reply, mock, posts) {
18
42
  const answer = reply.answers[askId];
19
43
  const ids = answer === undefined ? [] : Array.isArray(answer) ? answer : [answer];
20
44
  const chosen = ids.map((id) => ask?.options.find((o) => o.id === id) ?? { id, label: id });
45
+ const otherImages = refs(reply.otherImages?.[askId]);
21
46
  if (others[askId] !== undefined)
22
- chosen.push({ id: OTHER_ID, label: others[askId], other: true });
47
+ chosen.push({
48
+ id: OTHER_ID,
49
+ label: others[askId],
50
+ other: true,
51
+ ...(otherImages.length ? { images: otherImages } : {}),
52
+ });
53
+ const noteImages = refs(reply.noteImages?.[askId]);
23
54
  out.push({
24
55
  ask: askId,
25
56
  text: ask?.text ?? "",
26
57
  chosen,
27
58
  ...(notes[askId] !== undefined ? { note: notes[askId] } : {}),
59
+ ...(noteImages.length ? { noteImages } : {}),
28
60
  });
29
61
  }
30
62
  return out;
@@ -33,8 +65,8 @@ function answered(reply, mock, posts) {
33
65
  // one. A mock gets a fresh batch whenever a second reply arrives for it, so a
34
66
  // batch carries at most one reply and nothing is merged away. A comment whose
35
67
  // mock is unknown still gets a batch — feedback is never dropped for want of
36
- // context.
37
- export function groupFeedback(comments, mocks, posts) {
68
+ // context. `base` (origin + base path) makes image URLs absolute.
69
+ export function groupFeedback(comments, mocks, posts, base = "") {
38
70
  const batches = [];
39
71
  const open = new Map();
40
72
  for (const c of comments) {
@@ -57,12 +89,15 @@ export function groupFeedback(comments, mocks, posts) {
57
89
  batches.push(batch);
58
90
  }
59
91
  if (c.kind === "reply" && c.payload) {
60
- const { others: _others, notes: _notes, ...payload } = c.payload;
92
+ const { others: _others, notes: _notes, otherImages: _otherImages, noteImages: _noteImages, ...payload } = c.payload;
93
+ const reactions = (payload.comments ?? []).filter(isReaction).map(reactionLine);
61
94
  batch.reply = {
62
95
  ...payload,
96
+ comments: (payload.comments ?? []).filter((x) => !x.reaction),
63
97
  seq: c.seq,
64
98
  at: c.createdAt,
65
- asks: answered(c.payload, mock, mockPosts),
99
+ asks: answered(c.payload, mock, mockPosts, base),
100
+ ...(reactions.length ? { reactions } : {}),
66
101
  };
67
102
  batch.accepted = mockPosts.filter((p) => p.status === "accepted").map(ref);
68
103
  batch.archived = mockPosts.filter((p) => p.status === "archived").map(ref);
@@ -82,7 +117,7 @@ export function groupFeedback(comments, mocks, posts) {
82
117
  }
83
118
  return batches;
84
119
  }
85
- export async function buildFeedbackBatches(store, comments) {
120
+ export async function buildFeedbackBatches(store, comments, base) {
86
121
  const mocks = new Map();
87
122
  const posts = new Map();
88
123
  for (const c of comments) {
@@ -94,5 +129,20 @@ export async function buildFeedbackBatches(store, comments) {
94
129
  mocks.set(c.mockId, mock);
95
130
  posts.set(c.mockId, await store.listPosts({ mockId: c.mockId }));
96
131
  }
97
- return groupFeedback(comments, mocks, posts);
132
+ return groupFeedback(comments, mocks, posts, base);
133
+ }
134
+ // Every image a list of batches carries, in order — what the MCP tiers attach
135
+ // as image content so the model sees what the user attached.
136
+ export function feedbackImages(batches) {
137
+ const out = [];
138
+ for (const b of batches) {
139
+ for (const a of b.reply?.asks ?? []) {
140
+ for (const c of a.chosen)
141
+ if ("other" in c && c.images)
142
+ out.push(...c.images);
143
+ if (a.noteImages)
144
+ out.push(...a.noteImages);
145
+ }
146
+ }
147
+ return out;
98
148
  }
@@ -0,0 +1,6 @@
1
+ import type { Comment, Session } from "./types.ts";
2
+ export declare const HANDOFF_GRACE_MS: number;
3
+ export declare const HANDOFF_NOTE = "Sent to an earlier agent session in this project that has gone quiet; it is yours now. Read the mock before acting.";
4
+ export declare const ownerActiveAt: (s: Session) => string;
5
+ export declare function supersedes(taker: Session, owner: Session, listening: boolean): boolean;
6
+ export declare function handoffReady(comment: Comment, owner: Session, taker: Session, now: number): boolean;
@@ -0,0 +1,38 @@
1
+ // Orphaned replies: a Send lands on the session that owns the mock, and when
2
+ // that agent restarts into a new session (a new conversation, /clear, a crash)
3
+ // nobody would ever read it. The rule below decides when a newer session may
4
+ // take such a reply instead. Runtime-agnostic (no node imports).
5
+ //
6
+ // The danger is the other way round: two agents can work in one repo at once,
7
+ // and an agent waiting on the user looks exactly like a dead one — both are
8
+ // silent. So each clause narrows who counts as "the same agent, restarted":
9
+ //
10
+ // - Same project, same cwd, same agent name. A restart reopens the same harness
11
+ // in the same checkout; a different worktree or tool is a parallel worker.
12
+ // - The owner was created first and has made no call since the taker appeared
13
+ // (agentActiveAt, which the user's own Sends don't bump). An owner that kept
14
+ // working after the taker started is running alongside it, not gone.
15
+ // - The owner has no feedback long-poll open right now (`mockpit watch`).
16
+ //
17
+ // A reply also waits HANDOFF_GRACE_MS after the Send, so the owner — the agent
18
+ // the user is most likely about to tell — gets first claim. The one exception:
19
+ // a keyless owner (no harness conversation id) can never come back, so a reply
20
+ // it got before the taker existed goes at once — the user answered, then
21
+ // restarted. A keyed owner always gets the grace: its conversation may resume.
22
+ export const HANDOFF_GRACE_MS = 2 * 60_000;
23
+ export const HANDOFF_NOTE = "Sent to an earlier agent session in this project that has gone quiet; it is yours now. Read the mock before acting.";
24
+ export const ownerActiveAt = (s) => s.agentActiveAt ?? s.lastActiveAt;
25
+ export function supersedes(taker, owner, listening) {
26
+ return (owner.id !== taker.id &&
27
+ owner.project !== null &&
28
+ owner.project === taker.project &&
29
+ owner.cwd === taker.cwd &&
30
+ owner.agent === taker.agent &&
31
+ owner.createdAt < taker.createdAt &&
32
+ ownerActiveAt(owner) < taker.createdAt &&
33
+ !listening);
34
+ }
35
+ export function handoffReady(comment, owner, taker, now) {
36
+ return ((!owner.keyed && comment.createdAt < taker.createdAt) ||
37
+ now - Date.parse(comment.createdAt) >= HANDOFF_GRACE_MS);
38
+ }
@@ -320,11 +320,10 @@ export function checkKnobValues(raw, mock, posts, what) {
320
320
  return { ok: true, value: out };
321
321
  }
322
322
  // How many discrete choices a knob offers, or null when it is continuous. A knob
323
- // with only a handful is usually a decision dressed as a control (plan D12).
323
+ // with only a handful is usually a decision dressed as a control (plan D12). A
324
+ // toggle is null: on/off reads fine on one render.
324
325
  export function discreteChoices(config) {
325
326
  const knob = explicitKnob(config);
326
- if (knob.type === "toggle")
327
- return 2;
328
327
  if (knob.type === "select")
329
328
  return knob.options.length;
330
329
  if (knob.type === "image" && knob.options)
@@ -23,11 +23,11 @@ export interface McpFlows {
23
23
  },
24
24
  ctx: FlowContext
25
25
  ]>;
26
- say: Flow<[ref: unknown, body: any]>;
26
+ say: Flow<[ref: unknown, body: any, ctx: FlowContext]>;
27
27
  feedback: Flow<[query: {
28
28
  session?: unknown;
29
29
  project?: unknown;
30
- }]>;
30
+ }, ctx: FlowContext]>;
31
31
  }
32
32
  export interface McpDeps {
33
33
  store: Store;
@@ -1,6 +1,6 @@
1
1
  import { decodeBase64 } from "./base64.js";
2
2
  import { isGuideTopic, unknownTopicMessage } from "./designGuide.js";
3
- import { HTTP_MCP_TOOLS, HTTP_RUN_TOOLS, MCP_INSTRUCTIONS, MCP_SERVER_INFO, runToolResult, toolResult, } from "./mcpSpec.js";
3
+ import { HTTP_MCP_TOOLS, HTTP_RUN_TOOLS, MCP_INSTRUCTIONS, MCP_SERVER_INFO, runToolResult, toolResult, withFeedbackImages, } from "./mcpSpec.js";
4
4
  import { RUN_INSTRUCTIONS } from "./runApi.js";
5
5
  // A flow's error status becomes a tool error carrying the same JSON body the
6
6
  // REST tier returns, so the agent sees the hint (states, variants) either way.
@@ -24,6 +24,10 @@ export function registerMcp(app, deps) {
24
24
  const projects = await store.listProjects();
25
25
  return projects[0]?.name ?? "workspace";
26
26
  }
27
+ async function loadImage(ref) {
28
+ const asset = await store.getAsset(ref.id);
29
+ return asset ? { data: asset.data, mimeType: asset.contentType } : null;
30
+ }
27
31
  async function callTool(name, args, ctx) {
28
32
  switch (name) {
29
33
  case "publish":
@@ -39,9 +43,9 @@ export function registerMcp(app, deps) {
39
43
  history: args.history === true,
40
44
  }));
41
45
  case "feedback":
42
- return unwrap(await flows.feedback({ session: args.session, project: args.project }));
46
+ return unwrap(await flows.feedback({ session: args.session, project: args.project }, ctx));
43
47
  case "say":
44
- return unwrap(await flows.say(args.mock, args));
48
+ return unwrap(await flows.say(args.mock, args, ctx));
45
49
  case "export":
46
50
  return unwrap(await flows.exportMock(args.mock, args, ctx));
47
51
  case "upload": {
@@ -124,9 +128,11 @@ export function registerMcp(app, deps) {
124
128
  const name = msg.params?.name;
125
129
  if (name === "run") {
126
130
  const args = msg.params?.arguments ?? {};
127
- return rpc(msg.id, runToolResult(unwrap(await deps.run(args, ctx))));
131
+ const envelope = unwrap(await deps.run(args, ctx));
132
+ return rpc(msg.id, await withFeedbackImages(runToolResult(envelope), envelope, loadImage));
128
133
  }
129
- return rpc(msg.id, toolResult(name, await callTool(name, msg.params?.arguments ?? {}, ctx)));
134
+ const value = await callTool(name, msg.params?.arguments ?? {}, ctx);
135
+ return rpc(msg.id, await withFeedbackImages(toolResult(name, value), value, loadImage));
130
136
  }
131
137
  catch (err) {
132
138
  return rpc(msg.id, {
@@ -1,4 +1,5 @@
1
1
  import * as z from "zod/v4";
2
+ import { type ImageRef } from "./feedbackBatch.ts";
2
3
  export declare const MCP_SERVER_INFO: {
3
4
  name: string;
4
5
  version: string;
@@ -180,6 +181,13 @@ export declare function toolResult(name: string, value: unknown): {
180
181
  text: string;
181
182
  }[];
182
183
  };
184
+ export declare const MAX_RESULT_IMAGES = 8;
185
+ export declare function withFeedbackImages<T extends {
186
+ content: unknown[];
187
+ }>(result: T, value: unknown, load: (ref: ImageRef) => Promise<{
188
+ data: Uint8Array;
189
+ mimeType: string;
190
+ } | null>): Promise<T>;
183
191
  export declare function runToolResult(envelope: unknown): {
184
192
  isError?: boolean | undefined;
185
193
  structuredContent?: Record<string, unknown> | undefined;
@@ -1,5 +1,7 @@
1
1
  import * as z from "zod/v4";
2
+ import { encodeBase64 } from "./base64.js";
2
3
  import { GUIDE_TOPICS } from "./designGuide.js";
4
+ import { feedbackImages } from "./feedbackBatch.js";
3
5
  import { KIT_IDS } from "./kits.js";
4
6
  import { RUN_DESCRIPTION } from "./runApi.js";
5
7
  import { SURFACE_KINDS } from "./types.js";
@@ -245,6 +247,7 @@ const feedbackSchema = o({
245
247
  mix: bag,
246
248
  tuned: bag,
247
249
  comments: z.array(o({ part: nullStr, text: str })),
250
+ reactions: strs.optional().describe('One-tap reactions, e.g. 👍 part "hero" (variant B)'),
248
251
  })
249
252
  .nullable()
250
253
  .describe("The user's Send; null if only comments"),
@@ -288,7 +291,13 @@ const OUTPUT_SCHEMAS = {
288
291
  session: nullStr,
289
292
  },
290
293
  publish: writeOutput,
291
- ask: { asks: z.array(o({ id: str, text: str })), open: num, url: str, feedback: feedbackRide },
294
+ ask: {
295
+ asks: z.array(o({ id: str, text: str })),
296
+ open: num,
297
+ url: str,
298
+ nudges: strs.optional(),
299
+ feedback: feedbackRide,
300
+ },
292
301
  feedback: {
293
302
  feedback: z.array(feedbackSchema).describe("One batch per mock; empty when nothing is new"),
294
303
  pending: z.array(pendingSchema),
@@ -394,6 +403,22 @@ export function toolResult(name, value) {
394
403
  ...(structured ? { structuredContent: value } : {}),
395
404
  };
396
405
  }
406
+ // The images a result's feedback carries, inlined as image content so the
407
+ // model sees what the user attached instead of only its URL. Capped per result:
408
+ // the rest stay reachable by URL in the JSON.
409
+ export const MAX_RESULT_IMAGES = 8;
410
+ export async function withFeedbackImages(result, value, load) {
411
+ const batches = value?.feedback;
412
+ if (!Array.isArray(batches))
413
+ return result;
414
+ const images = [];
415
+ for (const ref of feedbackImages(batches).slice(0, MAX_RESULT_IMAGES)) {
416
+ const bytes = await load(ref).catch(() => null);
417
+ if (bytes)
418
+ images.push({ type: "image", data: encodeBase64(bytes.data), mimeType: bytes.mimeType });
419
+ }
420
+ return images.length ? { ...result, content: [...result.content, ...images] } : result;
421
+ }
397
422
  // A run that failed is still a full envelope (calls, feedback); isError tells
398
423
  // the client the script did not finish.
399
424
  export function runToolResult(envelope) {
@@ -170,13 +170,13 @@ export function createRunFlow(deps) {
170
170
  return [value, `${value.mock} ${value.asks.length} ask(s)`];
171
171
  },
172
172
  async feedback() {
173
- const value = await take(flows.feedback(await mine({})));
173
+ const value = await take(flows.feedback(await mine({}), flowCtx));
174
174
  const n = value.feedback.length;
175
175
  return [value, n ? `${n} batch(es)` : "no feedback"];
176
176
  },
177
177
  async say([v, message]) {
178
178
  const r = plain(v);
179
- await take(flows.say(r.mock, { ...(await mine(r)), message }));
179
+ await take(flows.say(r.mock, { ...(await mine(r)), message }, flowCtx));
180
180
  return [null, String(r.mock ?? "?")];
181
181
  },
182
182
  async upload([data, opts]) {