mockpit 1.1.0 → 1.3.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 (62) hide show
  1. package/README.md +13 -6
  2. package/bin/mockpit.js +139 -18
  3. package/dist/mcp/server.js +30 -10
  4. package/dist/server/apiViews.d.ts +129 -25
  5. package/dist/server/apiViews.js +86 -23
  6. package/dist/server/app.d.ts +2 -1
  7. package/dist/server/app.js +1258 -200
  8. package/dist/server/appIcon.d.ts +22 -0
  9. package/dist/server/appIcon.js +43 -0
  10. package/dist/server/appIconPng.d.ts +1 -0
  11. package/dist/server/appIconPng.js +6 -0
  12. package/dist/server/askNudges.d.ts +3 -0
  13. package/dist/server/askNudges.js +40 -0
  14. package/dist/server/base64.d.ts +1 -0
  15. package/dist/server/base64.js +8 -0
  16. package/dist/server/decisions.d.ts +23 -0
  17. package/dist/server/decisions.js +99 -0
  18. package/dist/server/designGuide.js +53 -16
  19. package/dist/server/embeds.d.ts +25 -0
  20. package/dist/server/embeds.js +118 -0
  21. package/dist/server/eventLog.js +2 -1
  22. package/dist/server/feedbackBatch.d.ts +28 -5
  23. package/dist/server/feedbackBatch.js +78 -11
  24. package/dist/server/fills.d.ts +41 -0
  25. package/dist/server/fills.js +328 -0
  26. package/dist/server/handoff.d.ts +6 -0
  27. package/dist/server/handoff.js +38 -0
  28. package/dist/server/index.js +15 -1
  29. package/dist/server/knobs.d.ts +6 -2
  30. package/dist/server/knobs.js +13 -4
  31. package/dist/server/mcpHttp.d.ts +5 -2
  32. package/dist/server/mcpHttp.js +14 -5
  33. package/dist/server/mcpSpec.d.ts +21 -13
  34. package/dist/server/mcpSpec.js +99 -9
  35. package/dist/server/postSurfaces.js +3 -12
  36. package/dist/server/richRender.js +3 -1
  37. package/dist/server/run.js +6 -2
  38. package/dist/server/runApi.d.ts +1 -1
  39. package/dist/server/runApi.js +5 -4
  40. package/dist/server/sqlStore.d.ts +92 -5
  41. package/dist/server/sqlStore.js +188 -30
  42. package/dist/server/surfacePage.d.ts +6 -2
  43. package/dist/server/surfacePage.js +142 -10
  44. package/dist/server/themes.d.ts +2 -0
  45. package/dist/server/themes.js +23 -3
  46. package/dist/server/types.d.ts +95 -10
  47. package/dist/server/types.js +89 -6
  48. package/dist/server/viewerRequest.d.ts +2 -0
  49. package/dist/server/viewerRequest.js +14 -0
  50. package/extensions/mockpit.js +1 -0
  51. package/guide/topics/asks.md +69 -10
  52. package/guide/topics/feedback.md +104 -16
  53. package/guide/topics/html.md +5 -1
  54. package/guide/topics/http.md +2 -1
  55. package/guide/topics/knobs.md +4 -3
  56. package/guide/topics/surfaces.md +32 -16
  57. package/package.json +4 -13
  58. package/skills/launch-video/SKILL.md +1 -1
  59. package/skills/mockpit/SKILL.md +14 -4
  60. package/viewer/dist/index.html +9 -9
  61. package/dist/server/slots.d.ts +0 -14
  62. package/dist/server/slots.js +0 -71
@@ -1,8 +1,12 @@
1
- import { type AskOption, type Comment, type CommentAnchor, type Mock, OTHER_ID, type Post, type Reply, type Store } from "./types.ts";
1
+ import { type Decisions } from "./decisions.ts";
2
+ 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
3
  export interface VariantRef {
3
4
  state: string | null;
4
5
  variant: string;
5
6
  }
7
+ export interface InUseRef extends VariantRef {
8
+ version: number;
9
+ }
6
10
  export interface FeedbackComment {
7
11
  seq: number;
8
12
  text: string;
@@ -13,21 +17,29 @@ export interface FeedbackComment {
13
17
  anchors?: Comment["anchors"];
14
18
  viewport?: number;
15
19
  }
20
+ export interface ImageRef {
21
+ id: string;
22
+ url: string;
23
+ }
16
24
  export interface OtherChoice {
17
25
  id: typeof OTHER_ID;
18
26
  label: string;
19
27
  other: true;
28
+ images?: ImageRef[];
20
29
  }
21
30
  export interface AnsweredAsk {
22
31
  ask: string;
23
32
  text: string;
24
33
  chosen: (AskOption | OtherChoice)[];
25
34
  note?: string;
35
+ noteImages?: ImageRef[];
26
36
  }
27
- export interface FeedbackReply extends Omit<Reply, "others" | "notes"> {
37
+ export interface FeedbackReply extends Omit<Reply, "others" | "notes" | "otherImages" | "noteImages"> {
28
38
  seq: number;
29
39
  at: string;
30
40
  asks: AnsweredAsk[];
41
+ reactions?: string[];
42
+ decisions?: Decisions;
31
43
  }
32
44
  export interface FeedbackBatch {
33
45
  mockId: string | null;
@@ -36,8 +48,19 @@ export interface FeedbackBatch {
36
48
  title: string | null;
37
49
  reply: FeedbackReply | null;
38
50
  comments: FeedbackComment[];
39
- accepted: VariantRef[];
51
+ accepted: InUseRef[];
40
52
  archived: VariantRef[];
53
+ dropped: VariantRef[];
54
+ handoff?: string;
55
+ resolved?: true;
56
+ dismissed?: {
57
+ ask: string;
58
+ text: string;
59
+ }[];
41
60
  }
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[]>;
61
+ export declare function reactionLine(c: PartComment & {
62
+ reaction: Reaction;
63
+ }): string;
64
+ export declare function groupFeedback(comments: Comment[], mocks: Map<string, Mock>, posts: Map<string, Post[]>, base?: string): FeedbackBatch[];
65
+ export declare function buildFeedbackBatches(store: Store, comments: Comment[], base: string): Promise<FeedbackBatch[]>;
66
+ export declare function feedbackImages(batches: FeedbackBatch[]): ImageRef[];
@@ -2,11 +2,36 @@
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 { decisionsFor } from "./decisions.js";
6
+ import { builtinAsk, inUseVersion, DEFAULT_VARIANT, OTHER_ID, } from "./types.js";
6
7
  const ref = (p) => ({ state: p.state, variant: p.variant });
7
- function answered(reply, mock, posts) {
8
+ const REACTION_LEAD = {
9
+ up: "👍",
10
+ down: "👎",
11
+ more: "more like this:",
12
+ };
13
+ // `👍 part "hero" (variant B, state Mobile)`, `👎 variant B`, `more like this: the page`.
14
+ export function reactionLine(c) {
15
+ // A mock published without variant names has only "default": naming it is noise.
16
+ const variant = c.variant && c.variant !== DEFAULT_VARIANT ? c.variant : null;
17
+ const where = [
18
+ c.part !== null && variant ? `variant ${variant}` : "",
19
+ c.state ? `state ${c.state}` : "",
20
+ ].filter(Boolean);
21
+ const target = c.part !== null
22
+ ? `part ${JSON.stringify(c.part)}`
23
+ : variant
24
+ ? `variant ${variant}`
25
+ : "the page";
26
+ const tail = where.length ? ` (${where.join(", ")})` : "";
27
+ const note = c.text ? ` “${c.text}”` : "";
28
+ return `${REACTION_LEAD[c.reaction]} ${target}${tail}${note}`;
29
+ }
30
+ const isReaction = (c) => !!c.reaction;
31
+ function answered(reply, mock, posts, base) {
8
32
  const others = reply.others ?? {};
9
33
  const notes = reply.notes ?? {};
34
+ const refs = (ids) => (ids ?? []).map((id) => ({ id, url: `${base}/a/${encodeURIComponent(id)}` }));
10
35
  const askIds = new Set([
11
36
  ...Object.keys(reply.answers),
12
37
  ...Object.keys(others),
@@ -18,13 +43,21 @@ function answered(reply, mock, posts) {
18
43
  const answer = reply.answers[askId];
19
44
  const ids = answer === undefined ? [] : Array.isArray(answer) ? answer : [answer];
20
45
  const chosen = ids.map((id) => ask?.options.find((o) => o.id === id) ?? { id, label: id });
46
+ const otherImages = refs(reply.otherImages?.[askId]);
21
47
  if (others[askId] !== undefined)
22
- chosen.push({ id: OTHER_ID, label: others[askId], other: true });
48
+ chosen.push({
49
+ id: OTHER_ID,
50
+ label: others[askId],
51
+ other: true,
52
+ ...(otherImages.length ? { images: otherImages } : {}),
53
+ });
54
+ const noteImages = refs(reply.noteImages?.[askId]);
23
55
  out.push({
24
56
  ask: askId,
25
57
  text: ask?.text ?? "",
26
58
  chosen,
27
59
  ...(notes[askId] !== undefined ? { note: notes[askId] } : {}),
60
+ ...(noteImages.length ? { noteImages } : {}),
28
61
  });
29
62
  }
30
63
  return out;
@@ -33,8 +66,8 @@ function answered(reply, mock, posts) {
33
66
  // one. A mock gets a fresh batch whenever a second reply arrives for it, so a
34
67
  // batch carries at most one reply and nothing is merged away. A comment whose
35
68
  // mock is unknown still gets a batch — feedback is never dropped for want of
36
- // context.
37
- export function groupFeedback(comments, mocks, posts) {
69
+ // context. `base` (origin + base path) makes image URLs absolute.
70
+ export function groupFeedback(comments, mocks, posts, base = "") {
38
71
  const batches = [];
39
72
  const open = new Map();
40
73
  for (const c of comments) {
@@ -42,7 +75,7 @@ export function groupFeedback(comments, mocks, posts) {
42
75
  const mock = c.mockId ? mocks.get(c.mockId) : undefined;
43
76
  const mockPosts = (c.mockId && posts.get(c.mockId)) || [];
44
77
  let batch = open.get(key);
45
- if (!batch || (c.kind === "reply" && batch.reply)) {
78
+ if (!batch || (c.kind === "reply" && (batch.reply || batch.resolved))) {
46
79
  batch = {
47
80
  mockId: c.mockId,
48
81
  project: mock?.project ?? null,
@@ -52,20 +85,39 @@ export function groupFeedback(comments, mocks, posts) {
52
85
  comments: [],
53
86
  accepted: [],
54
87
  archived: [],
88
+ dropped: [],
55
89
  };
56
90
  open.set(key, batch);
57
91
  batches.push(batch);
58
92
  }
93
+ if (c.kind === "reply" && c.payload?.resolved) {
94
+ batch.resolved = true;
95
+ batch.dismissed = c.payload.resolved.dismissed.map((id) => ({
96
+ ask: id,
97
+ text: (mock?.asks.find((a) => a.id === id) ?? (mock && builtinAsk(mock, mockPosts, id)))
98
+ ?.text ?? "",
99
+ }));
100
+ continue;
101
+ }
59
102
  if (c.kind === "reply" && c.payload) {
60
- const { others: _others, notes: _notes, ...payload } = c.payload;
103
+ const { others: _others, notes: _notes, otherImages: _otherImages, noteImages: _noteImages, ...payload } = c.payload;
104
+ const reactions = (payload.comments ?? []).filter(isReaction).map(reactionLine);
61
105
  batch.reply = {
62
106
  ...payload,
107
+ comments: (payload.comments ?? []).filter((x) => !x.reaction),
63
108
  seq: c.seq,
64
109
  at: c.createdAt,
65
- asks: answered(c.payload, mock, mockPosts),
110
+ asks: answered(c.payload, mock, mockPosts, base),
111
+ ...(reactions.length ? { reactions } : {}),
66
112
  };
67
- batch.accepted = mockPosts.filter((p) => p.status === "accepted").map(ref);
113
+ const decisions = mock ? decisionsFor(mock, mockPosts, c.payload) : null;
114
+ if (decisions)
115
+ batch.reply.decisions = decisions;
116
+ batch.accepted = mockPosts
117
+ .filter((p) => p.status === "accepted")
118
+ .map((p) => ({ ...ref(p), version: inUseVersion(p) }));
68
119
  batch.archived = mockPosts.filter((p) => p.status === "archived").map(ref);
120
+ batch.dropped = mockPosts.filter((p) => p.status === "dropped").map(ref);
69
121
  continue;
70
122
  }
71
123
  const post = c.postId ? mockPosts.find((p) => p.id === c.postId) : undefined;
@@ -82,7 +134,7 @@ export function groupFeedback(comments, mocks, posts) {
82
134
  }
83
135
  return batches;
84
136
  }
85
- export async function buildFeedbackBatches(store, comments) {
137
+ export async function buildFeedbackBatches(store, comments, base) {
86
138
  const mocks = new Map();
87
139
  const posts = new Map();
88
140
  for (const c of comments) {
@@ -94,5 +146,20 @@ export async function buildFeedbackBatches(store, comments) {
94
146
  mocks.set(c.mockId, mock);
95
147
  posts.set(c.mockId, await store.listPosts({ mockId: c.mockId }));
96
148
  }
97
- return groupFeedback(comments, mocks, posts);
149
+ return groupFeedback(comments, mocks, posts, base);
150
+ }
151
+ // Every image a list of batches carries, in order — what the MCP tiers attach
152
+ // as image content so the model sees what the user attached.
153
+ export function feedbackImages(batches) {
154
+ const out = [];
155
+ for (const b of batches) {
156
+ for (const a of b.reply?.asks ?? []) {
157
+ for (const c of a.chosen)
158
+ if ("other" in c && c.images)
159
+ out.push(...c.images);
160
+ if (a.noteImages)
161
+ out.push(...a.noteImages);
162
+ }
163
+ }
164
+ return out;
98
165
  }
@@ -0,0 +1,41 @@
1
+ import type { Knobs, Surface } from "./types.ts";
2
+ export interface Fill {
3
+ name: string;
4
+ label?: string;
5
+ }
6
+ export interface SlotInfo {
7
+ part: string;
8
+ label?: string;
9
+ fills: Fill[];
10
+ default: string;
11
+ parent: {
12
+ part: string;
13
+ fill: string;
14
+ } | null;
15
+ }
16
+ interface Range {
17
+ part: string;
18
+ fill: string;
19
+ start: number;
20
+ end: number;
21
+ tagEnd: number;
22
+ }
23
+ export interface FillScan {
24
+ slots: SlotInfo[];
25
+ ranges: Range[];
26
+ }
27
+ export declare function scanFills(html: string): FillScan;
28
+ export declare function slotKnobs(slots: SlotInfo[]): Knobs;
29
+ export declare function slotCss(slots: SlotInfo[]): string;
30
+ export declare function composeFills(html: string, chosen: Record<string, string>): string;
31
+ export declare function slotsInSurfaces(surfaces: Surface[]): SlotInfo[];
32
+ export type FillSplice = {
33
+ ok: true;
34
+ surfaces: Surface[];
35
+ applied: string[];
36
+ } | {
37
+ ok: false;
38
+ error: string;
39
+ };
40
+ export declare function spliceFills(surfaces: Surface[], edits: Record<string, string>): FillSplice;
41
+ export {};
@@ -0,0 +1,328 @@
1
+ // Slots: a part with alternative fills, declared in the agent's own markup.
2
+ //
3
+ // <section data-part="entry" data-fill="dots">…</section>
4
+ // <section data-part="entry" data-fill="skeleton">…</section>
5
+ //
6
+ // Elements sharing a `data-part` name with two or more distinct `data-fill`
7
+ // values make one slot. The first fill in document order is the default unless
8
+ // one carries `data-fill-default`. A fill may hold further slots: the scan
9
+ // records, for each slot, the fill it sits in, so the viewer can ask about it
10
+ // only while that fill is chosen.
11
+ //
12
+ // String-only and runtime-agnostic, like parts.ts: identity is read from the
13
+ // markup, never from a DOM. The scan is a tolerant tag walker, not a parser:
14
+ // it tracks open elements to know containment and each fill's extent, skips
15
+ // comments and raw-text elements, and treats an unclosed element as ending at
16
+ // the next close of an outer one.
17
+ const VOID = new Set([
18
+ "area",
19
+ "base",
20
+ "br",
21
+ "col",
22
+ "embed",
23
+ "hr",
24
+ "img",
25
+ "input",
26
+ "link",
27
+ "meta",
28
+ "param",
29
+ "source",
30
+ "track",
31
+ "wbr",
32
+ ]);
33
+ const RAW = new Set(["script", "style", "textarea", "title"]);
34
+ const TOKEN = /<!--[\s\S]*?-->|<\/([A-Za-z][\w:-]*)\s*>|<([A-Za-z][\w:-]*)((?:\s[^<>]*?)?)(\/?)>/g;
35
+ // Longest names first, and each must end at the name's end: `data-fill`
36
+ // must not match the start of `data-fill-label`.
37
+ const ATTR = /\s(data-part-label|data-part|data-fill-label|data-fill-default|data-fill)(?=[\s=/>]|$)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g;
38
+ // A slot's knob path is its part name, so the name must be a valid path segment.
39
+ const PATH_SEGMENT = /^[A-Za-z_][\w-]{0,63}$/;
40
+ const MAX_SLOTS = 100;
41
+ const decode = (s) => s
42
+ .replace(/&quot;/g, '"')
43
+ .replace(/&#39;|&apos;/g, "'")
44
+ .replace(/&lt;/g, "<")
45
+ .replace(/&gt;/g, ">")
46
+ .replace(/&amp;/g, "&")
47
+ .trim()
48
+ .slice(0, 200);
49
+ function attributes(raw) {
50
+ const out = {};
51
+ ATTR.lastIndex = 0;
52
+ let m;
53
+ while ((m = ATTR.exec(raw)))
54
+ out[m[1]] = decode(m[2] ?? m[3] ?? m[4] ?? "");
55
+ return out;
56
+ }
57
+ export function scanFills(html) {
58
+ const { slots, ranges } = scanAll(html);
59
+ const real = slots.filter((s) => s.fills.length >= 2);
60
+ const isSlot = new Set(real.map((s) => s.part));
61
+ return { slots: real, ranges: ranges.filter((r) => isSlot.has(r.part)) };
62
+ }
63
+ // Every part carrying `data-fill`, a lone fill included: a splice may add the
64
+ // second fill that makes it a slot.
65
+ function scanAll(html) {
66
+ const ranges = [];
67
+ // Per part, in document order: each fill element and the fill it sits in.
68
+ const seen = new Map();
69
+ const stack = [];
70
+ const close = (i, end) => {
71
+ for (let j = stack.length - 1; j >= i; j--) {
72
+ const f = stack[j].fill;
73
+ if (f)
74
+ f.end = end;
75
+ }
76
+ stack.length = i;
77
+ };
78
+ TOKEN.lastIndex = 0;
79
+ let m;
80
+ while ((m = TOKEN.exec(html))) {
81
+ if (m[0].startsWith("<!--"))
82
+ continue;
83
+ if (m[1]) {
84
+ const tag = m[1].toLowerCase();
85
+ for (let i = stack.length - 1; i >= 0; i--) {
86
+ if (stack[i].tag === tag) {
87
+ close(i, TOKEN.lastIndex);
88
+ break;
89
+ }
90
+ }
91
+ continue;
92
+ }
93
+ const tag = m[2].toLowerCase();
94
+ const attrs = attributes(m[3] ?? "");
95
+ let fill = null;
96
+ const part = attrs["data-part"];
97
+ const name = attrs["data-fill"];
98
+ if (part && name && PATH_SEGMENT.test(part)) {
99
+ fill = { part, fill: name, start: m.index, end: html.length, tagEnd: TOKEN.lastIndex };
100
+ ranges.push(fill);
101
+ let slot = seen.get(part);
102
+ if (!slot && seen.size < MAX_SLOTS) {
103
+ const outer = [...stack].reverse().find((o) => o.fill)?.fill ?? null;
104
+ slot = {
105
+ fills: [],
106
+ defaults: [],
107
+ parent: outer ? { part: outer.part, fill: outer.fill } : null,
108
+ };
109
+ seen.set(part, slot);
110
+ }
111
+ if (slot) {
112
+ if (attrs["data-part-label"] && !slot.label)
113
+ slot.label = attrs["data-part-label"];
114
+ if (!slot.fills.some((f) => f.name === name)) {
115
+ slot.fills.push({
116
+ name,
117
+ ...(attrs["data-fill-label"] ? { label: attrs["data-fill-label"] } : {}),
118
+ });
119
+ }
120
+ if ("data-fill-default" in attrs)
121
+ slot.defaults.push(name);
122
+ }
123
+ }
124
+ if (VOID.has(tag) || m[4] === "/") {
125
+ if (fill)
126
+ fill.end = TOKEN.lastIndex;
127
+ continue;
128
+ }
129
+ if (RAW.has(tag)) {
130
+ // Skip to the matching close: markup inside script or style is text.
131
+ const endTag = new RegExp(`</${tag}\\s*>`, "gi");
132
+ endTag.lastIndex = TOKEN.lastIndex;
133
+ const e = endTag.exec(html);
134
+ TOKEN.lastIndex = e ? endTag.lastIndex : html.length;
135
+ if (fill)
136
+ fill.end = TOKEN.lastIndex;
137
+ continue;
138
+ }
139
+ stack.push({ tag, fill });
140
+ }
141
+ const slots = [];
142
+ for (const [part, s] of seen) {
143
+ slots.push({
144
+ part,
145
+ ...(s.label ? { label: s.label } : {}),
146
+ fills: s.fills,
147
+ default: s.defaults[0] ?? s.fills[0].name,
148
+ parent: s.parent,
149
+ });
150
+ }
151
+ return { slots, ranges };
152
+ }
153
+ // Each slot is a select knob at its part's path: the fills are its options and
154
+ // the default fill is its value. A knob the agent declares at the same path
155
+ // wins (to relabel the options), so these are merged under declared knobs.
156
+ export function slotKnobs(slots) {
157
+ const out = {};
158
+ for (const s of slots) {
159
+ const config = {
160
+ type: "select",
161
+ options: s.fills.map((f) => (f.label ? { value: f.name, label: f.label } : f.name)),
162
+ value: s.default,
163
+ };
164
+ out[s.part] = config;
165
+ }
166
+ return out;
167
+ }
168
+ const cssString = (s) => `"${s.replace(/[\\"]/g, (c) => `\\${c}`).replace(/[\n\r<>]/g, (c) => `\\${c.charCodeAt(0).toString(16)} `)}"`;
169
+ // Shows only the chosen fill of each slot, keyed on the `data-k-<part>`
170
+ // attribute the knob runtime keeps on <html>. The chosen fill is never styled:
171
+ // only the others are hidden, so a fill keeps whatever display the agent gave it.
172
+ export function slotCss(slots) {
173
+ const rules = [];
174
+ for (const s of slots) {
175
+ const attr = `data-k-${s.part}`;
176
+ for (const f of s.fills) {
177
+ const el = `[data-part=${cssString(s.part)}][data-fill=${cssString(f.name)}]`;
178
+ const chosen = `[${attr}=${cssString(f.name)}]`;
179
+ rules.push(f.name === s.default
180
+ ? `html[${attr}]:not(${chosen}) ${el}{display:none!important}`
181
+ : `html:not(${chosen}) ${el}{display:none!important}`);
182
+ }
183
+ }
184
+ return rules.join("\n");
185
+ }
186
+ const FILL_ATTRS = /\s(data-fill-label|data-fill-default|data-fill)(?=[\s=/>]|$)(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>`]+))?/g;
187
+ // The screen as chosen: every slot reduced to its chosen fill, the others cut
188
+ // out with whatever they contained, and the fill attributes dropped from the
189
+ // fills that stay. `chosen` maps a slot's part to a fill; a slot it does not
190
+ // name keeps its default.
191
+ export function composeFills(html, chosen) {
192
+ const { slots, ranges } = scanFills(html);
193
+ const pick = new Map(slots.map((s) => [s.part, chosen[s.part] ?? s.default]));
194
+ const cut = ranges.filter((r) => pick.get(r.part) !== r.fill);
195
+ const kept = ranges.filter((r) => pick.get(r.part) === r.fill);
196
+ const edits = [];
197
+ for (const r of cut) {
198
+ if (cut.some((o) => o !== r && o.start <= r.start && r.end <= o.end))
199
+ continue;
200
+ edits.push({ start: r.start, end: r.end, text: "" });
201
+ }
202
+ for (const r of kept) {
203
+ if (cut.some((o) => o.start <= r.start && r.end <= o.end))
204
+ continue;
205
+ edits.push({
206
+ start: r.start,
207
+ end: r.tagEnd,
208
+ text: html.slice(r.start, r.tagEnd).replace(FILL_ATTRS, ""),
209
+ });
210
+ }
211
+ edits.sort((a, b) => b.start - a.start);
212
+ let out = html;
213
+ for (const e of edits)
214
+ out = out.slice(0, e.start) + e.text + out.slice(e.end);
215
+ return out;
216
+ }
217
+ // A post's slots across its html surfaces (the first surface that declares a
218
+ // part wins), cached per surfaces array: a stored post never mutates it.
219
+ const bySurfaces = new WeakMap();
220
+ export function slotsInSurfaces(surfaces) {
221
+ const hit = bySurfaces.get(surfaces);
222
+ if (hit)
223
+ return hit;
224
+ const out = [];
225
+ for (const sf of surfaces) {
226
+ if (sf.kind !== "html")
227
+ continue;
228
+ for (const slot of scanFills(sf.html).slots) {
229
+ if (!out.some((o) => o.part === slot.part))
230
+ out.push(slot);
231
+ }
232
+ }
233
+ bySurfaces.set(surfaces, out);
234
+ return out;
235
+ }
236
+ const attrValue = (s) => s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
237
+ const FIRST_TAG = /^(?:\s|<!--[\s\S]*?-->)*<([A-Za-z][\w:-]*)((?:\s[^<>]*?)?)(\/?)>/;
238
+ // The replacement must be one element marked as that fill: the marks are added
239
+ // when missing and a contradicting one is refused, so a splice cannot quietly
240
+ // land in another slot.
241
+ function markFill(html, part, fill) {
242
+ const m = FIRST_TAG.exec(html);
243
+ if (!m)
244
+ return { error: `fill "${part}#${fill}" must be html starting with its element` };
245
+ const attrs = attributes(m[2] ?? "");
246
+ if (attrs["data-part"] !== undefined && attrs["data-part"] !== part) {
247
+ return { error: `fill "${part}#${fill}" is marked data-part="${attrs["data-part"]}"` };
248
+ }
249
+ if (attrs["data-fill"] !== undefined && attrs["data-fill"] !== fill) {
250
+ return { error: `fill "${part}#${fill}" is marked data-fill="${attrs["data-fill"]}"` };
251
+ }
252
+ const add = (attrs["data-part"] === undefined ? ` data-part="${attrValue(part)}"` : "") +
253
+ (attrs["data-fill"] === undefined ? ` data-fill="${attrValue(fill)}"` : "");
254
+ const at = m.index + m[0].indexOf("<") + 1 + m[1].length;
255
+ return html.slice(0, at) + add + html.slice(at);
256
+ }
257
+ // Replace one fill of a slot, or add it after the slot's last fill, keyed
258
+ // "part#fill". Like spliceParts, every target is located in the original
259
+ // markup and overlapping edits are refused.
260
+ export function spliceFills(surfaces, edits) {
261
+ const docs = surfaces.flatMap((s, i) => (s.kind === "html" ? [{ i, surface: s }] : []));
262
+ if (docs.length === 0) {
263
+ return { ok: false, error: "fills needs an html surface; this variant has none" };
264
+ }
265
+ const scans = docs.map((d) => scanAll(d.surface.html).ranges);
266
+ const plan = new Map();
267
+ const targets = Object.keys(edits);
268
+ for (const [n, target] of targets.entries()) {
269
+ const at = target.indexOf("#");
270
+ const part = at > 0 ? target.slice(0, at) : "";
271
+ const fill = at > 0 ? target.slice(at + 1).trim() : "";
272
+ if (!PATH_SEGMENT.test(part) || !fill) {
273
+ return {
274
+ ok: false,
275
+ error: `fills keys are "part#fill", e.g. "entry#skeleton" (got "${target}")`,
276
+ };
277
+ }
278
+ const where = scans.flatMap((rs, d) => (rs.some((r) => r.part === part) ? [d] : []));
279
+ if (where.length === 0) {
280
+ return {
281
+ ok: false,
282
+ error: `no fills of "${part}"; mark its alternatives data-part="${part}" data-fill="<name>" with html or parts first`,
283
+ };
284
+ }
285
+ if (where.length > 1) {
286
+ return {
287
+ ok: false,
288
+ error: `fills of "${part}" are in several html surfaces; revise with surfaces`,
289
+ };
290
+ }
291
+ const d = where[0];
292
+ const own = scans[d].filter((r) => r.part === part);
293
+ const same = own.filter((r) => r.fill === fill);
294
+ if (same.length > 1) {
295
+ return {
296
+ ok: false,
297
+ error: `fill "${target}" appears ${same.length} times; revise with html or parts`,
298
+ };
299
+ }
300
+ const text = markFill(edits[target], part, fill);
301
+ if (typeof text !== "string")
302
+ return { ok: false, error: text.error };
303
+ const edit = same.length
304
+ ? { start: same[0].start, end: same[0].end, text, n }
305
+ : (() => {
306
+ const end = Math.max(...own.map((r) => r.end));
307
+ return { start: end, end, text: `\n${text}`, n };
308
+ })();
309
+ const list = plan.get(d) ?? [];
310
+ const clash = list.find((e) => e.start < edit.end && edit.start < e.end);
311
+ if (clash) {
312
+ return { ok: false, error: `fills "${targets[clash.n]}" and "${target}" overlap; send one` };
313
+ }
314
+ list.push(edit);
315
+ plan.set(d, list);
316
+ }
317
+ const next = [...surfaces];
318
+ for (const [d, list] of plan) {
319
+ const { i, surface } = docs[d];
320
+ let html = surface.html;
321
+ // From the end, and same-place additions last-listed first, so they land in listed order.
322
+ for (const e of list.toSorted((a, b) => b.start - a.start || b.n - a.n)) {
323
+ html = html.slice(0, e.start) + e.text + html.slice(e.end);
324
+ }
325
+ next[i] = { ...surface, html };
326
+ }
327
+ return { ok: true, surfaces: next, applied: targets };
328
+ }
@@ -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
+ }
@@ -1,5 +1,6 @@
1
1
  import { serve } from "@hono/node-server";
2
- import { readFile } from "node:fs/promises";
2
+ import { createHash } from "node:crypto";
3
+ import { readdir, readFile } from "node:fs/promises";
3
4
  import { homedir } from "node:os";
4
5
  import { basename, dirname, join } from "node:path";
5
6
  import { fileURLToPath } from "node:url";
@@ -25,6 +26,18 @@ const [viewerHtml, setupText, topicTexts, pkgJson] = await Promise.all([
25
26
  readFile(join(root, "package.json"), "utf8"),
26
27
  ]);
27
28
  const topics = Object.fromEntries(GUIDE_TOPICS.map((id, i) => [id, topicTexts[i]]));
29
+ // What renders surface documents: this server's own files and the locked
30
+ // library versions. Hashed whole rather than picked apart, so an edit in dev or
31
+ // an upgrade both reach browsers holding an old document; a change elsewhere
32
+ // in the server only costs one re-fetch per document.
33
+ const serverDir = dirname(fileURLToPath(import.meta.url));
34
+ const renderHash = createHash("sha256");
35
+ for (const name of (await readdir(serverDir)).sort()) {
36
+ if (/\.(ts|js)$/.test(name))
37
+ renderHash.update(await readFile(join(serverDir, name)));
38
+ }
39
+ renderHash.update(await readFile(join(root, "package-lock.json")).catch(() => pkgJson));
40
+ const renderRev = renderHash.digest("hex").slice(0, 10);
28
41
  const pr = process.env.MOCKPIT_PUBLIC_READ;
29
42
  const publicRead = pr === "session" || pr === "full" ? pr : undefined;
30
43
  // Storage: SQLite via node:sqlite, the same SqlStore the Cloudflare Durable
@@ -55,6 +68,7 @@ const app = createApp({
55
68
  // notice); set it to the empty string to disable the update check
56
69
  version: process.env.MOCKPIT_VERSION ?? JSON.parse(pkgJson).version,
57
70
  upgradeCommand: "npm install -g mockpit",
71
+ renderRev,
58
72
  // `run` scripts execute in QuickJS inside a worker thread per run.
59
73
  executor: createNodeExecutor(),
60
74
  log: logPath ? createFileLog(logPath) : undefined,