mockpit 2.0.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/README.md +60 -212
  2. package/bin/demoData.js +2 -2
  3. package/bin/initDesign.js +6 -6
  4. package/bin/mockpit.js +407 -187
  5. package/bin/poster.js +377 -0
  6. package/dist/mcp/server.js +80 -24
  7. package/dist/server/answerAdditions.d.ts +7 -0
  8. package/dist/server/answerAdditions.js +76 -0
  9. package/dist/server/apiViews.d.ts +289 -52
  10. package/dist/server/apiViews.js +189 -81
  11. package/dist/server/app.d.ts +7 -2
  12. package/dist/server/app.js +2845 -832
  13. package/dist/server/appIcon.js +15 -5
  14. package/dist/server/appIconPng.js +3 -3
  15. package/dist/server/askNudges.js +1 -1
  16. package/dist/server/cdnInline.d.ts +8 -0
  17. package/dist/server/cdnInline.js +638 -0
  18. package/dist/server/codeWorker.js +1 -1
  19. package/dist/server/decisionList.d.ts +45 -0
  20. package/dist/server/decisionList.js +302 -0
  21. package/dist/server/decisions.d.ts +4 -4
  22. package/dist/server/decisions.js +9 -9
  23. package/dist/server/designGuide.d.ts +5 -5
  24. package/dist/server/designGuide.js +128 -67
  25. package/dist/server/embeds.d.ts +3 -1
  26. package/dist/server/embeds.js +31 -6
  27. package/dist/server/eventLog.d.ts +2 -2
  28. package/dist/server/eventLog.js +11 -11
  29. package/dist/server/events.d.ts +9 -6
  30. package/dist/server/feedbackBatch.d.ts +35 -13
  31. package/dist/server/feedbackBatch.js +111 -47
  32. package/dist/server/fills.d.ts +4 -0
  33. package/dist/server/fills.js +55 -1
  34. package/dist/server/handoff.d.ts +4 -1
  35. package/dist/server/handoff.js +8 -2
  36. package/dist/server/icons.js +1 -1
  37. package/dist/server/index.js +39 -10
  38. package/dist/server/jump.d.ts +21 -0
  39. package/dist/server/jump.js +175 -0
  40. package/dist/server/knobs.d.ts +3 -3
  41. package/dist/server/knobs.js +6 -6
  42. package/dist/server/mcpHttp.d.ts +5 -1
  43. package/dist/server/mcpHttp.js +16 -11
  44. package/dist/server/mcpSpec.js +118 -46
  45. package/dist/server/parts.js +9 -1
  46. package/dist/server/run.d.ts +1 -1
  47. package/dist/server/run.js +25 -16
  48. package/dist/server/runApi.d.ts +3 -3
  49. package/dist/server/runApi.js +25 -19
  50. package/dist/server/sqlStore.d.ts +112 -96
  51. package/dist/server/sqlStore.js +906 -172
  52. package/dist/server/standings.d.ts +25 -0
  53. package/dist/server/standings.js +113 -0
  54. package/dist/server/surfacePage.d.ts +6 -9
  55. package/dist/server/surfacePage.js +361 -86
  56. package/dist/server/types.d.ts +226 -72
  57. package/dist/server/types.js +73 -78
  58. package/extensions/mockpit.js +89 -72
  59. package/guide/AGENT_SETUP.md +13 -1
  60. package/guide/packs/design-craft.md +2 -3
  61. package/guide/topics/asks.md +106 -62
  62. package/guide/topics/feedback.md +126 -119
  63. package/guide/topics/html.md +17 -24
  64. package/guide/topics/http.md +41 -41
  65. package/guide/topics/knobs.md +7 -10
  66. package/guide/topics/scripts.md +22 -33
  67. package/guide/topics/surfaces.md +39 -48
  68. package/opencode/mockpit.js +120 -0
  69. package/opencode/package.json +8 -0
  70. package/package.json +4 -3
  71. package/skills/launch-video/SKILL.md +11 -11
  72. package/skills/mockpit/SKILL.md +26 -22
  73. package/viewer/dist/index.html +9 -9
@@ -1,5 +1,6 @@
1
1
  import { type Decisions } from "./decisions.ts";
2
- import { type AskOption, type Comment, type CommentAnchor, type Mock, type MockEdit, OTHER_ID, type PartComment, type Post, type Reaction, type Reply, type Store } from "./types.ts";
2
+ import { type StandingView } from "./standings.ts";
3
+ import { type AskOption, type Comment, type CommentAnchor, type Design, type DesignEdit, OTHER_ID, type PartComment, type Post, type Reaction, type Reply, type Store } from "./types.ts";
3
4
  export interface VariantRef {
4
5
  state: string | null;
5
6
  variant: string;
@@ -27,14 +28,40 @@ export interface OtherChoice {
27
28
  other: true;
28
29
  images?: ImageRef[];
29
30
  }
30
- export interface AnsweredAsk {
31
+ export interface AnsweredAsk extends StandingView {
31
32
  ask: string;
32
33
  text: string;
33
34
  chosen: (AskOption | OtherChoice)[];
35
+ comment?: string;
36
+ commentImages?: ImageRef[];
37
+ }
38
+ export interface MoreAnswers {
39
+ ask: string;
40
+ text: string;
41
+ existing: string[];
34
42
  note?: string;
35
- noteImages?: ImageRef[];
43
+ like?: string;
44
+ kept?: string[];
45
+ hidden?: {
46
+ label: string;
47
+ why?: string;
48
+ }[];
49
+ answered: {
50
+ ask: string;
51
+ text: string;
52
+ chosen: string[];
53
+ comment?: string;
54
+ }[];
36
55
  }
37
- export interface FeedbackReply extends Omit<Reply, "others" | "notes" | "otherImages" | "noteImages"> {
56
+ export interface UpdateRequest {
57
+ ask: string;
58
+ text: string;
59
+ kept: AskOption;
60
+ inUse: AskOption[];
61
+ }
62
+ export interface FeedbackReply extends Omit<Reply, "others" | "askComments" | "otherImages" | "askCommentImages" | "more" | "standings" | "update"> {
63
+ more?: MoreAnswers;
64
+ update?: UpdateRequest;
38
65
  seq: number;
39
66
  at: string;
40
67
  asks: AnsweredAsk[];
@@ -42,25 +69,20 @@ export interface FeedbackReply extends Omit<Reply, "others" | "notes" | "otherIm
42
69
  decisions?: Decisions;
43
70
  }
44
71
  export interface FeedbackBatch {
45
- mockId: string | null;
72
+ designId: string | null;
46
73
  project: string | null;
47
- mock: string | null;
74
+ design: string | null;
48
75
  title: string | null;
49
76
  reply: FeedbackReply | null;
50
77
  messages: FeedbackMessage[];
51
78
  inUse: InUseRef[];
52
79
  archived: VariantRef[];
53
80
  handoff?: string;
54
- closed?: true;
55
- dismissed?: {
56
- ask: string;
57
- text: string;
58
- }[];
59
- edited?: MockEdit;
81
+ edited?: DesignEdit;
60
82
  }
61
83
  export declare function reactionLine(c: PartComment & {
62
84
  reaction: Reaction;
63
85
  }): string;
64
- export declare function groupFeedback(comments: Comment[], mocks: Map<string, Mock>, posts: Map<string, Post[]>, base?: string): FeedbackBatch[];
86
+ export declare function groupFeedback(comments: Comment[], designs: Map<string, Design>, posts: Map<string, Post[]>, base?: string): FeedbackBatch[];
65
87
  export declare function buildFeedbackBatches(store: Store, comments: Comment[], base: string): Promise<FeedbackBatch[]>;
66
88
  export declare function feedbackImages(batches: FeedbackBatch[]): ImageRef[];
@@ -1,10 +1,60 @@
1
- // The agent-facing shape of feedback: one batch per mock, in delivery order.
1
+ // The agent-facing shape of feedback: one batch per design, in delivery order.
2
2
  // The `feedback` verb, the watch long-poll and the write piggyback all return this,
3
- // so an agent reads one grouped object instead of re-deriving which mock a flat
3
+ // so an agent reads one grouped object instead of re-deriving which design a flat
4
4
  // comment list belongs to. Runtime-agnostic (no node imports).
5
5
  import { decisionsFor } from "./decisions.js";
6
+ import { ROUTED_NOTE } from "./handoff.js";
7
+ import { standingOf, standingView } from "./standings.js";
6
8
  import { builtinAsk, inUseVersion, DEFAULT_VARIANT, OTHER_ID, } from "./types.js";
7
9
  const ref = (p) => ({ state: p.state, variant: p.variant });
10
+ function moreAnswers(design, more, sent) {
11
+ const ask = design.asks.find((a) => a.id === more.ask);
12
+ if (!ask)
13
+ return undefined;
14
+ const st = sent ?? standingOf(design, ask.id);
15
+ const view = standingView(ask, st);
16
+ const like = more.like ? ask.options.find((o) => o.id === more.like)?.label : undefined;
17
+ const labels = (a) => {
18
+ const ids = a.answer === undefined ? [] : Array.isArray(a.answer) ? a.answer : [a.answer];
19
+ return [
20
+ ...ids.map((id) => a.options.find((o) => o.id === id)?.label ?? id),
21
+ ...(a.other === undefined ? [] : [a.other]),
22
+ ];
23
+ };
24
+ return {
25
+ ask: ask.id,
26
+ text: ask.text,
27
+ // Hidden ones are out: asked again, they'd only fold away.
28
+ existing: ask.options.filter((o) => !st.hidden.some((h) => h.id === o.id)).map((o) => o.label),
29
+ ...(more.note ? { note: more.note } : {}),
30
+ ...(like ? { like } : {}),
31
+ ...(view?.kept ? { kept: view.kept.map((k) => k.label) } : {}),
32
+ ...(view?.hidden
33
+ ? { hidden: view.hidden.map((h) => ({ label: h.label, ...(h.why ? { why: h.why } : {}) })) }
34
+ : {}),
35
+ answered: design.asks
36
+ .filter((a) => a.id !== ask.id && labels(a).length > 0)
37
+ .map((a) => ({
38
+ ask: a.id,
39
+ text: a.text,
40
+ chosen: labels(a),
41
+ ...(a.comment ? { comment: a.comment } : {}),
42
+ })),
43
+ };
44
+ }
45
+ function updateRequest(design, wanted) {
46
+ const ask = design.asks.find((a) => a.id === wanted.ask);
47
+ const kept = ask?.options.find((o) => o.id === wanted.option);
48
+ if (!ask || !kept)
49
+ return undefined;
50
+ const ids = ask.answer === undefined ? [] : [ask.answer].flat();
51
+ return {
52
+ ask: ask.id,
53
+ text: ask.text,
54
+ kept,
55
+ inUse: ask.options.filter((o) => ids.includes(o.id)),
56
+ };
57
+ }
8
58
  const REACTION_LEAD = {
9
59
  up: "👍",
10
60
  down: "👎",
@@ -12,7 +62,7 @@ const REACTION_LEAD = {
12
62
  };
13
63
  // `👍 part "hero" (variant B, state Mobile)`, `👎 variant B`, `more like this: the page`.
14
64
  export function reactionLine(c) {
15
- // A mock published without variant names has only "default": naming it is noise.
65
+ // A design published without variant names has only "default": naming it is noise.
16
66
  const named = c.variant && c.variant !== DEFAULT_VARIANT ? c.variant : null;
17
67
  // A version is written as agents write it, dark@2.
18
68
  const at = c.version ? `@${c.version}` : "";
@@ -31,21 +81,32 @@ export function reactionLine(c) {
31
81
  return `${REACTION_LEAD[c.reaction]} ${target}${tail}${note}`;
32
82
  }
33
83
  const isReaction = (c) => !!c.reaction;
34
- function answered(reply, mock, posts, base) {
84
+ function answered(reply, design, posts, base) {
35
85
  const others = reply.others ?? {};
36
- const notes = reply.notes ?? {};
86
+ const comments = reply.askComments ?? {};
37
87
  const refs = (ids) => (ids ?? []).map((id) => ({ id, url: `${base}/a/${encodeURIComponent(id)}` }));
88
+ const standings = reply.standings ?? {};
38
89
  const askIds = new Set([
39
90
  ...Object.keys(reply.answers),
40
91
  ...Object.keys(others),
41
- ...Object.keys(notes),
92
+ ...Object.keys(comments),
93
+ // A question whose answers the user only kept or hid is still news: more answers
94
+ // should follow the kept ones and avoid the hidden.
95
+ ...Object.keys(standings).filter((id) => id !== reply.more?.ask),
42
96
  ]);
43
97
  const out = [];
44
98
  for (const askId of askIds) {
45
- const ask = mock?.asks.find((a) => a.id === askId) ?? (mock && builtinAsk(mock, posts, askId));
99
+ const ask = design?.asks.find((a) => a.id === askId) ?? (design && builtinAsk(design, posts, askId));
46
100
  const answer = reply.answers[askId];
47
101
  const ids = answer === undefined ? [] : Array.isArray(answer) ? answer : [answer];
48
- const chosen = ids.map((id) => ask?.options.find((o) => o.id === id) ?? { id, label: id });
102
+ const chosen = ids.map((id) => {
103
+ const option = ask?.options.find((o) => o.id === id);
104
+ if (!option)
105
+ return { id, label: id };
106
+ // What it brought isn't in any version yet: an agent folding it in needs it here.
107
+ const brought = design?.answerAdditions.find((a) => Object.entries(option.set ?? {}).some(([k, v]) => a.knob === k && a.value === v));
108
+ return brought ? { ...option, brought } : option;
109
+ });
49
110
  const otherImages = refs(reply.otherImages?.[askId]);
50
111
  if (others[askId] !== undefined)
51
112
  chosen.push({
@@ -54,38 +115,44 @@ function answered(reply, mock, posts, base) {
54
115
  other: true,
55
116
  ...(otherImages.length ? { images: otherImages } : {}),
56
117
  });
57
- const noteImages = refs(reply.noteImages?.[askId]);
118
+ const commentImages = refs(reply.askCommentImages?.[askId]);
119
+ const st = standings[askId];
58
120
  out.push({
59
121
  ask: askId,
60
122
  text: ask?.text ?? "",
61
123
  chosen,
62
- ...(notes[askId] !== undefined ? { note: notes[askId] } : {}),
63
- ...(noteImages.length ? { noteImages } : {}),
124
+ ...(ask && st ? standingView({ ...ask, answer }, st) : {}),
125
+ ...(comments[askId] !== undefined ? { comment: comments[askId] } : {}),
126
+ ...(commentImages.length ? { commentImages } : {}),
64
127
  });
65
128
  }
66
129
  return out;
67
130
  }
68
131
  // Comments arrive in seq order; keep that order both between batches and inside
69
- // one. A mock gets a fresh batch whenever a second reply arrives for it, so a
132
+ // one. A design gets a fresh batch whenever a second reply arrives for it, so a
70
133
  // batch carries at most one reply and nothing is merged away. A comment whose
71
- // mock is unknown still gets a batch — feedback is never dropped for want of
134
+ // design is unknown still gets a batch — feedback is never dropped for want of
72
135
  // context. `base` (origin + base path) makes image URLs absolute.
73
- export function groupFeedback(comments, mocks, posts, base = "") {
136
+ export function groupFeedback(comments, designs, posts, base = "") {
74
137
  const batches = [];
75
138
  const open = new Map();
76
139
  for (const c of comments) {
77
- const key = c.mockId ?? "";
78
- const mock = c.mockId ? mocks.get(c.mockId) : undefined;
79
- const mockPosts = (c.mockId && posts.get(c.mockId)) || [];
140
+ // Stored from when a close wrote to this stream. Ending something tells the agent
141
+ // nothing, so one still ahead of the cursor stays unsaid.
142
+ if (c.kind === "reply" && c.payload?.archived)
143
+ continue;
144
+ const key = c.designId ?? "";
145
+ const design = c.designId ? designs.get(c.designId) : undefined;
146
+ const designPosts = (c.designId && posts.get(c.designId)) || [];
80
147
  let batch = open.get(key);
81
- // An edit says where the mock is now, so it joins whatever batch is open.
148
+ // An edit says where the design is now, so it joins whatever batch is open.
82
149
  const edit = c.kind === "reply" ? c.payload?.edited : undefined;
83
- if (!batch || (c.kind === "reply" && !edit && (batch.reply || batch.closed))) {
150
+ if (!batch || (c.kind === "reply" && !edit && batch.reply)) {
84
151
  batch = {
85
- mockId: c.mockId,
86
- project: mock?.project ?? null,
87
- mock: mock?.slug ?? null,
88
- title: mock?.title ?? null,
152
+ designId: c.designId,
153
+ project: design?.project ?? null,
154
+ design: design?.slug ?? null,
155
+ title: design?.title ?? null,
89
156
  reply: null,
90
157
  messages: [],
91
158
  inUse: [],
@@ -98,36 +165,33 @@ export function groupFeedback(comments, mocks, posts, base = "") {
98
165
  batch.edited = { ...batch.edited, ...edit };
99
166
  continue;
100
167
  }
101
- if (c.kind === "reply" && c.payload?.closed) {
102
- batch.closed = true;
103
- batch.dismissed = c.payload.closed.dismissed.map((id) => ({
104
- ask: id,
105
- text: (mock?.asks.find((a) => a.id === id) ?? (mock && builtinAsk(mock, mockPosts, id)))
106
- ?.text ?? "",
107
- }));
108
- continue;
109
- }
110
168
  if (c.kind === "reply" && c.payload) {
111
- const { others: _others, notes: _notes, otherImages: _otherImages, noteImages: _noteImages, ...payload } = c.payload;
169
+ const { others: _others, askComments: _askComments, otherImages: _otherImages, askCommentImages: _askCommentImages, routed, more: asked, standings: _standings, update: wanted, ...payload } = c.payload;
170
+ const more = asked && design ? moreAnswers(design, asked, c.payload.standings?.[asked.ask]) : undefined;
171
+ const update = wanted && design ? updateRequest(design, wanted) : undefined;
172
+ if (routed)
173
+ batch.handoff = ROUTED_NOTE;
112
174
  const reactions = (payload.comments ?? []).filter(isReaction).map(reactionLine);
113
175
  batch.reply = {
114
176
  ...payload,
115
177
  comments: (payload.comments ?? []).filter((x) => !x.reaction),
116
178
  seq: c.seq,
117
179
  at: c.createdAt,
118
- asks: answered(c.payload, mock, mockPosts, base),
180
+ asks: answered(c.payload, design, designPosts, base),
119
181
  ...(reactions.length ? { reactions } : {}),
182
+ ...(more ? { more } : {}),
183
+ ...(update ? { update } : {}),
120
184
  };
121
- const decisions = mock ? decisionsFor(mock, mockPosts, c.payload) : null;
185
+ const decisions = design ? decisionsFor(design, designPosts, c.payload) : null;
122
186
  if (decisions)
123
187
  batch.reply.decisions = decisions;
124
- batch.inUse = mockPosts
188
+ batch.inUse = designPosts
125
189
  .filter((p) => p.status === "accepted")
126
190
  .map((p) => ({ ...ref(p), version: inUseVersion(p) }));
127
- batch.archived = mockPosts.filter((p) => p.status === "archived").map(ref);
191
+ batch.archived = designPosts.filter((p) => p.status === "archived").map(ref);
128
192
  continue;
129
193
  }
130
- const post = c.postId ? mockPosts.find((p) => p.id === c.postId) : undefined;
194
+ const post = c.postId ? designPosts.find((p) => p.id === c.postId) : undefined;
131
195
  batch.messages.push({
132
196
  seq: c.seq,
133
197
  text: c.text,
@@ -142,18 +206,18 @@ export function groupFeedback(comments, mocks, posts, base = "") {
142
206
  return batches;
143
207
  }
144
208
  export async function buildFeedbackBatches(store, comments, base) {
145
- const mocks = new Map();
209
+ const designs = new Map();
146
210
  const posts = new Map();
147
211
  for (const c of comments) {
148
- if (!c.mockId || mocks.has(c.mockId))
212
+ if (!c.designId || designs.has(c.designId))
149
213
  continue;
150
- const mock = await store.getMock(c.mockId);
151
- if (!mock)
214
+ const design = await store.getDesign(c.designId);
215
+ if (!design)
152
216
  continue;
153
- mocks.set(c.mockId, mock);
154
- posts.set(c.mockId, await store.listPosts({ mockId: c.mockId }));
217
+ designs.set(c.designId, design);
218
+ posts.set(c.designId, await store.listPosts({ designId: c.designId }));
155
219
  }
156
- return groupFeedback(comments, mocks, posts, base);
220
+ return groupFeedback(comments, designs, posts, base);
157
221
  }
158
222
  // Every image a list of batches carries, in order — what the MCP tiers attach
159
223
  // as image content so the model sees what the user attached.
@@ -164,8 +228,8 @@ export function feedbackImages(batches) {
164
228
  for (const c of a.chosen)
165
229
  if ("other" in c && c.images)
166
230
  out.push(...c.images);
167
- if (a.noteImages)
168
- out.push(...a.noteImages);
231
+ if (a.commentImages)
232
+ out.push(...a.commentImages);
169
233
  }
170
234
  }
171
235
  return out;
@@ -26,9 +26,13 @@ export interface FillScan {
26
26
  }
27
27
  export declare function scanFills(html: string): FillScan;
28
28
  export declare function slotKnobs(slots: SlotInfo[]): Knobs;
29
+ export declare const cssString: (s: string) => string;
29
30
  export declare function slotCss(slots: SlotInfo[]): string;
30
31
  export declare function composeFills(html: string, chosen: Record<string, string>): string;
31
32
  export declare function slotsInSurfaces(surfaces: Surface[]): SlotInfo[];
33
+ export declare const AS_IS_FILL = "as-is";
34
+ export declare function partOccurrences(html: string, part: string): number;
35
+ export declare function addFill(html: string, part: string, fill: string, markup: string): string;
32
36
  export type FillSplice = {
33
37
  ok: true;
34
38
  surfaces: Surface[];
@@ -165,7 +165,7 @@ export function slotKnobs(slots) {
165
165
  }
166
166
  return out;
167
167
  }
168
- const cssString = (s) => `"${s.replace(/[\\"]/g, (c) => `\\${c}`).replace(/[\n\r<>]/g, (c) => `\\${c.charCodeAt(0).toString(16)} `)}"`;
168
+ export const cssString = (s) => `"${s.replace(/[\\"]/g, (c) => `\\${c}`).replace(/[\n\r<>]/g, (c) => `\\${c.charCodeAt(0).toString(16)} `)}"`;
169
169
  // Shows only the chosen fill of each slot, keyed on the `data-k-<part>`
170
170
  // attribute the knob runtime keeps on <html>. The chosen fill is never styled:
171
171
  // only the others are hidden, so a fill keeps whatever display the agent gave it.
@@ -254,6 +254,60 @@ function markFill(html, part, fill) {
254
254
  const at = m.index + m[0].indexOf("<") + 1 + m[1].length;
255
255
  return html.slice(0, at) + add + html.slice(at);
256
256
  }
257
+ // The name a plain part's own markup goes by once an answer adds a fill to it.
258
+ export const AS_IS_FILL = "as-is";
259
+ // How many elements declare this part, fills counted once each. A fill can only be
260
+ // added to a part that is one thing or already has fills.
261
+ export function partOccurrences(html, part) {
262
+ if (scanAll(html).ranges.some((r) => r.part === part))
263
+ return 1;
264
+ let n = 0;
265
+ walkOpenTags(html, (attrs) => {
266
+ if (attrs["data-part"] === part)
267
+ n++;
268
+ });
269
+ return n;
270
+ }
271
+ function walkOpenTags(html, visit) {
272
+ TOKEN.lastIndex = 0;
273
+ let m;
274
+ while ((m = TOKEN.exec(html))) {
275
+ if (m[0].startsWith("<!--") || m[1])
276
+ continue;
277
+ const tag = m[2].toLowerCase();
278
+ const end = TOKEN.lastIndex;
279
+ visit(attributes(m[3] ?? ""), m.index + 1 + m[2].length);
280
+ TOKEN.lastIndex = end;
281
+ if (RAW.has(tag)) {
282
+ const endTag = new RegExp(`</${tag}\\s*>`, "gi");
283
+ endTag.lastIndex = end;
284
+ const e = endTag.exec(html);
285
+ TOKEN.lastIndex = e ? endTag.lastIndex : html.length;
286
+ }
287
+ }
288
+ }
289
+ // Adds a fill at render time, leaving the html as it was when it can't: a version
290
+ // without this part has nothing to fill, and one with its own fill of this name
291
+ // already says what that value means.
292
+ export function addFill(html, part, fill, markup) {
293
+ const ranges = scanAll(html).ranges.filter((r) => r.part === part);
294
+ if (ranges.some((r) => r.fill === fill))
295
+ return html;
296
+ let base = html;
297
+ if (ranges.length === 0) {
298
+ const at = [];
299
+ walkOpenTags(html, (attrs, tagNameEnd) => {
300
+ if (attrs["data-part"] === part)
301
+ at.push(tagNameEnd);
302
+ });
303
+ if (at.length !== 1)
304
+ return html;
305
+ base = `${html.slice(0, at[0])} data-fill="${AS_IS_FILL}" data-fill-default${html.slice(at[0])}`;
306
+ }
307
+ const spliced = spliceFills([{ kind: "html", html: base }], { [`${part}#${fill}`]: markup });
308
+ const surface = spliced.ok ? spliced.surfaces[0] : undefined;
309
+ return surface?.kind === "html" ? surface.html : html;
310
+ }
257
311
  // Replace one fill of a slot, or add it after the slot's last fill, keyed
258
312
  // "part#fill". Like spliceParts, every target is located in the original
259
313
  // markup and overlapping edits are refused.
@@ -1,6 +1,9 @@
1
1
  import type { Comment, Session } from "./types.ts";
2
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.";
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 design before acting.";
4
+ export declare const ROUTED_NOTE = "The agent that asked this wasn't listening, so the user sent it to you; it is yours now. Read the design before acting.";
5
+ export declare const OWNER_BUSY_MS: number;
4
6
  export declare const ownerActiveAt: (s: Session) => string;
7
+ export declare const sessionName: (s: Session) => string;
5
8
  export declare function supersedes(taker: Session, owner: Session, listening: boolean): boolean;
6
9
  export declare function handoffReady(comment: Comment, owner: Session, taker: Session, now: number): boolean;
@@ -1,4 +1,4 @@
1
- // Orphaned replies: a Send lands on the session that owns the mock, and when
1
+ // Orphaned replies: a Send lands on the session that owns the design, and when
2
2
  // that agent restarts into a new session (a new conversation, /clear, a crash)
3
3
  // nobody would ever read it. The rule below decides when a newer session may
4
4
  // take such a reply instead. Runtime-agnostic (no node imports).
@@ -20,8 +20,14 @@
20
20
  // it got before the taker existed goes at once — the user answered, then
21
21
  // restarted. A keyed owner always gets the grace: its conversation may resume.
22
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.";
23
+ export const HANDOFF_NOTE = "Sent to an earlier agent session in this project that has gone quiet; it is yours now. Read the design before acting.";
24
+ export const ROUTED_NOTE = "The agent that asked this wasn't listening, so the user sent it to you; it is yours now. Read the design before acting.";
25
+ // A waiter stops between a wake and the end of that turn, so an owner that called
26
+ // this recently is working, not gone, and keeps its replies. Same span as "Agent working".
27
+ export const OWNER_BUSY_MS = 5 * 60_000;
24
28
  export const ownerActiveAt = (s) => s.agentActiveAt ?? s.lastActiveAt;
29
+ // All the user can recognise a session by: most have no title, and ids mean nothing.
30
+ export const sessionName = (s) => s.cwd?.split(/[\\/]/).filter(Boolean).pop() ?? s.agent;
25
31
  export function supersedes(taker, owner, listening) {
26
32
  return (owner.id !== taker.id &&
27
33
  owner.project !== null &&
@@ -4,7 +4,7 @@
4
4
  // imports): it runs inside renderHtmlPage on the Worker too.
5
5
  // Served with the server rather than installed per project: lucide (ISC) is
6
6
  // what agents reach for by default, mage (Apache-2.0) is what `mockpit init`
7
- // used to upload as a sprite, so mocks written against it keep rendering.
7
+ // used to upload as a sprite, so designs written against it keep rendering.
8
8
  export const BUNDLED_ICON_PREFIXES = ["lucide", "mage"];
9
9
  const NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
10
10
  export const isIconName = (s) => typeof s === "string" && NAME_RE.test(s);
@@ -1,7 +1,9 @@
1
1
  import { serve } from "@hono/node-server";
2
+ import { Hono } from "hono";
3
+ import { compress } from "hono/compress";
2
4
  import { createHash } from "node:crypto";
3
5
  import { readdir, readFile } from "node:fs/promises";
4
- import { homedir } from "node:os";
6
+ import { homedir, networkInterfaces } from "node:os";
5
7
  import { basename, dirname, join } from "node:path";
6
8
  import { fileURLToPath } from "node:url";
7
9
  import { createApp } from "./app.js";
@@ -40,6 +42,10 @@ for (const name of (await readdir(serverDir)).sort()) {
40
42
  renderHash.update(await readFile(join(serverDir, name)));
41
43
  }
42
44
  renderHash.update(await readFile(join(root, "package-lock.json")).catch(() => pkgJson));
45
+ // Browsers keep a version's document for a year, so a version changed outside
46
+ // the API (a direct store edit) stays stale in them; a new salt on restart
47
+ // re-fetches every document without touching code.
48
+ renderHash.update(process.env.MOCKPIT_RENDER_SALT ?? "");
43
49
  const renderRev = renderHash.digest("hex").slice(0, 10);
44
50
  const pr = process.env.MOCKPIT_PUBLIC_READ;
45
51
  const publicRead = pr === "session" || pr === "full" ? pr : undefined;
@@ -48,7 +54,9 @@ const publicRead = pr === "session" || pr === "full" ? pr : undefined;
48
54
  // default lives in ~/.mockpit/, a user-owned dir that survives reinstalls and
49
55
  // is writable however the package was installed.
50
56
  const dbPath = process.env.MOCKPIT_DB ?? join(homedir(), ".mockpit", "mockpit.db");
51
- const store = new SqlStore(createSqliteStorage(dbPath));
57
+ // Far past the Durable Object's budget: one lab of inlined pages can be 70 MB,
58
+ // and a design that doesn't fit is re-read and re-parsed on every request.
59
+ const store = new SqlStore(createSqliteStorage(dbPath), { postCacheBytes: 256 * 1024 * 1024 });
52
60
  console.log(`mockpit store: SQLite at ${dbPath}`);
53
61
  // The event log is on by default, next to the database, so a session can be
54
62
  // diagnosed after the fact without having planned to (`scripts/inspect.ts`).
@@ -64,6 +72,27 @@ if (logPath)
64
72
  // database never picks up the real ones.
65
73
  const rulesPath = dbPath === ":memory:" ? undefined : join(dirname(dbPath), "rules.md");
66
74
  const userRules = rulesPath ? () => readFile(rulesPath, "utf8").catch(() => null) : undefined;
75
+ const port = Number(process.env.PORT ?? 8228);
76
+ // MOCKPIT_HOST (or `serve --host`) restricts the listener to one address.
77
+ // Unset keeps the previous behaviour — node's default, every interface — because
78
+ // that is what a container or a LAN-shared instance needs. Set it to 127.0.0.1
79
+ // when the server shares a host with anything you don't want reaching it; that
80
+ // is stronger than the token, which is a single shared secret by design.
81
+ const hostname = process.env.MOCKPIT_HOST || undefined;
82
+ // The addresses a phone might open this server at. Read when asked, since a laptop
83
+ // changes networks while it runs.
84
+ const networkHosts = () => {
85
+ if (hostname === "127.0.0.1" || hostname === "::1" || hostname === "localhost") {
86
+ return { localOnly: true, hosts: [] };
87
+ }
88
+ if (hostname && hostname !== "0.0.0.0" && hostname !== "::") {
89
+ return { localOnly: false, hosts: [`${hostname}:${port}`] };
90
+ }
91
+ const hosts = Object.values(networkInterfaces())
92
+ .flat()
93
+ .flatMap((a) => (a && a.family === "IPv4" && !a.internal ? [`${a.address}:${port}`] : []));
94
+ return { localOnly: false, hosts: hosts.slice(0, 3) };
95
+ };
67
96
  const app = createApp({
68
97
  store,
69
98
  viewerHtml,
@@ -80,16 +109,16 @@ const app = createApp({
80
109
  renderRev,
81
110
  // `run` scripts execute in QuickJS inside a worker thread per run.
82
111
  executor: createNodeExecutor(),
112
+ networkHosts,
83
113
  log: logPath ? createFileLog(logPath) : undefined,
84
114
  });
85
- const port = Number(process.env.PORT ?? 8228);
86
- // MOCKPIT_HOST (or `serve --host`) restricts the listener to one address.
87
- // Unset keeps the previous behaviour — node's default, every interface — because
88
- // that is what a container or a LAN-shared instance needs. Set it to 127.0.0.1
89
- // when the server shares a host with anything you don't want reaching it; that
90
- // is stronger than the token, which is a single shared secret by design.
91
- const hostname = process.env.MOCKPIT_HOST || undefined;
92
- serve({ fetch: app.fetch, port, hostname }, (info) => {
115
+ // Cloudflare compresses at its edge; a Node server reached over a network
116
+ // (a tailnet, a LAN) would otherwise send the viewer and every list uncompressed.
117
+ // Hono's filter leaves SSE streams alone.
118
+ const outer = new Hono();
119
+ outer.use(compress());
120
+ outer.all("*", (c) => app.fetch(c.req.raw, c.env));
121
+ serve({ fetch: outer.fetch, port, hostname }, (info) => {
93
122
  // Report the address actually bound. Printing "localhost" unconditionally hid
94
123
  // the fact that the default listens on every interface.
95
124
  const shown = hostname ?? "localhost";
@@ -0,0 +1,21 @@
1
+ import { type JumpRows, type DesignKind } from "./types.ts";
2
+ export type JumpMatch = "title" | "state" | "variant" | "area" | "question" | "description";
3
+ export interface JumpHit {
4
+ project: string;
5
+ id: string | null;
6
+ slug: string | null;
7
+ title: string;
8
+ kind: DesignKind | null;
9
+ width: number | null;
10
+ area: string | null;
11
+ match: JumpMatch;
12
+ text: string | null;
13
+ state: string | null;
14
+ variant: string | null;
15
+ archived: boolean;
16
+ open: number;
17
+ designArchived: boolean;
18
+ inUse: boolean;
19
+ }
20
+ export declare function jumpHits(rows: JumpRows, query: string, project: string | null): JumpHit[];
21
+ export declare function recentHits(rows: JumpRows, ids: string[]): JumpHit[];