@marshalliqiu/loupe 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.mjs ADDED
@@ -0,0 +1,3706 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.js
4
+ import { spawn, spawnSync } from "node:child_process";
5
+ import { closeSync, existsSync, openSync, readFileSync } from "node:fs";
6
+ import { access, mkdir as mkdir2, writeFile as writeFile2 } from "node:fs/promises";
7
+ import os2 from "node:os";
8
+ import path5 from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+ import { AxiError, installSessionStartHooks, runAxiCli } from "axi-sdk-js";
11
+
12
+ // src/playbooks.js
13
+ var PLAYBOOK_ROUTER_INSTRUCTION = "MUST open each matching playbook before writing HTML. Match against the use_when trigger; one artifact often combines several playbooks.";
14
+ var PLAYBOOK_ROUTER_HELP = "One artifact often combines several playbooks (for example a plan that includes a comparison and a diagram), so MUST open each matching playbook before writing HTML.";
15
+ var PLAYBOOKS = [
16
+ {
17
+ id: "diagram",
18
+ use_when: "Map relationships, flows, state, and architecture",
19
+ choose: [
20
+ "Use Mermaid when automatic node placement and edge routing matter more than rich card content.",
21
+ "Use CSS grid, SVG, or positioned HTML when each item needs prose, code, controls, or detailed annotations.",
22
+ "Use a hybrid shape for large systems: a small overview diagram followed by detailed module cards."
23
+ ],
24
+ structure: [
25
+ "Lead with the question the diagram answers, not with the implementation detail that produced it.",
26
+ "Keep the first visual to the core relationship, then put dense evidence or file references below it.",
27
+ "For complex systems, separate topology from detail so the overview stays readable."
28
+ ],
29
+ design_rules: [
30
+ "Use page-scoped class names and avoid generic names like .node that can collide with diagram libraries.",
31
+ "Prefer top-down flow for multi-step diagrams unless the flow is genuinely linear and short.",
32
+ "Quote labels that contain punctuation or code-like names, and use explicit line breaks where the renderer supports them."
33
+ ],
34
+ pitfalls: [
35
+ "Do not cram every file or function into one diagram when a layered explanation would be clearer.",
36
+ "Do not hand-build boxes-and-arrows from div/flexbox for a flow: it does not auto-route edges and reads worse than Mermaid; reach for Mermaid or SVG for richly annotated nodes.",
37
+ "Do not let default diagram colors clash with the page palette or dark mode.",
38
+ "Do not present unverified architecture claims as facts. Cite the files or commands that support them."
39
+ ],
40
+ lavish_notes: [
41
+ "A Loupe diagram should invite precise annotation: make modules, edges, and captions easy to click and discuss.",
42
+ "When a relationship is uncertain, label it as a question so the user can resolve it in the review loop."
43
+ ]
44
+ },
45
+ {
46
+ id: "table",
47
+ use_when: "Turn dense records into scan-friendly review surfaces",
48
+ choose: [
49
+ "Use a table when rows share the same fields and the user needs to compare evidence quickly.",
50
+ "Use cards when each record has a different shape or needs a long explanation.",
51
+ "Use summaries above the table when counts, risk levels, or statuses change how the table should be read."
52
+ ],
53
+ structure: [
54
+ "Start with a short summary of what the rows prove or require.",
55
+ "Group columns by the decision they support: identity, evidence, status, action.",
56
+ "Keep raw details available, but make the primary status visible without reading every cell."
57
+ ],
58
+ design_rules: [
59
+ "Use semantic table markup when the data is tabular.",
60
+ "Protect long paths, code symbols, URLs, and prose from overflowing on narrow screens.",
61
+ "Use restrained color for status and severity so the table remains readable when printed or skimmed."
62
+ ],
63
+ pitfalls: [
64
+ "Do not paste a terminal table into HTML and call it done.",
65
+ "Do not hide the important conclusion below a large undifferentiated grid.",
66
+ "Do not use color as the only status signal."
67
+ ],
68
+ lavish_notes: [
69
+ "A Loupe table should make individual rows easy annotation targets.",
70
+ "If a row implies a follow-up change, include an action control that queues a specific prompt."
71
+ ]
72
+ },
73
+ {
74
+ id: "comparison",
75
+ use_when: "Show options, tradeoffs, and current vs target behavior",
76
+ choose: [
77
+ "Use before and after when the same system is changing over time.",
78
+ "Use option cards when the user needs to choose between mutually exclusive directions.",
79
+ "Use a scorecard only when the criteria are explicit and comparable."
80
+ ],
81
+ structure: [
82
+ "Name the decision at the top of the artifact.",
83
+ "Show the concrete behavior or artifact shape for each side, not just abstract pros and cons.",
84
+ "End with a recommendation only when the evidence actually supports one."
85
+ ],
86
+ design_rules: [
87
+ "Keep corresponding details aligned so differences are visible without hunting.",
88
+ "Use visual hierarchy to separate primary tradeoffs from secondary notes.",
89
+ "Make the cost of each option as visible as the benefit."
90
+ ],
91
+ pitfalls: [
92
+ "Do not make every option look equally recommended if one is clearly preferred.",
93
+ "Do not compare vague summaries when concrete examples are available.",
94
+ "Do not bury assumptions that would change the recommendation."
95
+ ],
96
+ lavish_notes: [
97
+ "A Loupe comparison should let the user annotate the exact option or tradeoff they want changed.",
98
+ "If the goal is selection, provide controls that queue the chosen option with rationale."
99
+ ]
100
+ },
101
+ {
102
+ id: "plan",
103
+ use_when: "Explain a product or technical plan before implementation",
104
+ choose: [
105
+ "Use this when the user needs to inspect a feature approach before implementation begins.",
106
+ "Use it when the user explicitly asked for a PRD, technical design, implementation plan or proposal.",
107
+ "Use a lighter comparison or diagram playbook when the plan is only a single small design choice."
108
+ ],
109
+ structure: [
110
+ "Start with the goal, the current state, and desired behavior.",
111
+ "Then describe a proposed approach, focusing on high level decisions.",
112
+ "At the end, list any risks you see, and open questions you have, and follow the 'comparison' playbook to provide options for the user to choose from."
113
+ ],
114
+ design_rules: [
115
+ "Verify each claim against the codebase before presenting it as fact.",
116
+ "When discussing frontend experiences, prefer visually mocking the experience under a consistent design system as the real product over describing it with text.",
117
+ "The plan needs to be self-contained enough that another developer can read it and fully implement the proposal."
118
+ ],
119
+ pitfalls: [
120
+ "Do not leave resolved open questions in the artifact. Update existing content to reflect the decision and remove the open question.",
121
+ "Do not only focus on ambiguous decisions and omit the actual proposal.",
122
+ "Do not omit failure modes, migration concerns, or backwards compatibility questions."
123
+ ],
124
+ lavish_notes: ["A Loupe plan should make a plan and its uncertainties easy to annotate before code exists."]
125
+ },
126
+ {
127
+ id: "code",
128
+ use_when: "Render source code, code files, patches, PR diffs, and before/after code inside Loupe artifacts",
129
+ choose: [
130
+ "Use this whenever an artifact shows source code: a snippet, full file, patch, PR diff, local change set, or before/after code.",
131
+ "Use File for one code file, FileDiff for old/new versions or parsed patch metadata, and CodeView only when several files or diffs need coordinated navigation.",
132
+ "Choose split layout for careful side-by-side review when width allows; choose unified layout when space is tight, changes are mostly additive, or mobile readability matters."
133
+ ],
134
+ structure: [
135
+ "Place the path, language, and reason to inspect the code immediately before each rendered file or diff.",
136
+ "Keep evidence close to each claim with file paths, line references, or annotations next to the relevant code.",
137
+ "For multi-file changes, group files by user-facing area or task instead of dumping a raw patch in repository order."
138
+ ],
139
+ design_rules: [
140
+ `Rendering MUST use @pierre/diffs, not hand-rolled <pre> blocks or another diff library. This verified no-build standalone HTML snippet renders one file and one split diff from esm.sh:
141
+ \`\`\`html
142
+ <div id="file"></div>
143
+ <div id="diff"></div>
144
+ <script type="module">
145
+ import { File, FileDiff } from "https://esm.sh/@pierre/diffs@1.2.10?bundle";
146
+
147
+ const theme = { light: "github-light", dark: "github-dark" };
148
+ const options = { theme, themeType: "dark", overflow: "wrap" };
149
+ const oldFile = {
150
+ name: "src/greeting.ts",
151
+ contents: "export function greet(name: string) {\\n return \\"Hello \\" + name;\\n}\\n\\nconsole.log(greet(\\"Loupe\\"));\\n",
152
+ };
153
+ const newFile = {
154
+ name: "src/greeting.ts",
155
+ contents: "export function greet(name: string) {\\n return \\"Hello, \\" + name + \\"!\\";\\n}\\n\\nconsole.log(greet(\\"Loupe\\"));\\n",
156
+ };
157
+
158
+ new File(options).render({
159
+ containerWrapper: document.querySelector("#file"),
160
+ file: newFile,
161
+ });
162
+
163
+ new FileDiff({ ...options, diffStyle: "split" }).render({
164
+ containerWrapper: document.querySelector("#diff"),
165
+ oldFile,
166
+ newFile,
167
+ });
168
+
169
+ </script>
170
+ \`\`\``,
171
+ "Pick a Shiki theme pair that matches the artifact's DaisyUI or Tailwind direction and light or dark mode; replace the GitHub pair above when the page is not GitHub-like.",
172
+ 'Use FileDiff diffStyle: "split" for side-by-side review and diffStyle: "unified" for stacked reading; keep overflow: "wrap" unless horizontal alignment is essential.',
173
+ "Use @pierre/diffs line annotations, selections, and headers when calling out specific lines so notes stay attached to code."
174
+ ],
175
+ pitfalls: [
176
+ "Do not render code as static screenshots, plain <pre> blocks, or markdown pasted into HTML.",
177
+ "Do not choose an arbitrary default Shiki theme that clashes with the page palette or dark mode.",
178
+ "Do not show huge unrelated files when a focused render range, parsed patch file, or grouped summary would be clearer.",
179
+ "Do not separate a claim from the code lines that prove it."
180
+ ],
181
+ lavish_notes: [
182
+ "A Loupe code artifact should make each file, hunk, and relevant line easy to annotate precisely.",
183
+ "When a user action should trigger a fix, queue prompts that name the file path, line range, and desired change.",
184
+ "If the artifact combines code with a plan, table, or comparison, read those playbooks too and keep @pierre/diffs responsible for the code surface."
185
+ ]
186
+ },
187
+ {
188
+ id: "input",
189
+ use_when: "Must be used when the agent needs to collect user input on decisions, choices, preferences, triage, scope, or other structured feedback from within the artifact",
190
+ choose: [
191
+ "Use this when the user needs to select, tune, triage, annotate, or edit a structured choice.",
192
+ "Use controls for decisions the user can make faster visually than by writing a prompt.",
193
+ "Use plain annotations when the artifact only needs open-ended feedback."
194
+ ],
195
+ structure: [
196
+ "Make each decision surface visible: what is being chosen, what the options mean, and what happens next.",
197
+ "Keep reversible selection state local in the artifact until the user explicitly submits that question.",
198
+ "Pair each question with a Submit or Queue answer control that sends exactly one prompt for the final answer.",
199
+ "Show selected state separately from queued state so the user trusts what will be sent back."
200
+ ],
201
+ design_rules: [
202
+ "Native controls - radios, checkboxes, text inputs, selects, textareas, buttons, options, labels, disclosure summaries, and contenteditable regions - are interactive automatically: clicks toggle, focus, and type instead of annotating, so they do not need data-lavish-action. Build choice and option UIs from these whenever you can.",
203
+ "For reversible choices, do not call window.lavish.queuePrompt() from radio change handlers or option click handlers. Those handlers should only update local selected state.",
204
+ "Use a per-question form submit or explicit Queue answer button to read the current values and call window.lavish.queuePrompt() exactly once for the final answer.",
205
+ "Put data-lavish-action only on custom (non-native) elements that should act like a feedback control - typically a styled div or span you made clickable - so Loupe does not annotate it and shows a pointer cursor instead.",
206
+ "Use data-lavish-question on a question wrapper or pass queueKey when multiple pre-send updates should replace the prior unsent answer for the same question.",
207
+ "Pass options such as tag, text, selector, target, data, queueKey, or element when they help the agent understand exactly what the user chose.",
208
+ "Call window.lavish.sendQueuedPrompts() only when the control should immediately send committed feedback instead of waiting for the user to press Send to Agent.",
209
+ "Make queued prompts specific enough that the agent can act without asking a follow-up question.",
210
+ "Keep native browser controls accessible and readable on mobile."
211
+ ],
212
+ pitfalls: [
213
+ "Do not queue one prompt per radio change, checkbox toggle, dropdown change, or choice-button click when the user can still change their mind.",
214
+ "Do not create controls whose queued prompt is unclear or too vague to execute.",
215
+ "Do not hide the difference between selected locally and queued for the agent.",
216
+ "Do not require interaction for content the user only needs to read."
217
+ ],
218
+ lavish_notes: [
219
+ "Loupe is strongest when the artifact becomes a focused review surface and not just a static page.",
220
+ `A native single-choice question should submit the final value: \`<form data-lavish-question="plan" onsubmit="event.preventDefault(); const choice = new FormData(event.currentTarget).get('plan'); if (choice) window.lavish.queuePrompt('Use the ' + choice + ' plan', { tag: 'choice', text: 'Plan: ' + choice, element: event.currentTarget, data: { question: 'plan', answer: choice } });"><label><input type="radio" name="plan" value="Starter"> Starter</label><label><input type="radio" name="plan" value="Pro"> Pro</label><button type="submit">Queue this answer</button></form>\`.`,
221
+ "A custom choice UI should make option buttons update local state, then use a separate Queue answer button with data-lavish-action to queue the final selected value.",
222
+ "Use window.lavish.queuePrompt for user intent, not internal analytics or UI-only state changes.",
223
+ "End input paths with an obvious way for the user to send feedback back to the agent."
224
+ ]
225
+ },
226
+ {
227
+ id: "slides",
228
+ use_when: "Create a deliberate presentation when slides are requested",
229
+ choose: [
230
+ "Use slides only when the user asks for a deck, presentation, talk, or paced walkthrough.",
231
+ "Use a scroll page when the user needs reference material, detailed review, or dense evidence.",
232
+ "Use one idea per slide when the artifact has a narrative arc."
233
+ ],
234
+ structure: [
235
+ "Plan the story before writing the slide markup.",
236
+ "Open with the point, build context, show evidence, and close with the decision or next action.",
237
+ "Vary slide composition so the deck does not feel like repeated cards."
238
+ ],
239
+ design_rules: [
240
+ "Keep slide text sparse and let visuals carry the explanation.",
241
+ "Use large type, strong alignment, and deliberate whitespace rather than dense paragraphs.",
242
+ "Make navigation and screen-size assumptions explicit in the artifact."
243
+ ],
244
+ pitfalls: [
245
+ "Do not turn every explainer into slides by default.",
246
+ "Do not paste a scroll-page outline into fixed-size frames without rewriting the narrative.",
247
+ "Do not make consecutive slides with the same spatial composition unless repetition is the point."
248
+ ],
249
+ lavish_notes: [
250
+ "A Loupe slide deck can still collect feedback, but each prompt should refer to a slide or decision.",
251
+ "Use slides for persuasion or presentation, not for dense code review."
252
+ ]
253
+ }
254
+ ];
255
+ function listPlaybooks() {
256
+ return PLAYBOOKS.map(({ id, use_when }) => ({ id, use_when }));
257
+ }
258
+ function findPlaybook(id) {
259
+ return PLAYBOOKS.find((playbook) => playbook.id === id) || null;
260
+ }
261
+ function playbookIds() {
262
+ return PLAYBOOKS.map((playbook) => playbook.id);
263
+ }
264
+
265
+ // src/design-reference.js
266
+ var TAILWIND_BROWSER_VERSION = "4.2.4";
267
+ var DAISYUI_VERSION = "5.5.19";
268
+ var MERMAID_VERSION = "11.15.0";
269
+ var DESIGN_CDN_URLS = {
270
+ tailwind: `https://cdn.jsdelivr.net/npm/@tailwindcss/browser@${TAILWIND_BROWSER_VERSION}/dist/index.global.js`,
271
+ daisyui: `https://cdn.jsdelivr.net/npm/daisyui@${DAISYUI_VERSION}/daisyui.css`,
272
+ daisyuiThemes: `https://cdn.jsdelivr.net/npm/daisyui@${DAISYUI_VERSION}/themes.css`
273
+ };
274
+ var MERMAID_CDN_URL = `https://cdn.jsdelivr.net/npm/mermaid@${MERMAID_VERSION}/dist/mermaid.esm.min.mjs`;
275
+ var DESIGN_CDN_SNIPPET = `<link rel="stylesheet" href="${DESIGN_CDN_URLS.daisyui}">
276
+ <link rel="stylesheet" href="${DESIGN_CDN_URLS.daisyuiThemes}">
277
+ <script src="${DESIGN_CDN_URLS.tailwind}"></script>`;
278
+ var MERMAID_CDN_SNIPPET = `<script type="module">
279
+ import mermaid from "${MERMAID_CDN_URL}";
280
+
281
+ mermaid.initialize({
282
+ startOnLoad: true,
283
+ theme: "base",
284
+ securityLevel: "strict",
285
+ });
286
+ </script>`;
287
+ var LAYOUT_SAFETY_CSS_SNIPPET = `<style>
288
+ *, *::before, *::after { box-sizing: border-box; }
289
+ :where(.grid, .flex, .layout-grid, .layout-flex) > *,
290
+ :where([style*="display: grid"], [style*="display:grid"], [style*="display: flex"], [style*="display:flex"]) > * {
291
+ min-width: 0;
292
+ }
293
+ :where(p, h1, h2, h3, h4, h5, h6, li, dd, blockquote, figcaption, td, th, .badge, .label) {
294
+ overflow-wrap: anywhere;
295
+ }
296
+ :where(img, svg, video, canvas, iframe) {
297
+ max-width: 100%;
298
+ height: auto;
299
+ }
300
+ </style>`;
301
+ var DESIGN_SYSTEM_HINT = "Loupe does not auto-inject any design system - artifacts stay portable so they render identically when opened directly without loupe running. Before writing any HTML, decide the design direction in this strict priority order, and only move to the next step when the current one truly yields nothing: (1) if the user asked for a specific look or named design system, use that; (2) otherwise you must first inspect the project the artifact is about - the subject or product whose content or UI it represents, which may differ from your current working directory - and match that project's design system: Tailwind or theme config, shared CSS variables or design tokens, component library, brand assets, or existing styled pages. If the artifact previews, proposes, or mocks a specific app's UI, render it in that app's own design system so it faithfully shows the product, even when you are running in a different repo; (3) only when both steps come up empty, use the Loupe-recommended Tailwind CSS browser runtime v4 + DaisyUI v5, available via CDN - run `loupe design` for a content-to-playbook router, a copy-pasteable CDN snippet, a Mermaid CDN snippet/init for diagrams, and the DaisyUI component reference, and prefer the Tailwind/DaisyUI CDN snippet over hand-writing styles unless explicitly instructed otherwise by the user. When you deliver the artifact, state which of the three design sources you used and why.";
302
+ var DAISYUI_THEMES = [
303
+ "light",
304
+ "dark",
305
+ "cupcake",
306
+ "bumblebee",
307
+ "emerald",
308
+ "corporate",
309
+ "synthwave",
310
+ "retro",
311
+ "cyberpunk",
312
+ "valentine",
313
+ "halloween",
314
+ "garden",
315
+ "forest",
316
+ "aqua",
317
+ "lofi",
318
+ "pastel",
319
+ "fantasy",
320
+ "wireframe",
321
+ "black",
322
+ "luxury",
323
+ "dracula",
324
+ "cmyk",
325
+ "autumn",
326
+ "business",
327
+ "acid",
328
+ "lemonade",
329
+ "night",
330
+ "coffee",
331
+ "winter",
332
+ "dim",
333
+ "nord",
334
+ "sunset",
335
+ "caramellatte",
336
+ "abyss",
337
+ "silk"
338
+ ];
339
+ function createDesignOutput() {
340
+ return {
341
+ playbook_router: {
342
+ instruction: PLAYBOOK_ROUTER_INSTRUCTION,
343
+ playbooks: listPlaybooks()
344
+ },
345
+ design: {
346
+ summary: "Use this Loupe CDN fallback only if (1) the user gave no design direction and (2) you already inspected the project the artifact is about - the subject or product whose content or UI it represents, which may differ from your current working directory - and found no design system or style conventions to match. If you have not checked the subject project yet, check first. Loupe does not auto-inject any design system; artifacts stay portable HTML. The strict priority order is: (1) a look or named design system the user asked for; (2) the subject project's design system or style conventions - look for a Tailwind or theme config, shared CSS variables or design tokens, a component library, brand assets, or existing styled pages. If the artifact previews, proposes, or mocks a specific app's UI, render it in that app's own design system so it faithfully shows the product, even when you are running in a different repo; (3) this Tailwind CSS browser runtime v4 + DaisyUI v5 + themes snippet - paste the CDN snippet below into your `<head>` and prefer the CDN snippet over hand-writing styles unless explicitly instructed otherwise by the user.",
347
+ cdn_snippet: DESIGN_CDN_SNIPPET,
348
+ cdn_urls: DESIGN_CDN_URLS,
349
+ versions: { tailwind: TAILWIND_BROWSER_VERSION, daisyui: DAISYUI_VERSION },
350
+ latest_docs: "https://daisyui.com/components/",
351
+ docs_note: "Use this command for common syntax. Read the latest DaisyUI docs for full details when using advanced or unfamiliar components.",
352
+ layout_safety_snippet: LAYOUT_SAFETY_CSS_SNIPPET,
353
+ layout_safety_note: "Optional copy-paste CSS for artifacts with dense nested grid/flex layouts, badges, wide monospace or pixel fonts, or local media. Paste it into the artifact yourself when useful. Loupe never auto-injects it, so direct-open portability stays intact.",
354
+ other_design_systems: "If the user asks for a different design system (Bootstrap, custom CSS, plain HTML, etc.), use that instead - Loupe does not require DaisyUI."
355
+ },
356
+ diagram_tooling: {
357
+ use_when: "Use this for flows / architecture / state / sequence diagrams after opening the diagram playbook; Mermaid handles layout and edge routing better than hand-built div/flexbox boxes.",
358
+ mermaid_cdn_snippet: MERMAID_CDN_SNIPPET,
359
+ cdn_urls: { mermaid: MERMAID_CDN_URL },
360
+ versions: { mermaid: MERMAID_VERSION }
361
+ },
362
+ theme_usage: [
363
+ 'Default to `<html data-theme="luxury">` - it matches the Loupe look. Pick a different theme from the list below only when the user asked for one or the content clearly calls for it.',
364
+ 'Set a nested section theme with `<section data-theme="night">`.',
365
+ "Prefer semantic colors such as `bg-base-100`, `bg-base-200`, `text-base-content`, `bg-primary`, `text-primary-content`, `alert-warning`, and `btn-primary` so themes remain readable.",
366
+ "Avoid hardcoded Tailwind color names for text and surfaces unless the user asked for exact colors.",
367
+ "Use Tailwind responsive prefixes such as `sm:`, `md:`, `lg:`, and `xl:` for layout changes.",
368
+ 'Never `@apply` DaisyUI classes (such as `text-base-content/40`, `bg-base-200`, or `btn`) inside `<style type="text/tailwindcss">` - the Tailwind browser runtime does not know them, and one unknown utility aborts the entire compile, leaving the page with no Tailwind styles at all. Put DaisyUI classes directly on elements, or write plain CSS with theme variables such as `var(--color-base-200)`.'
369
+ ],
370
+ themes: DAISYUI_THEMES,
371
+ components: {
372
+ actions: ["button", "dropdown", "fab", "modal", "swap", "theme-controller"],
373
+ data_display: [
374
+ "accordion",
375
+ "avatar",
376
+ "badge",
377
+ "card",
378
+ "carousel",
379
+ "chat",
380
+ "collapse",
381
+ "countdown",
382
+ "diff",
383
+ "hover-3d",
384
+ "hover-gallery",
385
+ "kbd",
386
+ "list",
387
+ "stat",
388
+ "status",
389
+ "table",
390
+ "text-rotate",
391
+ "timeline"
392
+ ],
393
+ navigation: ["breadcrumbs", "dock", "link", "menu", "navbar", "pagination", "steps", "tabs"],
394
+ feedback: ["alert", "loading", "progress", "radial-progress", "skeleton", "toast", "tooltip"],
395
+ data_input: [
396
+ "calendar",
397
+ "checkbox",
398
+ "fieldset",
399
+ "file-input",
400
+ "filter",
401
+ "label",
402
+ "radio",
403
+ "range",
404
+ "rating",
405
+ "select",
406
+ "input",
407
+ "textarea",
408
+ "toggle",
409
+ "validator"
410
+ ],
411
+ layout: ["divider", "drawer", "footer", "hero", "indicator", "join", "mask", "stack"],
412
+ mockup: ["mockup-browser", "mockup-code", "mockup-phone", "mockup-window"]
413
+ },
414
+ modifiers: {
415
+ colors: ["neutral", "primary", "secondary", "accent", "info", "success", "warning", "error"],
416
+ sizes: ["xs", "sm", "md", "lg", "xl"],
417
+ styles: ["outline", "dash", "soft", "ghost", "link"],
418
+ placements: ["start", "center", "end", "top", "middle", "bottom", "left", "right"]
419
+ },
420
+ reference: {
421
+ button: {
422
+ classes: [
423
+ "btn",
424
+ "btn-neutral",
425
+ "btn-primary",
426
+ "btn-secondary",
427
+ "btn-accent",
428
+ "btn-info",
429
+ "btn-success",
430
+ "btn-warning",
431
+ "btn-error",
432
+ "btn-outline",
433
+ "btn-dash",
434
+ "btn-soft",
435
+ "btn-ghost",
436
+ "btn-link",
437
+ "btn-xs",
438
+ "btn-sm",
439
+ "btn-md",
440
+ "btn-lg",
441
+ "btn-xl",
442
+ "btn-wide",
443
+ "btn-block",
444
+ "btn-square",
445
+ "btn-circle",
446
+ "btn-active",
447
+ "btn-disabled"
448
+ ],
449
+ syntax: '<button class="btn btn-primary">Save</button>',
450
+ notes: [
451
+ 'Use `btn` on `<button>`, `<a role="button">`, `<input>`, or `<label>`.',
452
+ 'For class-only disabled state, add `btn-disabled tabindex="-1" role="button" aria-disabled="true"`.',
453
+ "Use `btn-square` or `btn-circle` for icon-only buttons and provide an accessible label."
454
+ ]
455
+ },
456
+ card: {
457
+ classes: [
458
+ "card",
459
+ "card-body",
460
+ "card-title",
461
+ "card-actions",
462
+ "card-border",
463
+ "card-dash",
464
+ "card-side",
465
+ "image-full",
466
+ "card-xs",
467
+ "card-sm",
468
+ "card-md",
469
+ "card-lg",
470
+ "card-xl"
471
+ ],
472
+ syntax: '<div class="card card-border bg-base-100"><div class="card-body"><h2 class="card-title">Title</h2><p>Text</p><div class="card-actions justify-end"><button class="btn btn-primary">Act</button></div></div></div>',
473
+ notes: [
474
+ "Use `lg:card-side` for responsive horizontal cards.",
475
+ "Use `card-border` for a bordered card without custom CSS."
476
+ ]
477
+ },
478
+ alert: {
479
+ classes: [
480
+ "alert",
481
+ "alert-outline",
482
+ "alert-dash",
483
+ "alert-soft",
484
+ "alert-info",
485
+ "alert-success",
486
+ "alert-warning",
487
+ "alert-error",
488
+ "alert-vertical",
489
+ "alert-horizontal"
490
+ ],
491
+ syntax: '<div role="alert" class="alert alert-warning"><span>Check this before shipping.</span></div>',
492
+ notes: [
493
+ 'Use `role="alert"` for important status messages.',
494
+ "Use `sm:alert-horizontal` to switch from stacked to horizontal layouts."
495
+ ]
496
+ },
497
+ badge: {
498
+ classes: [
499
+ "badge",
500
+ "badge-outline",
501
+ "badge-dash",
502
+ "badge-soft",
503
+ "badge-ghost",
504
+ "badge-neutral",
505
+ "badge-primary",
506
+ "badge-secondary",
507
+ "badge-accent",
508
+ "badge-info",
509
+ "badge-success",
510
+ "badge-warning",
511
+ "badge-error",
512
+ "badge-xs",
513
+ "badge-sm",
514
+ "badge-md",
515
+ "badge-lg",
516
+ "badge-xl"
517
+ ],
518
+ syntax: '<span class="badge badge-soft badge-warning">Risk</span>',
519
+ notes: ["Use badges for short statuses and labels, not long prose."]
520
+ },
521
+ table: {
522
+ classes: [
523
+ "table",
524
+ "table-zebra",
525
+ "table-pin-rows",
526
+ "table-pin-cols",
527
+ "table-xs",
528
+ "table-sm",
529
+ "table-md",
530
+ "table-lg",
531
+ "table-xl"
532
+ ],
533
+ syntax: '<div class="overflow-x-auto rounded-box border border-base-content/5 bg-base-100"><table class="table table-zebra"><thead><tr><th>Name</th></tr></thead><tbody><tr><td>Value</td></tr></tbody></table></div>',
534
+ notes: ["Wrap tables in `overflow-x-auto` for mobile.", "Use semantic table markup for tabular data."]
535
+ },
536
+ modal: {
537
+ classes: [
538
+ "modal",
539
+ "modal-box",
540
+ "modal-action",
541
+ "modal-backdrop",
542
+ "modal-toggle",
543
+ "modal-open",
544
+ "modal-top",
545
+ "modal-middle",
546
+ "modal-bottom",
547
+ "modal-start",
548
+ "modal-end"
549
+ ],
550
+ syntax: '<button class="btn" onclick="details_modal.showModal()">Open</button><dialog id="details_modal" class="modal"><div class="modal-box"><h3 class="text-lg font-bold">Title</h3><p class="py-4">Content</p><div class="modal-action"><form method="dialog"><button class="btn">Close</button></form></div></div></dialog>',
551
+ notes: [
552
+ "Prefer native `<dialog>` with `showModal()` for accessibility.",
553
+ "Use unique IDs for every modal.",
554
+ "Use `modal-bottom sm:modal-middle` for mobile-friendly responsive placement."
555
+ ]
556
+ },
557
+ collapse: {
558
+ classes: [
559
+ "collapse",
560
+ "collapse-title",
561
+ "collapse-content",
562
+ "collapse-arrow",
563
+ "collapse-plus",
564
+ "collapse-open",
565
+ "collapse-close"
566
+ ],
567
+ syntax: '<div tabindex="0" class="collapse collapse-arrow bg-base-200"><div class="collapse-title">Title</div><div class="collapse-content"><p>Hidden detail</p></div></div>',
568
+ notes: [
569
+ "Use a checkbox child for independently toggleable collapses.",
570
+ "Use radio inputs with the same name for accordion behavior where only one item stays open."
571
+ ]
572
+ },
573
+ drawer: {
574
+ classes: [
575
+ "drawer",
576
+ "drawer-toggle",
577
+ "drawer-content",
578
+ "drawer-side",
579
+ "drawer-overlay",
580
+ "drawer-end",
581
+ "drawer-open"
582
+ ],
583
+ syntax: '<div class="drawer lg:drawer-open"><input id="nav" type="checkbox" class="drawer-toggle"><div class="drawer-content"><label for="nav" class="btn drawer-button lg:hidden">Menu</label></div><div class="drawer-side"><label for="nav" aria-label="close sidebar" class="drawer-overlay"></label><ul class="menu bg-base-200 min-h-full w-80 p-4"><li><button>Item</button></li></ul></div></div>',
584
+ notes: [
585
+ "Every page region belongs inside `drawer-content` or `drawer-side`.",
586
+ "The hidden `drawer-toggle` input needs a unique ID.",
587
+ "Use labels with `for` to open and close the drawer."
588
+ ]
589
+ },
590
+ navbar: {
591
+ classes: ["navbar", "navbar-start", "navbar-center", "navbar-end"],
592
+ syntax: '<div class="navbar bg-base-200"><div class="navbar-start"><a class="btn btn-ghost text-xl">Title</a></div><div class="navbar-end"><button class="btn btn-primary">Action</button></div></div>',
593
+ notes: ["Use the start, center, and end parts to align content horizontally."]
594
+ },
595
+ menu: {
596
+ classes: [
597
+ "menu",
598
+ "menu-title",
599
+ "menu-dropdown",
600
+ "menu-dropdown-toggle",
601
+ "menu-disabled",
602
+ "menu-active",
603
+ "menu-focus",
604
+ "menu-dropdown-show",
605
+ "menu-xs",
606
+ "menu-sm",
607
+ "menu-md",
608
+ "menu-lg",
609
+ "menu-xl",
610
+ "menu-horizontal",
611
+ "menu-vertical"
612
+ ],
613
+ syntax: '<ul class="menu bg-base-200 rounded-box"><li><button class="menu-active">Item</button></li><li><a>Link</a></li></ul>',
614
+ notes: ["Use `lg:menu-horizontal` for responsive menus.", "Use `<details>` for collapsible submenus."]
615
+ },
616
+ tabs: {
617
+ classes: [
618
+ "tabs",
619
+ "tab",
620
+ "tab-active",
621
+ "tab-disabled",
622
+ "tabs-box",
623
+ "tabs-border",
624
+ "tabs-lift",
625
+ "tab-content",
626
+ "tab-xs",
627
+ "tab-sm",
628
+ "tab-md",
629
+ "tab-lg",
630
+ "tab-xl"
631
+ ],
632
+ syntax: '<div role="tablist" class="tabs tabs-border"><button role="tab" class="tab tab-active">One</button><button role="tab" class="tab">Two</button></div>',
633
+ notes: ["Use role attributes when tabs are interactive controls."]
634
+ },
635
+ steps: {
636
+ classes: [
637
+ "steps",
638
+ "step",
639
+ "step-primary",
640
+ "step-secondary",
641
+ "step-accent",
642
+ "step-info",
643
+ "step-success",
644
+ "step-warning",
645
+ "step-error",
646
+ "steps-vertical",
647
+ "steps-horizontal"
648
+ ],
649
+ syntax: '<ul class="steps"><li class="step step-primary">Plan</li><li class="step">Build</li><li class="step">Review</li></ul>',
650
+ notes: ["Use `steps-vertical lg:steps-horizontal` for responsive process views."]
651
+ },
652
+ stat: {
653
+ classes: ["stats", "stat", "stat-title", "stat-value", "stat-desc", "stat-figure", "stat-actions"],
654
+ syntax: '<div class="stats stats-vertical lg:stats-horizontal shadow"><div class="stat"><div class="stat-title">Issues</div><div class="stat-value">3</div><div class="stat-desc">Need review</div></div></div>',
655
+ notes: ["Use stats for key numbers above dense detail."]
656
+ },
657
+ progress: {
658
+ classes: [
659
+ "progress",
660
+ "progress-neutral",
661
+ "progress-primary",
662
+ "progress-secondary",
663
+ "progress-accent",
664
+ "progress-info",
665
+ "progress-success",
666
+ "progress-warning",
667
+ "progress-error",
668
+ "radial-progress"
669
+ ],
670
+ syntax: '<progress class="progress progress-primary" value="70" max="100"></progress><div class="radial-progress" style="--value:70;" role="progressbar" aria-valuenow="70">70%</div>',
671
+ notes: [
672
+ "Progress elements need `value` and `max`.",
673
+ 'Radial progress uses `--value`, `role="progressbar"`, and `aria-valuenow`.'
674
+ ]
675
+ },
676
+ forms: {
677
+ classes: [
678
+ "input",
679
+ "textarea",
680
+ "select",
681
+ "checkbox",
682
+ "radio",
683
+ "toggle",
684
+ "range",
685
+ "rating",
686
+ "fieldset",
687
+ "fieldset-legend",
688
+ "label",
689
+ "floating-label",
690
+ "validator"
691
+ ],
692
+ syntax: '<fieldset class="fieldset"><legend class="fieldset-legend">Choice</legend><select class="select"><option>One</option></select><p class="label">Helper text</p></fieldset>',
693
+ notes: [
694
+ "Use unique `name` values for each radio, rating, or filter group.",
695
+ "Use matching color and size modifiers such as `input-primary input-lg` when needed."
696
+ ]
697
+ },
698
+ tooltip_toast: {
699
+ classes: [
700
+ "tooltip",
701
+ "tooltip-open",
702
+ "tooltip-top",
703
+ "tooltip-bottom",
704
+ "tooltip-left",
705
+ "tooltip-right",
706
+ "toast",
707
+ "toast-start",
708
+ "toast-center",
709
+ "toast-end",
710
+ "toast-top",
711
+ "toast-middle",
712
+ "toast-bottom"
713
+ ],
714
+ syntax: '<div class="tooltip" data-tip="More context"><button class="btn">Hover</button></div><div class="toast toast-end"><div class="alert alert-success">Saved</div></div>',
715
+ notes: ["Tooltips use `data-tip` for text.", "Toast is a positioned wrapper; put `alert` content inside."]
716
+ },
717
+ mockup: {
718
+ classes: [
719
+ "mockup-browser",
720
+ "mockup-browser-toolbar",
721
+ "mockup-code",
722
+ "mockup-phone",
723
+ "mockup-phone-camera",
724
+ "mockup-phone-display",
725
+ "mockup-window"
726
+ ],
727
+ syntax: '<div class="mockup-code"><pre data-prefix="$"><code>npm test</code></pre></div>',
728
+ notes: [
729
+ "Use `pre data-prefix` for short command prompts, symbols, or line numbers.",
730
+ "Keep `data-prefix` short because DaisyUI renders it in the code gutter; use prose outside the mockup for long labels.",
731
+ "Use mockups for product or terminal examples, not regular prose."
732
+ ]
733
+ },
734
+ utility_rules: {
735
+ classes: [
736
+ "hero",
737
+ "hero-content",
738
+ "divider",
739
+ "join",
740
+ "join-item",
741
+ "indicator",
742
+ "indicator-item",
743
+ "avatar",
744
+ "chat",
745
+ "chat-start",
746
+ "chat-end",
747
+ "loading",
748
+ "skeleton",
749
+ "diff",
750
+ "timeline"
751
+ ],
752
+ syntax: '<main class="mx-auto max-w-6xl p-6 lg:p-10"><section class="hero bg-base-200 rounded-box"><div class="hero-content text-center"><h1 class="text-5xl font-bold">Review surface</h1></div></section></main>',
753
+ notes: [
754
+ "Compose DaisyUI components with Tailwind utilities for spacing, grid, flex, width, and typography.",
755
+ "Prefer component classes over custom CSS for common UI."
756
+ ]
757
+ }
758
+ }
759
+ };
760
+ }
761
+
762
+ // src/paths.js
763
+ import { mkdir } from "node:fs/promises";
764
+ import os from "node:os";
765
+ import path from "node:path";
766
+ var LOOPBACK_HOST = "127.0.0.1";
767
+ var IPV6_LOOPBACK_HOST = "::1";
768
+ var WILDCARD_BIND_LOOPBACK = /* @__PURE__ */ new Map([
769
+ ["0.0.0.0", LOOPBACK_HOST],
770
+ ["::", IPV6_LOOPBACK_HOST]
771
+ ]);
772
+ function bindHost(env = process.env) {
773
+ return env.LAVISH_AXI_HOST?.trim() || LOOPBACK_HOST;
774
+ }
775
+ function clientHost(env = process.env) {
776
+ const host = bindHost(env);
777
+ return WILDCARD_BIND_LOOPBACK.get(host) ?? host;
778
+ }
779
+ function linkHost(env = process.env) {
780
+ return env.LAVISH_AXI_LINK_HOST?.trim() || clientHost(env);
781
+ }
782
+ function hostForUrl(host) {
783
+ if (host.includes(":") && !host.startsWith("[")) return `[${host}]`;
784
+ return host;
785
+ }
786
+ function stateDir() {
787
+ return process.env.LAVISH_AXI_STATE_DIR || path.join(os.homedir(), ".lavish-axi");
788
+ }
789
+ function stateFile() {
790
+ return path.join(stateDir(), "state.json");
791
+ }
792
+ function serverLogFile() {
793
+ return path.join(stateDir(), "server.log");
794
+ }
795
+ async function ensureStateDir() {
796
+ await mkdir(stateDir(), { recursive: true });
797
+ }
798
+ function defaultPort() {
799
+ return Number(process.env.LAVISH_AXI_PORT || 4387);
800
+ }
801
+
802
+ // src/scaffold.js
803
+ var MERMAID_CDN = "https://cdn.jsdelivr.net/npm/mermaid@11.15.0/dist/mermaid.esm.min.mjs";
804
+ function escapeHtml(value) {
805
+ return String(value).replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
806
+ }
807
+ function grillCard({ question, prompt, type = "radio", options = [] }) {
808
+ const inputs = options.map(
809
+ (opt) => ` <label><input type="${type}" name="answer" value="${escapeHtml(opt)}" /> ${escapeHtml(opt)}</label>`
810
+ ).join("\n");
811
+ return ` <form class="loupe-grill-card" data-loupe-grill data-question="${escapeHtml(question)}">
812
+ <p class="loupe-q">${escapeHtml(prompt)}</p>
813
+ <div class="loupe-options">
814
+ ${inputs}
815
+ </div>
816
+ <details class="loupe-open">
817
+ <summary>I'd rather say it myself</summary>
818
+ <textarea name="open" placeholder="Your own answer\u2026"></textarea>
819
+ </details>
820
+ <div class="loupe-card-actions">
821
+ <button type="submit" class="loupe-btn primary">Queue answer</button>
822
+ <span class="loupe-answered-note">\u2713 queued \u2014 edit and re-queue any time</span>
823
+ </div>
824
+ </form>`;
825
+ }
826
+ function productLens() {
827
+ return ` <section class="loupe-lens" id="product-lens">
828
+ <div class="loupe-lens-head"><span class="loupe-lens-kicker">Product Lens</span> What changes for the user</div>
829
+
830
+ <!-- ===== \xA7A PRODUCT CURRENT WORLD ===== -->
831
+ <section class="loupe-section" id="product-current-world">
832
+ <div class="loupe-section-label"><b>\xA7A</b> Current World</div>
833
+ <h2>Where this change lands today</h2>
834
+ <p class="loupe-section-hint">
835
+ Map the relevant use cases as they are now, then highlight the Blast Radius \u2014
836
+ the parts this change will touch.
837
+ </p>
838
+ <div class="loupe-card">
839
+ <div class="loupe-fill">
840
+ LOUPE \u2014 fill: replace the mermaid with the real use-case flow. Color touched
841
+ nodes with <code>class X hit</code> (predefined). Quote labels with punctuation,
842
+ e.g. <code>X["Pay (guest)"]</code>; a bare <code>&amp;</code> / <code>(</code> breaks the parse.
843
+ </div>
844
+ <div class="loupe-diagram">
845
+ <pre class="mermaid">
846
+ flowchart TD
847
+ classDef hit fill:#fdecea,stroke:#d83a2e,stroke-width:2px,color:#1d1d1f;
848
+
849
+ A[Step one] --> B[Step two]
850
+ B --> C[Affected step]
851
+ C --> D[Another affected step]
852
+ class C,D hit
853
+ </pre>
854
+ </div>
855
+ <div class="loupe-legend">
856
+ <span><i class="loupe-sw" style="background:#fff;border:1px solid var(--hair)"></i> unaffected</span>
857
+ <span><i class="loupe-sw" style="background:var(--hit-bg);border:1px solid var(--hit)"></i> touched by this change (Blast Radius)</span>
858
+ </div>
859
+ </div>
860
+ </section>
861
+
862
+ <!-- ===== \xA7B PRODUCT GRILL ===== -->
863
+ <section class="loupe-section" id="product-grill">
864
+ <div class="loupe-section-label"><b>\xA7B</b> Grill</div>
865
+ <h2>Questions to sharpen the change</h2>
866
+ <p class="loupe-section-hint">
867
+ Answer by clicking \u2014 every card also has an open field. Your answers shape the vision below.
868
+ </p>
869
+ <div class="loupe-fill">
870
+ LOUPE \u2014 fill: replace the example with the real product questions (impact, scope, edge cases).
871
+ </div>
872
+ ${grillCard({
873
+ question: "example-scope",
874
+ prompt: "Example: how far should this change reach?",
875
+ type: "radio",
876
+ options: ["Just the affected steps", "The whole flow", "Not sure yet"]
877
+ })}
878
+ </section>
879
+
880
+ <!-- ===== \xA7C PRODUCT GOAL VISION ===== -->
881
+ <section class="loupe-section" id="product-vision">
882
+ <div class="loupe-section-label"><b>\xA7C</b> Goal Vision</div>
883
+ <h2>Before \u2192 after, for the user</h2>
884
+ <p class="loupe-section-hint">
885
+ The same map as \xA7A, re-drawn for the agreed change, side by side so the delta is visible.
886
+ </p>
887
+ <div class="loupe-card">
888
+ <div class="loupe-fill">
889
+ LOUPE \u2014 fill after grill answers arrive: redraw both sides, keeping the layout aligned with \xA7A.
890
+ </div>
891
+ <div class="loupe-ba">
892
+ <div class="loupe-ba-col before">
893
+ <div class="loupe-ba-tag">Before</div>
894
+ <div class="loupe-diagram">
895
+ <pre class="mermaid">
896
+ flowchart TD
897
+ A[Step one] --> B[Step two]
898
+ B --> C[Affected step]
899
+ </pre>
900
+ </div>
901
+ </div>
902
+ <div class="loupe-ba-col after">
903
+ <div class="loupe-ba-tag">After</div>
904
+ <div class="loupe-diagram">
905
+ <pre class="mermaid">
906
+ flowchart TD
907
+ classDef new fill:#eaf4ec,stroke:#1f8b4c,stroke-width:2px,color:#1d1d1f;
908
+ A[Step one] --> B[Step two]
909
+ B --> C[Reworked step]
910
+ class C new
911
+ </pre>
912
+ </div>
913
+ </div>
914
+ </div>
915
+ </div>
916
+ </section>
917
+ </section>`;
918
+ }
919
+ function gate() {
920
+ return ` <div class="loupe-gate" id="lens-gate">
921
+ <div class="loupe-gate-text">
922
+ <strong>Gate \u2014 agree the product change first</strong>
923
+ <p>Settle the Product Lens above, then lock the intent so the agent fills the Code Lens against an agreed change. (A soft signal \u2014 it never blocks editing or annotation.)</p>
924
+ </div>
925
+ <button type="button" class="loupe-btn primary" data-loupe-lock>Lock product intent \u2192</button>
926
+ </div>`;
927
+ }
928
+ function codeLens() {
929
+ return ` <section class="loupe-lens loupe-codelens" id="code-lens" data-loupe-codelens>
930
+ <div class="loupe-lens-head"><span class="loupe-lens-kicker">Code Lens</span> What changes in the code <span class="loupe-lens-note">\u2014 the agent fills this once the product intent is locked</span></div>
931
+
932
+ <!-- ===== \xA7A CODE CURRENT ARCHITECTURE ===== -->
933
+ <section class="loupe-section" id="code-current-world">
934
+ <div class="loupe-section-label"><b>\xA7A</b> Current Architecture</div>
935
+ <h2>How the code is shaped today</h2>
936
+ <p class="loupe-section-hint">
937
+ The modules / interfaces involved as they are now, with the Blast Radius highlighted.
938
+ </p>
939
+ <div class="loupe-card">
940
+ <div class="loupe-fill">
941
+ LOUPE \u2014 fill after the Gate: read the REAL codebase and draw the current
942
+ architecture (modules, interfaces, data flow); color touched nodes
943
+ <code>class X hit</code> and quote labels with punctuation (e.g.
944
+ <code>X["Auth (login)"]</code>). No codebase yet (greenfield)? Replace this with
945
+ the proposed from-scratch architecture and say so.
946
+ </div>
947
+ <div class="loupe-diagram">
948
+ <pre class="mermaid">
949
+ flowchart TD
950
+ classDef hit fill:#fdecea,stroke:#d83a2e,stroke-width:2px,color:#1d1d1f;
951
+
952
+ M1[Module A] --> M2[Module B]
953
+ M2 --> M3[Affected module]
954
+ class M3 hit
955
+ </pre>
956
+ </div>
957
+ <div class="loupe-legend">
958
+ <span><i class="loupe-sw" style="background:#fff;border:1px solid var(--hair)"></i> unaffected</span>
959
+ <span><i class="loupe-sw" style="background:var(--hit-bg);border:1px solid var(--hit)"></i> touched (Blast Radius)</span>
960
+ </div>
961
+ </div>
962
+ </section>
963
+
964
+ <!-- ===== \xA7B CODE GRILL ===== -->
965
+ <section class="loupe-section" id="code-grill">
966
+ <div class="loupe-section-label"><b>\xA7B</b> Grill</div>
967
+ <h2>Questions to sharpen the design</h2>
968
+ <p class="loupe-section-hint">Architecture and interface decisions \u2014 answer by clicking.</p>
969
+ <div class="loupe-fill">
970
+ LOUPE \u2014 fill: real questions about interfaces, data shape, boundaries, migration.
971
+ </div>
972
+ ${grillCard({
973
+ question: "code-example-boundary",
974
+ prompt: "Example: where should the new behavior live?",
975
+ type: "radio",
976
+ options: ["Extend the affected module", "New module behind an interface", "Not sure yet"]
977
+ })}
978
+ </section>
979
+
980
+ <!-- ===== \xA7C CODE GOAL VISION ===== -->
981
+ <section class="loupe-section" id="code-vision">
982
+ <div class="loupe-section-label"><b>\xA7C</b> Goal Vision</div>
983
+ <h2>Before \u2192 after: interfaces &amp; architecture</h2>
984
+ <p class="loupe-section-hint">
985
+ What is added / changed / removed in the code, aligned with \xA7A so the delta is visible.
986
+ </p>
987
+ <div class="loupe-card">
988
+ <div class="loupe-fill">
989
+ LOUPE \u2014 fill after code grill answers: redraw the architecture before \u2192 after and
990
+ list the interface delta (added / changed / removed signatures, endpoints, configs).
991
+ </div>
992
+ <div class="loupe-ba">
993
+ <div class="loupe-ba-col before">
994
+ <div class="loupe-ba-tag">Before</div>
995
+ <div class="loupe-diagram">
996
+ <pre class="mermaid">
997
+ flowchart TD
998
+ M1[Module A] --> M2[Module B]
999
+ M2 --> M3[Affected module]
1000
+ </pre>
1001
+ </div>
1002
+ </div>
1003
+ <div class="loupe-ba-col after">
1004
+ <div class="loupe-ba-tag">After</div>
1005
+ <div class="loupe-diagram">
1006
+ <pre class="mermaid">
1007
+ flowchart TD
1008
+ classDef new fill:#eaf4ec,stroke:#1f8b4c,stroke-width:2px,color:#1d1d1f;
1009
+ M1[Module A] --> M2[Module B]
1010
+ M2 --> M4[New module]
1011
+ class M4 new
1012
+ </pre>
1013
+ </div>
1014
+ </div>
1015
+ </div>
1016
+ </div>
1017
+ </section>
1018
+
1019
+ <div class="loupe-reopen-bar">
1020
+ <button type="button" class="loupe-link" data-loupe-reopen>\u21A9 Reopen product intent (the code review changed my mind)</button>
1021
+ </div>
1022
+ </section>`;
1023
+ }
1024
+ function codeLensGreenfield() {
1025
+ return ` <section class="loupe-lens loupe-codelens" id="code-lens" data-loupe-codelens>
1026
+ <div class="loupe-lens-head"><span class="loupe-lens-kicker">Code Lens</span> What we'll build <span class="loupe-lens-note">\u2014 greenfield: no existing code, designed from the agreed product intent</span></div>
1027
+
1028
+ <!-- ===== \xA7A PROPOSED ARCHITECTURE (from scratch) ===== -->
1029
+ <section class="loupe-section" id="code-current-world">
1030
+ <div class="loupe-section-label"><b>\xA7A</b> Proposed Architecture</div>
1031
+ <h2>How we'll shape the code from scratch</h2>
1032
+ <p class="loupe-section-hint">
1033
+ The modules and interfaces to build for the agreed change. No "before" \u2014 this is greenfield.
1034
+ </p>
1035
+ <div class="loupe-card">
1036
+ <div class="loupe-fill">
1037
+ LOUPE \u2014 fill after the Gate: design the architecture from scratch from the agreed product
1038
+ intent; mark the core new pieces with <code>class X new</code> and quote labels with
1039
+ punctuation (e.g. <code>X["Auth (token)"]</code>).
1040
+ </div>
1041
+ <div class="loupe-diagram">
1042
+ <pre class="mermaid">
1043
+ flowchart TD
1044
+ classDef new fill:#eaf4ec,stroke:#1f8b4c,stroke-width:2px,color:#1d1d1f;
1045
+
1046
+ Client[Client] --> Api[New API]
1047
+ Api --> Svc[New service]
1048
+ Svc --> Store[(New store)]
1049
+ class Api,Svc,Store new
1050
+ </pre>
1051
+ </div>
1052
+ <div class="loupe-legend">
1053
+ <span><i class="loupe-sw" style="background:#eaf4ec;border:1px solid #1f8b4c"></i> new (to build)</span>
1054
+ </div>
1055
+ </div>
1056
+ </section>
1057
+
1058
+ <!-- ===== \xA7B CODE GRILL ===== -->
1059
+ <section class="loupe-section" id="code-grill">
1060
+ <div class="loupe-section-label"><b>\xA7B</b> Grill</div>
1061
+ <h2>Questions to sharpen the design</h2>
1062
+ <p class="loupe-section-hint">Architecture and interface decisions \u2014 answer by clicking.</p>
1063
+ <div class="loupe-fill">
1064
+ LOUPE \u2014 fill: real questions about boundaries, data shape, build order, what to defer.
1065
+ </div>
1066
+ ${grillCard({
1067
+ question: "greenfield-shape",
1068
+ prompt: "Example: what is the first piece to build?",
1069
+ type: "radio",
1070
+ options: ["The data model", "The API surface", "An end-to-end thin slice", "Not sure yet"]
1071
+ })}
1072
+ </section>
1073
+
1074
+ <!-- ===== \xA7C INTERFACES & CONTRACTS (all new) ===== -->
1075
+ <section class="loupe-section" id="code-vision">
1076
+ <div class="loupe-section-label"><b>\xA7C</b> Interfaces &amp; contracts</div>
1077
+ <h2>What to build, concretely</h2>
1078
+ <p class="loupe-section-hint">
1079
+ The shapes the new code exposes \u2014 endpoints, signatures, data. No before/after; it is all new.
1080
+ </p>
1081
+ <div class="loupe-card">
1082
+ <div class="loupe-fill">
1083
+ LOUPE \u2014 fill after grill answers: list the interfaces / endpoints / data shapes to add.
1084
+ </div>
1085
+ <ul class="loupe-contracts">
1086
+ <li><span class="loupe-tag-add">add</span> <code>\u2026</code></li>
1087
+ <li><span class="loupe-tag-add">add</span> <code>\u2026</code></li>
1088
+ </ul>
1089
+ </div>
1090
+ </section>
1091
+
1092
+ <div class="loupe-reopen-bar">
1093
+ <button type="button" class="loupe-link" data-loupe-reopen>\u21A9 Reopen product intent (the design changed my mind)</button>
1094
+ </div>
1095
+ </section>`;
1096
+ }
1097
+ function decisionSection() {
1098
+ return ` <section class="loupe-section loupe-decision" id="decision">
1099
+ <div class="loupe-section-label"><b>\u2713</b> Decision</div>
1100
+ <h2>Ready to build this?</h2>
1101
+ <p class="loupe-section-hint">
1102
+ When the vision above is right, decide. Execute hands the agreed spec back to the agent to
1103
+ implement; Adjust keeps iterating; Cancel drops it.
1104
+ </p>
1105
+ <div class="loupe-decision-actions">
1106
+ <button type="button" class="loupe-btn primary" data-loupe-decision="execute">Execute \u2014 build it \u2192</button>
1107
+ <button type="button" class="loupe-btn" data-loupe-decision="adjust">Adjust \u2014 keep iterating</button>
1108
+ <button type="button" class="loupe-btn" data-loupe-decision="cancel">Cancel \u2014 don't build</button>
1109
+ </div>
1110
+ </section>`;
1111
+ }
1112
+ function createScaffoldHtml({
1113
+ title = "Untitled change",
1114
+ problem = "",
1115
+ productOnly = false,
1116
+ greenfield = false
1117
+ } = {}) {
1118
+ const safeTitle = escapeHtml(title);
1119
+ const safeProblem = problem ? escapeHtml(problem) : "";
1120
+ const isGreenfield = greenfield && !productOnly;
1121
+ const nav = productOnly ? ` <a href="#product-current-world">\xA7A Current World</a>
1122
+ <a href="#product-grill">\xA7B Grill</a>
1123
+ <a href="#product-vision">\xA7C Goal Vision</a>` : ` <span class="loupe-nav-group">Product</span>
1124
+ <a href="#product-current-world">\xA7A</a>
1125
+ <a href="#product-grill">\xA7B</a>
1126
+ <a href="#product-vision">\xA7C</a>
1127
+ <span class="loupe-nav-lock" title="opens after the gate">\u{1F512}</span>
1128
+ <span class="loupe-nav-group">Code</span>
1129
+ <a href="#code-current-world">\xA7A</a>
1130
+ <a href="#code-grill">\xA7B</a>
1131
+ <a href="#code-vision">\xA7C</a>`;
1132
+ const code = isGreenfield ? codeLensGreenfield() : codeLens();
1133
+ const lensBlocks = productOnly ? productLens() : `${productLens()}
1134
+
1135
+ ${gate()}
1136
+
1137
+ ${code}`;
1138
+ const lenses = `${lensBlocks}
1139
+
1140
+ ${decisionSection()}`;
1141
+ return `<!DOCTYPE html>
1142
+ <html lang="en" data-loupe-scaffold="v2"${isGreenfield ? ' data-loupe-greenfield="true"' : ""}>
1143
+ <head>
1144
+ <meta charset="UTF-8" />
1145
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
1146
+ <title>Loupe \u2014 ${safeTitle}</title>
1147
+ <style>
1148
+ /* Loupe clarity styles (ADR-0003: clarity over polish). Restrained, legible. */
1149
+ :root {
1150
+ --bg: #f6f6f7;
1151
+ --surface: #ffffff;
1152
+ --ink: #1d1d1f;
1153
+ --ink-2: #5b5b60;
1154
+ --ink-3: #8a8a8f;
1155
+ --hair: rgba(0, 0, 0, 0.1);
1156
+ --accent: #0b6bcb;
1157
+ --hit: #d83a2e;
1158
+ --hit-bg: #fdecea;
1159
+ --radius: 14px;
1160
+ --maxw: 880px;
1161
+ --font: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", "PingFang TC", "Noto Sans TC",
1162
+ sans-serif;
1163
+ }
1164
+ * { box-sizing: border-box; }
1165
+ html { background: var(--bg); }
1166
+ body {
1167
+ margin: 0; font-family: var(--font); color: var(--ink); line-height: 1.55;
1168
+ -webkit-font-smoothing: antialiased; padding: 0 20px 120px;
1169
+ }
1170
+ .loupe-wrap { max-width: var(--maxw); margin: 0 auto; }
1171
+
1172
+ header.loupe-head { padding: 56px 0 8px; }
1173
+ .loupe-kicker { font-size: 12px; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; color: var(--accent); }
1174
+ header.loupe-head h1 { font-size: 30px; font-weight: 680; letter-spacing: -0.02em; margin: 8px 0 0; }
1175
+ .loupe-problem { color: var(--ink-2); font-size: 17px; margin-top: 10px; }
1176
+
1177
+ nav.loupe-nav {
1178
+ position: sticky; top: 0; z-index: 5; display: flex; gap: 8px; align-items: center; flex-wrap: wrap;
1179
+ padding: 14px 0; background: linear-gradient(var(--bg) 70%, transparent); margin-top: 24px;
1180
+ }
1181
+ nav.loupe-nav a {
1182
+ font-size: 13px; font-weight: 550; color: var(--ink-2); text-decoration: none;
1183
+ padding: 6px 11px; border-radius: 999px; border: 1px solid var(--hair); background: var(--surface);
1184
+ }
1185
+ nav.loupe-nav a:hover { color: var(--ink); }
1186
+ .loupe-nav-group { font-size: 12px; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: var(--ink-3); margin-left: 6px; }
1187
+ .loupe-nav-lock { color: var(--ink-3); }
1188
+
1189
+ .loupe-lens { margin-top: 28px; }
1190
+ .loupe-lens-head { font-size: 15px; font-weight: 600; color: var(--ink-2); margin: 8px 0 4px; }
1191
+ .loupe-lens-kicker {
1192
+ display: inline-block; font-size: 11px; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase;
1193
+ color: var(--accent); background: #eaf2fb; border-radius: 999px; padding: 3px 10px; margin-right: 8px;
1194
+ }
1195
+
1196
+ section.loupe-section { margin-top: 32px; scroll-margin-top: 64px; }
1197
+ .loupe-section-label { display: inline-flex; align-items: baseline; gap: 8px; font-size: 13px; font-weight: 600; color: var(--ink-3); letter-spacing: 0.02em; margin-bottom: 4px; }
1198
+ .loupe-section-label b { color: var(--accent); font-size: 14px; }
1199
+ section.loupe-section h2 { font-size: 21px; font-weight: 640; letter-spacing: -0.015em; margin: 0 0 4px; }
1200
+ .loupe-section-hint { color: var(--ink-3); font-size: 14px; margin: 0 0 18px; }
1201
+
1202
+ .loupe-card { background: var(--surface); border: 1px solid var(--hair); border-radius: var(--radius); padding: 22px; }
1203
+
1204
+ .loupe-diagram { display: flex; justify-content: center; min-width: 0; overflow-x: auto; }
1205
+ .loupe-diagram .mermaid { min-width: 0; }
1206
+ .loupe-legend { display: flex; flex-wrap: wrap; gap: 16px; margin-top: 14px; font-size: 13px; color: var(--ink-2); }
1207
+ .loupe-legend span { display: inline-flex; align-items: center; gap: 7px; }
1208
+ .loupe-sw { width: 13px; height: 13px; border-radius: 4px; display: inline-block; }
1209
+
1210
+ .loupe-ba { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
1211
+ @media (max-width: 720px) { .loupe-ba { grid-template-columns: 1fr; } }
1212
+ .loupe-ba-col .loupe-ba-tag { font-size: 12px; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; margin-bottom: 8px; color: var(--ink-3); }
1213
+ .loupe-ba-col.after .loupe-ba-tag { color: var(--accent); }
1214
+
1215
+ .loupe-grill-card { background: var(--surface); border: 1px solid var(--hair); border-radius: var(--radius); padding: 20px; margin-bottom: 14px; }
1216
+ .loupe-grill-card[data-answered="true"] { border-color: var(--accent); }
1217
+ .loupe-q { font-size: 16px; font-weight: 580; margin: 0 0 14px; }
1218
+ .loupe-options { display: flex; flex-direction: column; gap: 8px; margin-bottom: 12px; }
1219
+ .loupe-options label { display: flex; align-items: center; gap: 10px; padding: 9px 12px; border: 1px solid var(--hair); border-radius: 10px; cursor: pointer; font-size: 15px; }
1220
+ .loupe-options label:hover { border-color: var(--accent); }
1221
+ .loupe-open { width: 100%; }
1222
+ .loupe-open summary { cursor: pointer; font-size: 13px; color: var(--ink-3); list-style: none; }
1223
+ .loupe-open summary::-webkit-details-marker { display: none; }
1224
+ .loupe-open textarea { width: 100%; margin-top: 8px; padding: 10px; border: 1px solid var(--hair); border-radius: 10px; font: inherit; font-size: 14px; resize: vertical; min-height: 64px; }
1225
+ .loupe-card-actions { display: flex; align-items: center; gap: 12px; margin-top: 14px; }
1226
+ .loupe-btn { font: inherit; font-size: 14px; font-weight: 560; cursor: pointer; border: 1px solid var(--hair); background: var(--surface); color: var(--ink); padding: 8px 16px; border-radius: 999px; }
1227
+ .loupe-btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
1228
+ .loupe-btn:disabled { opacity: 0.55; cursor: default; }
1229
+ .loupe-answered-note { font-size: 13px; color: var(--accent); display: none; }
1230
+ .loupe-grill-card[data-answered="true"] .loupe-answered-note { display: inline; }
1231
+
1232
+ /* gate (soft signal \u2014 never blocks editing or annotation) */
1233
+ .loupe-gate {
1234
+ display: flex; align-items: center; gap: 18px; justify-content: space-between; flex-wrap: wrap;
1235
+ margin: 36px 0 8px; padding: 18px 22px; border: 1px solid var(--accent); border-radius: var(--radius);
1236
+ background: #eef5fd;
1237
+ }
1238
+ .loupe-gate-text strong { display: block; font-size: 15px; }
1239
+ .loupe-gate-text p { margin: 4px 0 0; font-size: 13px; color: var(--ink-2); }
1240
+ .loupe-lens-note { font-weight: 400; color: var(--ink-3); }
1241
+ .loupe-reopen-bar { margin-top: 20px; }
1242
+ .loupe-link { background: none; border: none; color: var(--ink-3); font: inherit; font-size: 13px; cursor: pointer; text-decoration: underline; padding: 4px 0; }
1243
+ .loupe-link:hover { color: var(--accent); }
1244
+ .loupe-contracts { margin: 4px 0 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 8px; }
1245
+ .loupe-contracts li { display: flex; align-items: center; gap: 10px; font-size: 14px; }
1246
+ .loupe-tag-add { font-size: 11px; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: #1f8b4c; background: #eaf4ec; border-radius: 6px; padding: 2px 7px; }
1247
+
1248
+ .loupe-decision { border-top: 1px solid var(--hair); padding-top: 16px; }
1249
+ .loupe-decision-actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 6px; }
1250
+ .loupe-decision-actions .loupe-btn[disabled] { opacity: 1; outline: 2px solid var(--accent); outline-offset: 2px; }
1251
+
1252
+ .loupe-send-bar { position: sticky; bottom: 0; margin-top: 24px; padding: 16px 0; background: linear-gradient(transparent, var(--bg) 30%); display: flex; justify-content: flex-end; gap: 12px; }
1253
+
1254
+ .loupe-fill { border: 1px dashed var(--accent); border-radius: 10px; padding: 10px 14px; margin: 10px 0; font-size: 13px; color: var(--accent); background: #f0f6fd; }
1255
+ </style>
1256
+ </head>
1257
+ <body>
1258
+ <div class="loupe-wrap">
1259
+
1260
+ <header class="loupe-head">
1261
+ <div class="loupe-kicker">\u{1F50D} Loupe \xB7 inspect before you build</div>
1262
+ <h1>${safeTitle}</h1>
1263
+ ${safeProblem ? `<p class="loupe-problem">${safeProblem}</p>` : `<!-- LOUPE \u2014 fill: one-line statement of the problem this change solves -->`}
1264
+ </header>
1265
+
1266
+ <nav class="loupe-nav">
1267
+ ${nav}
1268
+ </nav>
1269
+
1270
+ ${lenses}
1271
+
1272
+ <div class="loupe-send-bar">
1273
+ <button type="button" class="loupe-btn primary" data-loupe-send>Send answers to agent</button>
1274
+ </div>
1275
+
1276
+ </div>
1277
+
1278
+ <script type="module">
1279
+ import mermaid from "${MERMAID_CDN}";
1280
+ mermaid.initialize({ startOnLoad: false, theme: "base", securityLevel: "loose", flowchart: { useMaxWidth: true } });
1281
+ try {
1282
+ await mermaid.run();
1283
+ } catch (e) {
1284
+ console.error("Loupe: mermaid render failed", e);
1285
+ }
1286
+ </script>
1287
+
1288
+ <script>
1289
+ // Loupe interactions, all built on the lavish queuePrompt round-trip. Everything
1290
+ // degrades gracefully when window.lavish is absent (artifact opened directly).
1291
+ (function () {
1292
+ function queue(text, key, send) {
1293
+ if (!(window.lavish && typeof window.lavish.queuePrompt === "function")) return;
1294
+ window.lavish.queuePrompt(text, { tag: "loupe", text: text, queueKey: key });
1295
+ if (send && typeof window.lavish.sendQueuedPrompts === "function") window.lavish.sendQueuedPrompts();
1296
+ }
1297
+
1298
+ // Grill Cards
1299
+ function readAnswer(form) {
1300
+ const data = new FormData(form);
1301
+ const choices = data.getAll("answer").filter(Boolean);
1302
+ const open = (data.get("open") || "").toString().trim();
1303
+ const parts = [];
1304
+ if (choices.length) parts.push(choices.join(", "));
1305
+ if (open) parts.push("(note: " + open + ")");
1306
+ return { text: parts.join(" "), choices: choices, open: open };
1307
+ }
1308
+ document.querySelectorAll("form[data-loupe-grill]").forEach(function (form) {
1309
+ form.addEventListener("submit", function (event) {
1310
+ event.preventDefault();
1311
+ const question = form.getAttribute("data-question") || "grill";
1312
+ const ans = readAnswer(form);
1313
+ if (!ans.text) return;
1314
+ queue("Grill answer [" + question + "]: " + ans.text, "grill:" + question, false);
1315
+ form.setAttribute("data-answered", "true");
1316
+ });
1317
+ });
1318
+
1319
+ // Send-all
1320
+ const sendBtn = document.querySelector("[data-loupe-send]");
1321
+ if (sendBtn) {
1322
+ sendBtn.addEventListener("click", function () {
1323
+ if (window.lavish && typeof window.lavish.sendQueuedPrompts === "function") window.lavish.sendQueuedPrompts();
1324
+ });
1325
+ }
1326
+
1327
+ // Gate: a soft signal. The Code Lens is never blocked \u2014 the HTML stays freely
1328
+ // editable and annotatable (the source of truth). Locking just tells the agent
1329
+ // the product change is agreed, so it fills the Code Lens against it.
1330
+ const codeLens = document.querySelector("[data-loupe-codelens]");
1331
+ const lockBtn = document.querySelector("[data-loupe-lock]");
1332
+ if (lockBtn) {
1333
+ lockBtn.addEventListener("click", function () {
1334
+ lockBtn.disabled = true;
1335
+ lockBtn.textContent = "Product intent locked \u2713";
1336
+ queue(
1337
+ "PRODUCT INTENT LOCKED \u2014 the product change is agreed. Now fill the Code Lens: read the real codebase, draw \xA7A current architecture with the blast radius, write the architecture Grill Cards, and (after answers) the interface/architecture before \u2192 after.",
1338
+ "gate:lock",
1339
+ true,
1340
+ );
1341
+ if (codeLens) codeLens.scrollIntoView({ behavior: "smooth" });
1342
+ });
1343
+ }
1344
+
1345
+ // Reverse gate: reopening product intent is an explicit, consented signal (ADR-0004).
1346
+ const reopenBtn = document.querySelector("[data-loupe-reopen]");
1347
+ if (reopenBtn) {
1348
+ reopenBtn.addEventListener("click", function () {
1349
+ if (lockBtn) {
1350
+ lockBtn.disabled = false;
1351
+ lockBtn.textContent = "Lock product intent \u2192";
1352
+ }
1353
+ queue(
1354
+ "REOPEN PRODUCT INTENT \u2014 the code review surfaced a product-level problem; re-grill the product lens before continuing.",
1355
+ "gate:reopen",
1356
+ true,
1357
+ );
1358
+ const g = document.getElementById("lens-gate");
1359
+ if (g) g.scrollIntoView({ behavior: "smooth" });
1360
+ });
1361
+ }
1362
+
1363
+ // Decision: execute / adjust / cancel. Execute tells the agent to persist the
1364
+ // companion spec (loupe spec) and implement; the others keep or drop the change.
1365
+ const decisionBtns = document.querySelectorAll("[data-loupe-decision]");
1366
+ decisionBtns.forEach(function (btn) {
1367
+ btn.addEventListener("click", function () {
1368
+ const kind = btn.getAttribute("data-loupe-decision");
1369
+ let text;
1370
+ if (kind === "execute") {
1371
+ text =
1372
+ "DECISION: EXECUTE \u2014 the developer approved this change. Persist the companion spec (run 'loupe spec' on this artifact and fill it with the agreed decisions), then begin implementing.";
1373
+ } else if (kind === "cancel") {
1374
+ text = "DECISION: CANCEL \u2014 do not build this change.";
1375
+ } else {
1376
+ text = "DECISION: ADJUST \u2014 keep iterating; do not build yet.";
1377
+ }
1378
+ queue(text, "decision", true);
1379
+ decisionBtns.forEach(function (b) {
1380
+ b.disabled = false;
1381
+ });
1382
+ btn.disabled = true;
1383
+ });
1384
+ });
1385
+ })();
1386
+ </script>
1387
+
1388
+ </body>
1389
+ </html>
1390
+ `;
1391
+ }
1392
+
1393
+ // src/spec.js
1394
+ import path2 from "node:path";
1395
+ function deriveSpecPath(htmlFile) {
1396
+ const dir = path2.dirname(htmlFile);
1397
+ const base = path2.basename(htmlFile).replace(/\.html?$/i, "");
1398
+ return path2.join(dir, `${base}.spec.md`);
1399
+ }
1400
+ function createSpecMarkdown({
1401
+ title = "Untitled change",
1402
+ htmlBasename,
1403
+ productOnly = false,
1404
+ greenfield = false
1405
+ }) {
1406
+ let codeSection = "";
1407
+ if (!productOnly) {
1408
+ codeSection = greenfield ? `
1409
+ ## Code Lens (greenfield \u2014 from scratch)
1410
+
1411
+ - Proposed architecture (modules / interfaces to build): \u2026
1412
+ - Interfaces / contracts to add: \u2026
1413
+ - Grounded in: greenfield from-scratch (no existing codebase)
1414
+ ` : `
1415
+ ## Code Lens
1416
+
1417
+ - Architecture change (before \u2192 after): \u2026
1418
+ - Interface delta \u2014 added / changed / removed: \u2026
1419
+ - Grounded in: real codebase
1420
+ `;
1421
+ }
1422
+ return `# ${title} \u2014 spec
1423
+
1424
+ > Canonical (visual review surface): [\`./${htmlBasename}\`](./${htmlBasename})
1425
+ > Status: **approved** \xB7 generated by \`loupe spec\`
1426
+
1427
+ ## Problem
1428
+
1429
+ \u2026
1430
+
1431
+ ## Product Lens
1432
+
1433
+ - Use cases touched (blast radius): \u2026
1434
+ - UX change (before \u2192 after): \u2026
1435
+ - Decisions settled in the grill: \u2026
1436
+ ${codeSection}
1437
+ ## Open questions / follow-ups
1438
+
1439
+ - \u2026
1440
+ `;
1441
+ }
1442
+
1443
+ // src/server.js
1444
+ import { EventEmitter } from "node:events";
1445
+ import { readFile as readFile2 } from "node:fs/promises";
1446
+ import { homedir } from "node:os";
1447
+ import path4 from "node:path";
1448
+ import chokidar from "chokidar";
1449
+ import express from "express";
1450
+
1451
+ // src/artifact-sdk.js
1452
+ function deriveLavishQueueKey(element, options = {}) {
1453
+ function stringValue(value) {
1454
+ return value === null || value === void 0 ? "" : String(value);
1455
+ }
1456
+ function attributeValue(el, name) {
1457
+ if (!el) return "";
1458
+ if (el.getAttribute) {
1459
+ const value = el.getAttribute(name);
1460
+ if (value !== null && value !== void 0) return value;
1461
+ }
1462
+ return el[name] || "";
1463
+ }
1464
+ function tagName(el) {
1465
+ return stringValue(el?.tagName || el?.nodeName).toLowerCase();
1466
+ }
1467
+ function closestElementMatching(el, selector) {
1468
+ return el && el.closest ? el.closest(selector) : null;
1469
+ }
1470
+ function elementPath(el) {
1471
+ const parts = [];
1472
+ let node = el;
1473
+ while (node && node.nodeType === 1 && parts.length < 6) {
1474
+ let part = tagName(node) || "element";
1475
+ const id = stringValue(attributeValue(node, "id") || node.id).trim();
1476
+ if (id) {
1477
+ part += `#${id}`;
1478
+ parts.unshift(part);
1479
+ break;
1480
+ }
1481
+ const parent2 = node.parentElement;
1482
+ if (parent2 && parent2.children) {
1483
+ const siblings = [...parent2.children].filter((child) => tagName(child) === tagName(node));
1484
+ if (siblings.length > 1) part += `:nth-of-type(${siblings.indexOf(node) + 1})`;
1485
+ }
1486
+ parts.unshift(part);
1487
+ node = parent2;
1488
+ }
1489
+ return parts.join(" > ");
1490
+ }
1491
+ function scopeKey(el) {
1492
+ const scope2 = closestElementMatching(el, "form,fieldset") || el?.parentElement || el;
1493
+ const tag2 = tagName(scope2) || "scope";
1494
+ const explicit = stringValue(
1495
+ attributeValue(scope2, "data-lavish-question") || attributeValue(scope2, "id") || attributeValue(scope2, "name")
1496
+ ).trim();
1497
+ if (explicit) return `${tag2}:${explicit}`;
1498
+ return elementPath(scope2) || tag2;
1499
+ }
1500
+ function controlIdentity(el) {
1501
+ const identity = stringValue(attributeValue(el, "name") || attributeValue(el, "id") || el?.name).trim();
1502
+ if (identity) return identity;
1503
+ return elementPath(el);
1504
+ }
1505
+ function isKeyedInputType(type2) {
1506
+ return !(/* @__PURE__ */ new Set(["button", "submit", "reset", "file", "image", "hidden", "radio", "checkbox"])).has(type2);
1507
+ }
1508
+ if (Object.hasOwn(options, "queueKey")) {
1509
+ return stringValue(options.queueKey).trim();
1510
+ }
1511
+ const question = closestElementMatching(element, "[data-lavish-question]");
1512
+ const questionKey = stringValue(attributeValue(question, "data-lavish-question")).trim();
1513
+ if (questionKey) return `question:${questionKey}`;
1514
+ const tag = tagName(element);
1515
+ const type = stringValue(attributeValue(element, "type") || element?.type).toLowerCase();
1516
+ const scope = scopeKey(element);
1517
+ if (tag === "input" && type === "radio") {
1518
+ const name = stringValue(attributeValue(element, "name") || element?.name).trim();
1519
+ if (name) return `radio:${scope}:${name}`;
1520
+ return "";
1521
+ }
1522
+ if (tag === "input" && type === "checkbox") {
1523
+ const identity = controlIdentity(element);
1524
+ const explicitValue = stringValue(element?.getAttribute ? element.getAttribute("value") : "").trim();
1525
+ const option = explicitValue || stringValue(attributeValue(element, "id") || elementPath(element)).trim();
1526
+ if (identity) return `checkbox:${scope}:${identity}:${option}`;
1527
+ return "";
1528
+ }
1529
+ if (tag === "select" || tag === "textarea" || tag === "input" && isKeyedInputType(type)) {
1530
+ const identity = controlIdentity(element);
1531
+ if (identity) return `field:${scope}:${identity}`;
1532
+ }
1533
+ return "";
1534
+ }
1535
+ function isNativeInteractiveControl(el) {
1536
+ return !!(el && el.closest && el.closest(
1537
+ "button,input,select,textarea,option,optgroup,label,summary,[contenteditable]:not([contenteditable='false'])"
1538
+ ));
1539
+ }
1540
+ function createArtifactSdk(deriveQueueKey, isNativeInteractive = isNativeInteractiveControl) {
1541
+ let annotationMode = true;
1542
+ let hovered = null;
1543
+ let selected = null;
1544
+ let ignoreNextClick = false;
1545
+ let shadow = null;
1546
+ let counter = 0;
1547
+ const ids = /* @__PURE__ */ new WeakMap();
1548
+ function uid(el) {
1549
+ if (!ids.has(el)) ids.set(el, String(++counter));
1550
+ return ids.get(el);
1551
+ }
1552
+ function selector(el) {
1553
+ if (!el || !el.tagName) return "";
1554
+ const parts = [];
1555
+ let node = el;
1556
+ while (node && node.nodeType === 1 && parts.length < 5) {
1557
+ let part = node.tagName.toLowerCase();
1558
+ if (node.id) {
1559
+ part += "#" + CSS.escape(node.id);
1560
+ parts.unshift(part);
1561
+ break;
1562
+ }
1563
+ const parent2 = node.parentElement;
1564
+ if (parent2) {
1565
+ const same = [...parent2.children].filter((x) => x.tagName === node.tagName);
1566
+ if (same.length > 1) part += ":nth-of-type(" + (same.indexOf(node) + 1) + ")";
1567
+ }
1568
+ parts.unshift(part);
1569
+ node = parent2;
1570
+ }
1571
+ return parts.join(" > ");
1572
+ }
1573
+ function context(el) {
1574
+ return {
1575
+ uid: uid(el),
1576
+ selector: selector(el),
1577
+ tag: (el.tagName || "").toLowerCase(),
1578
+ text: (el.innerText || el.textContent || "").trim().replace(/\s+/g, " ").slice(0, 240)
1579
+ };
1580
+ }
1581
+ function closestElement(node) {
1582
+ if (!node) return document.body;
1583
+ if (node.nodeType === 1) return node;
1584
+ return node.parentElement || document.body;
1585
+ }
1586
+ function nodePath(node, root) {
1587
+ const path6 = [];
1588
+ let current = node;
1589
+ while (current && current !== root) {
1590
+ const parentNode = current.parentNode;
1591
+ if (!parentNode) break;
1592
+ path6.unshift([...parentNode.childNodes].indexOf(current));
1593
+ current = parentNode;
1594
+ }
1595
+ return path6;
1596
+ }
1597
+ function rangeBoundary(node, offset) {
1598
+ const el = closestElement(node);
1599
+ return {
1600
+ selector: selector(el),
1601
+ path: nodePath(node, el),
1602
+ offset: Number(offset) || 0
1603
+ };
1604
+ }
1605
+ function textSelectionContext(selection) {
1606
+ if (!selection || selection.rangeCount === 0) return null;
1607
+ const range = selection.getRangeAt(0);
1608
+ const text = selection.toString().trim().replace(/\s+/g, " ");
1609
+ if (range.collapsed || !text) return null;
1610
+ const ancestor = closestElement(range.commonAncestorContainer);
1611
+ if (isLavishUi(ancestor) || isLavishAction(ancestor) || isInteractiveControl(ancestor)) return null;
1612
+ const commonAncestorSelector = selector(ancestor);
1613
+ const target = {
1614
+ type: "text-range",
1615
+ text,
1616
+ selector: commonAncestorSelector,
1617
+ commonAncestorSelector,
1618
+ start: rangeBoundary(range.startContainer, range.startOffset),
1619
+ end: rangeBoundary(range.endContainer, range.endOffset)
1620
+ };
1621
+ return {
1622
+ uid: "",
1623
+ selector: commonAncestorSelector,
1624
+ tag: "text",
1625
+ text: text.slice(0, 240),
1626
+ target,
1627
+ element: ancestor,
1628
+ range: range.cloneRange()
1629
+ };
1630
+ }
1631
+ function isLavishUi(el) {
1632
+ return !!(el && el.closest && el.closest("[data-lavish-ui]"));
1633
+ }
1634
+ function isLavishAction(el) {
1635
+ return !!(el && el.closest && el.closest("[data-lavish-action]"));
1636
+ }
1637
+ function isInteractiveControl(el) {
1638
+ return isNativeInteractive(el);
1639
+ }
1640
+ function highlightElement(el) {
1641
+ if (!el) return;
1642
+ el.style.outline = "var(--lavish-annotate-outline,2px solid #f4c95d)";
1643
+ el.style.outlineOffset = "var(--lavish-annotate-offset,2px)";
1644
+ }
1645
+ function clearHighlight(el) {
1646
+ if (el) el.style.outline = "";
1647
+ }
1648
+ function clearTextHighlight() {
1649
+ if (!shadow) return;
1650
+ for (const el of [...shadow.querySelectorAll(".lavish-text-highlight")]) el.remove();
1651
+ }
1652
+ function highlightTextRange(range) {
1653
+ clearTextHighlight();
1654
+ const root = ensureShadow();
1655
+ for (const rect of [...range.getClientRects()]) {
1656
+ if (rect.width <= 0 || rect.height <= 0) continue;
1657
+ const mark = document.createElement("div");
1658
+ mark.className = "lavish-text-highlight";
1659
+ mark.style.left = rect.left + "px";
1660
+ mark.style.top = rect.top + "px";
1661
+ mark.style.width = rect.width + "px";
1662
+ mark.style.height = rect.height + "px";
1663
+ root.appendChild(mark);
1664
+ }
1665
+ }
1666
+ function setAnnotationMode(enabled) {
1667
+ annotationMode = !!enabled;
1668
+ let style = document.getElementById("lavish-cursor-style");
1669
+ if (annotationMode && !style) {
1670
+ style = document.createElement("style");
1671
+ style.id = "lavish-cursor-style";
1672
+ style.textContent = ":root{--lavish-accent:#f4c95d;--lavish-annotate-outline:2px solid var(--lavish-accent);--lavish-annotate-offset:2px}*{cursor:default!important}[data-lavish-action],[data-lavish-action] *{cursor:pointer!important}input,textarea,[contenteditable]:not([contenteditable='false']){cursor:text!important}button,select,label,option,input[type='button'],input[type='submit'],input[type='reset'],input[type='checkbox'],input[type='radio'],input[type='file'],input[type='color'],input[type='range'],input[type='image']{cursor:pointer!important}";
1673
+ document.head.appendChild(style);
1674
+ }
1675
+ if (!annotationMode && style) style.remove();
1676
+ if (!annotationMode) closeCard();
1677
+ }
1678
+ function queuePrompt(prompt, options = {}) {
1679
+ const originElement = options.element || document.activeElement || document.body;
1680
+ const item = {
1681
+ ...context(originElement),
1682
+ prompt: String(prompt || "")
1683
+ };
1684
+ const queueKey = typeof deriveQueueKey === "function" ? deriveQueueKey(originElement, options) : "";
1685
+ if (queueKey) item._lavishQueueKey = String(queueKey);
1686
+ if (options.uid) item.uid = String(options.uid);
1687
+ if (options.selector) item.selector = String(options.selector);
1688
+ if (options.tag) item.tag = String(options.tag);
1689
+ if (options.text) item.text = String(options.text);
1690
+ if (options.target) item.target = options.target;
1691
+ if (options.data) item.prompt += "\n\nContext data:\n" + JSON.stringify(options.data, null, 2);
1692
+ parent.postMessage({ type: "lavish:queuePrompt", prompt: item }, "*");
1693
+ }
1694
+ function sendQueuedPrompts() {
1695
+ parent.postMessage({ type: "lavish:sendQueuedPrompts" }, "*");
1696
+ }
1697
+ function endSession() {
1698
+ parent.postMessage({ type: "lavish:endSession" }, "*");
1699
+ }
1700
+ function snapshot() {
1701
+ const lines = [];
1702
+ function walk(el, depth) {
1703
+ if (!(el instanceof Element) || depth > 6 || isLavishUi(el)) return;
1704
+ const c = context(el);
1705
+ const name = c.text ? ' "' + c.text.slice(0, 80).replace(/"/g, "'") + '"' : "";
1706
+ lines.push(" ".repeat(depth) + "uid=" + c.uid + " " + c.tag + name);
1707
+ for (const child of el.children) walk(child, depth + 1);
1708
+ }
1709
+ walk(document.body, 0);
1710
+ return lines.join("\n");
1711
+ }
1712
+ const layoutAuditOverflowEpsilon = 1;
1713
+ const layoutAuditErrorOverflowPx = 4;
1714
+ const layoutAuditSettleMs = 180;
1715
+ const layoutAuditMaxWaitMs = 2e3;
1716
+ let layoutAuditTimer = 0;
1717
+ let layoutAuditRun = 0;
1718
+ let lastLayoutAuditSignature = null;
1719
+ function toPixelNumber(value) {
1720
+ const parsed = Number.parseFloat(String(value || "0"));
1721
+ return Number.isFinite(parsed) ? parsed : 0;
1722
+ }
1723
+ function roundedOverflowPx(value) {
1724
+ return Math.round(Math.max(0, value) * 10) / 10;
1725
+ }
1726
+ function overflowSeverity(overflowPx) {
1727
+ return overflowPx > layoutAuditErrorOverflowPx ? "error" : "warning";
1728
+ }
1729
+ function elementText(el) {
1730
+ return String(el?.innerText || el?.textContent || "").trim().replace(/\s+/g, " ");
1731
+ }
1732
+ function hasReadableText(el) {
1733
+ return elementText(el).length > 0;
1734
+ }
1735
+ function rectArea(rect) {
1736
+ return Math.max(0, rect.width) * Math.max(0, rect.height);
1737
+ }
1738
+ function intersectionArea(a, b) {
1739
+ const width = Math.max(0, Math.min(a.right, b.right) - Math.max(a.left, b.left));
1740
+ const height = Math.max(0, Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top));
1741
+ return width * height;
1742
+ }
1743
+ function isVisibleForLayoutAudit(el, rect = el.getBoundingClientRect()) {
1744
+ if (!el || isLavishUi(el) || rect.width <= 0 || rect.height <= 0) return false;
1745
+ const style = getComputedStyle(el);
1746
+ return style.display !== "none" && style.visibility !== "hidden" && style.opacity !== "0";
1747
+ }
1748
+ function isIntentionalHorizontalScroller(el) {
1749
+ if (!el || el === document.body || el === document.documentElement) return false;
1750
+ const overflowX = getComputedStyle(el).overflowX;
1751
+ return overflowX === "auto" || overflowX === "scroll";
1752
+ }
1753
+ function hasIntentionalHorizontalScrollerAncestor(el) {
1754
+ let node = el;
1755
+ while (node && node.nodeType === 1 && node !== document.body && node !== document.documentElement) {
1756
+ if (isIntentionalHorizontalScroller(node)) return true;
1757
+ node = node.parentElement;
1758
+ }
1759
+ return false;
1760
+ }
1761
+ function contentBoxRect(el) {
1762
+ const rect = el.getBoundingClientRect();
1763
+ const style = getComputedStyle(el);
1764
+ const borderLeft = toPixelNumber(style.borderLeftWidth);
1765
+ const borderRight = toPixelNumber(style.borderRightWidth);
1766
+ const borderTop = toPixelNumber(style.borderTopWidth);
1767
+ const borderBottom = toPixelNumber(style.borderBottomWidth);
1768
+ const paddingLeft = toPixelNumber(style.paddingLeft);
1769
+ const paddingRight = toPixelNumber(style.paddingRight);
1770
+ const paddingTop = toPixelNumber(style.paddingTop);
1771
+ const paddingBottom = toPixelNumber(style.paddingBottom);
1772
+ return {
1773
+ left: rect.left + borderLeft + paddingLeft,
1774
+ right: rect.right - borderRight - paddingRight,
1775
+ top: rect.top + borderTop + paddingTop,
1776
+ bottom: rect.bottom - borderBottom - paddingBottom
1777
+ };
1778
+ }
1779
+ function collectLayoutAuditElements() {
1780
+ const elements = [];
1781
+ function walk(el) {
1782
+ if (!(el instanceof Element) || isLavishUi(el)) return;
1783
+ if (isIntentionalHorizontalScroller(el)) return;
1784
+ elements.push(el);
1785
+ for (const child of el.children) walk(child);
1786
+ }
1787
+ if (document.body) walk(document.body);
1788
+ return elements;
1789
+ }
1790
+ function pushLayoutFinding(findings, seen, finding) {
1791
+ const selectorValue = finding.selector || "";
1792
+ const key = `${finding.kind}:${selectorValue}`;
1793
+ if (seen.has(key)) return;
1794
+ seen.add(key);
1795
+ findings.push({
1796
+ selector: selectorValue,
1797
+ kind: String(finding.kind || "layout-warning"),
1798
+ overflowPx: roundedOverflowPx(finding.overflowPx),
1799
+ viewportWidth: Math.round(Number(finding.viewportWidth) || window.innerWidth || 0),
1800
+ severity: finding.severity === "warning" ? "warning" : "error"
1801
+ });
1802
+ }
1803
+ function isIntentionalTextTruncation(style) {
1804
+ return style.textOverflow === "ellipsis" || Number.parseInt(style.webkitLineClamp || "0", 10) > 0;
1805
+ }
1806
+ function auditElementOverflow(el, viewportWidth, findings, seen) {
1807
+ if (el === document.body || el === document.documentElement || hasIntentionalHorizontalScrollerAncestor(el)) return;
1808
+ const rect = el.getBoundingClientRect();
1809
+ if (!isVisibleForLayoutAudit(el, rect)) return;
1810
+ const style = getComputedStyle(el);
1811
+ const scrollOverflowPx = el.clientWidth > 0 ? el.scrollWidth - el.clientWidth : 0;
1812
+ if (scrollOverflowPx > layoutAuditOverflowEpsilon) {
1813
+ const clipsText = hasReadableText(el) && (style.overflowX === "hidden" || style.overflowX === "clip") && !isIntentionalTextTruncation(style);
1814
+ pushLayoutFinding(findings, seen, {
1815
+ selector: selector(el),
1816
+ kind: clipsText ? "clipped-text" : "element-scroll-overflow",
1817
+ overflowPx: scrollOverflowPx,
1818
+ viewportWidth,
1819
+ severity: clipsText ? "error" : overflowSeverity(scrollOverflowPx)
1820
+ });
1821
+ }
1822
+ const verticalClipPx = el.clientHeight > 0 ? el.scrollHeight - el.clientHeight : 0;
1823
+ if (verticalClipPx > layoutAuditOverflowEpsilon && hasReadableText(el) && (style.overflowY === "hidden" || style.overflowY === "clip") && !isIntentionalTextTruncation(style)) {
1824
+ pushLayoutFinding(findings, seen, {
1825
+ selector: selector(el),
1826
+ kind: "clipped-text",
1827
+ overflowPx: verticalClipPx,
1828
+ viewportWidth,
1829
+ severity: "error"
1830
+ });
1831
+ }
1832
+ const parent2 = el.parentElement;
1833
+ if (!parent2 || parent2 === document.body || parent2 === document.documentElement) return;
1834
+ if (hasIntentionalHorizontalScrollerAncestor(parent2)) return;
1835
+ const parentBox = contentBoxRect(parent2);
1836
+ const parentOverflowPx = rect.right - parentBox.right;
1837
+ if (parentOverflowPx > layoutAuditOverflowEpsilon && rectArea(rect) > 1) {
1838
+ const positionedOffCanvas = style.position === "absolute" || style.position === "fixed" || style.position === "sticky";
1839
+ pushLayoutFinding(findings, seen, {
1840
+ selector: selector(el),
1841
+ kind: "element-parent-overflow",
1842
+ overflowPx: parentOverflowPx,
1843
+ viewportWidth,
1844
+ severity: positionedOffCanvas ? "warning" : overflowSeverity(parentOverflowPx)
1845
+ });
1846
+ }
1847
+ }
1848
+ function auditOverlappingText(elements, viewportWidth, findings, seen) {
1849
+ const candidates = elements.filter((el) => el.children.length === 0 && hasReadableText(el)).filter((el) => isVisibleForLayoutAudit(el)).slice(0, 200);
1850
+ for (const el of candidates) {
1851
+ const rect = el.getBoundingClientRect();
1852
+ if (rectArea(rect) < 16) continue;
1853
+ const points = [
1854
+ { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 },
1855
+ { x: rect.left + Math.min(4, rect.width / 2), y: rect.top + Math.min(4, rect.height / 2) },
1856
+ { x: rect.right - Math.min(4, rect.width / 2), y: rect.bottom - Math.min(4, rect.height / 2) }
1857
+ ];
1858
+ for (const point of points) {
1859
+ if (point.x < 0 || point.y < 0 || point.x > viewportWidth || point.y > window.innerHeight) continue;
1860
+ const top = document.elementFromPoint(point.x, point.y);
1861
+ if (!(top instanceof Element) || top === el || el.contains(top) || top.contains(el) || isLavishUi(top))
1862
+ continue;
1863
+ if (hasIntentionalHorizontalScrollerAncestor(top)) continue;
1864
+ const elPosition = getComputedStyle(el).position;
1865
+ const topPosition = getComputedStyle(top).position;
1866
+ if (elPosition !== "static" || topPosition !== "static") continue;
1867
+ const topRect = top.getBoundingClientRect();
1868
+ const overlapArea = intersectionArea(rect, topRect);
1869
+ if (overlapArea < Math.min(rectArea(rect) * 0.25, 24)) continue;
1870
+ pushLayoutFinding(findings, seen, {
1871
+ selector: selector(el),
1872
+ kind: "overlapping-text",
1873
+ overflowPx: 0,
1874
+ viewportWidth,
1875
+ severity: "error"
1876
+ });
1877
+ break;
1878
+ }
1879
+ }
1880
+ }
1881
+ function auditLayout() {
1882
+ const viewportWidth = window.innerWidth || document.documentElement.clientWidth || 0;
1883
+ const findings = [];
1884
+ const seen = /* @__PURE__ */ new Set();
1885
+ const pageOverflowPx = document.documentElement.scrollWidth - viewportWidth;
1886
+ if (pageOverflowPx > layoutAuditOverflowEpsilon) {
1887
+ pushLayoutFinding(findings, seen, {
1888
+ selector: "html",
1889
+ kind: "page-horizontal-overflow",
1890
+ overflowPx: pageOverflowPx,
1891
+ viewportWidth,
1892
+ severity: overflowSeverity(pageOverflowPx)
1893
+ });
1894
+ }
1895
+ const elements = collectLayoutAuditElements();
1896
+ for (const el of elements) auditElementOverflow(el, viewportWidth, findings, seen);
1897
+ auditOverlappingText(elements, viewportWidth, findings, seen);
1898
+ return findings;
1899
+ }
1900
+ function waitForDocumentFontsReady() {
1901
+ try {
1902
+ if (document.fonts?.ready) return document.fonts.ready.catch(() => {
1903
+ });
1904
+ } catch {
1905
+ }
1906
+ return Promise.resolve();
1907
+ }
1908
+ function waitForAnimationFrames(count) {
1909
+ return new Promise((resolve) => {
1910
+ function step(remaining) {
1911
+ if (remaining <= 0) {
1912
+ resolve();
1913
+ return;
1914
+ }
1915
+ const next = () => step(remaining - 1);
1916
+ if (window.requestAnimationFrame) {
1917
+ window.requestAnimationFrame(next);
1918
+ } else {
1919
+ window.setTimeout(next, 16);
1920
+ }
1921
+ }
1922
+ step(count);
1923
+ });
1924
+ }
1925
+ function waitForResizeObserverSettle() {
1926
+ return new Promise((resolve) => {
1927
+ let observer = null;
1928
+ let settleTimer = 0;
1929
+ let maxTimer = 0;
1930
+ let done = false;
1931
+ const finish = () => {
1932
+ if (done) return;
1933
+ done = true;
1934
+ if (settleTimer) window.clearTimeout(settleTimer);
1935
+ if (maxTimer) window.clearTimeout(maxTimer);
1936
+ if (observer) observer.disconnect();
1937
+ resolve();
1938
+ };
1939
+ const scheduleFinish = () => {
1940
+ if (settleTimer) window.clearTimeout(settleTimer);
1941
+ settleTimer = window.setTimeout(finish, layoutAuditSettleMs);
1942
+ };
1943
+ if (typeof ResizeObserver !== "undefined") {
1944
+ observer = new ResizeObserver(scheduleFinish);
1945
+ const observed = [document.documentElement, document.body, ...[...document.body?.querySelectorAll("*") || []]].filter(Boolean).slice(0, 800);
1946
+ for (const el of observed) observer.observe(el);
1947
+ }
1948
+ scheduleFinish();
1949
+ maxTimer = window.setTimeout(finish, layoutAuditMaxWaitMs);
1950
+ });
1951
+ }
1952
+ async function runLayoutAudit(runId) {
1953
+ await waitForDocumentFontsReady();
1954
+ await waitForResizeObserverSettle();
1955
+ await waitForAnimationFrames(2);
1956
+ if (runId !== layoutAuditRun) return;
1957
+ const layout_warnings = auditLayout();
1958
+ const signature = JSON.stringify(layout_warnings);
1959
+ if (signature === lastLayoutAuditSignature) return;
1960
+ lastLayoutAuditSignature = signature;
1961
+ parent.postMessage({ type: "lavish:layoutWarnings", layout_warnings }, "*");
1962
+ }
1963
+ function scheduleLayoutAudit() {
1964
+ if (layoutAuditTimer) window.clearTimeout(layoutAuditTimer);
1965
+ const runId = ++layoutAuditRun;
1966
+ layoutAuditTimer = window.setTimeout(() => {
1967
+ runLayoutAudit(runId).catch(() => {
1968
+ });
1969
+ }, 50);
1970
+ }
1971
+ function startLayoutAudit() {
1972
+ scheduleLayoutAudit();
1973
+ window.addEventListener("load", scheduleLayoutAudit, { once: true });
1974
+ window.addEventListener("resize", scheduleLayoutAudit, { passive: true });
1975
+ }
1976
+ function ensureShadow() {
1977
+ if (shadow) return shadow;
1978
+ const host = document.createElement("div");
1979
+ host.className = "lavish-annotation-root";
1980
+ host.setAttribute("data-lavish-ui", "annotation-root");
1981
+ document.documentElement.appendChild(host);
1982
+ shadow = host.attachShadow({ mode: "open" });
1983
+ const style = document.createElement("style");
1984
+ style.textContent = `:host{all:initial;position:fixed;z-index:2147483647;left:0;top:0;color-scheme:dark;--ink-900:#0f1115;--ink-800:#11141a;--ink-700:#171a21;--ink-600:#1c212b;--steel-700:#2a2f3a;--steel-600:#303745;--steel-500:#3c4557;--steel-400:#8c96aa;--steel-300:#aeb6c6;--steel-200:#b9c0cf;--steel-100:#d8deea;--cream-50:#fffbf3;--cream-100:#f7f3ea;--cream-200:#e8e1cf;--brass-500:#f4c95d;--brass-400:#ffd877;--brass-ink:#17130a;--bg:var(--ink-900);--bg-panel:var(--ink-800);--bg-elevated:var(--ink-600);--fg:var(--cream-100);--fg-faint:var(--steel-300);--border:var(--steel-600);--accent:#f4c95d;--accent-hover:#ffd877;--font-sans:Geist,ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;--font-mono:"Geist Mono",ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;--radius-md:10px;--radius-xl:14px;--shadow-floating:0 20px 70px rgba(0,0,0,.35);font-family:var(--font-sans)}*{box-sizing:border-box}:focus-visible{outline:2px solid var(--accent);outline-offset:2px}.lavish-text-highlight{position:fixed;pointer-events:none;background:rgba(244,201,93,.28);border-radius:2px;box-shadow:0 0 0 1px rgba(244,201,93,.45)}.lavish-annotation-card{position:fixed;width:min(320px,calc(100vw - 24px));padding:12px;border-radius:var(--radius-xl);background:var(--bg-panel);color:var(--fg);border:1px solid var(--accent);box-shadow:var(--shadow-floating);font:14px/1.4 var(--font-sans)}.lavish-heading{font-weight:700;margin-bottom:6px}.lavish-annotation-card textarea{width:100%;min-height:86px;resize:vertical;border-radius:var(--radius-md);border:1px solid var(--border);background:var(--bg);color:var(--fg);padding:9px;font:inherit;font-family:var(--font-sans)}.lavish-annotation-card textarea::placeholder{color:var(--fg-faint)}.lavish-annotation-card .lavish-hint{margin-top:6px;font-size:11px;color:var(--fg-faint)}.lavish-annotation-card .lavish-row{display:flex;gap:8px;justify-content:flex-end;margin-top:8px}.lavish-annotation-card button{border:0;border-radius:var(--radius-md);padding:8px 10px;font-family:var(--font-sans);font-size:13px;font-weight:700;cursor:pointer}.lavish-annotation-card button:active{opacity:.85}.lavish-annotation-card .lavish-send{background:var(--accent);color:var(--brass-ink)}.lavish-annotation-card .lavish-send:hover{background:var(--accent-hover)}.lavish-annotation-card .lavish-cancel{background:var(--steel-700);color:var(--fg)}`;
1985
+ shadow.appendChild(style);
1986
+ return shadow;
1987
+ }
1988
+ function closeCard() {
1989
+ if (shadow) {
1990
+ for (const el of [...shadow.querySelectorAll(".lavish-annotation-card")]) el.remove();
1991
+ }
1992
+ clearHighlight(hovered);
1993
+ clearHighlight(selected);
1994
+ hovered = null;
1995
+ clearTextHighlight();
1996
+ selected = null;
1997
+ }
1998
+ function showAnnotationCard(target, options = {}) {
1999
+ const root = ensureShadow();
2000
+ closeCard();
2001
+ const c = options.context || context(target);
2002
+ if (options.range) {
2003
+ highlightTextRange(options.range);
2004
+ } else {
2005
+ selected = target;
2006
+ highlightElement(selected);
2007
+ }
2008
+ const rect = options.range ? options.range.getBoundingClientRect() : target.getBoundingClientRect();
2009
+ const card = document.createElement("div");
2010
+ card.className = "lavish-annotation-card";
2011
+ const heading = c.tag === "text" ? "Annotate text" : "Annotate &lt;" + c.tag + "&gt;";
2012
+ const placeholder = c.tag === "text" ? "Tell the agent what to change about this text..." : "Tell the agent what to change about this element...";
2013
+ card.innerHTML = '<div class="lavish-heading">' + heading + '</div><textarea placeholder="' + placeholder + '"></textarea><div class="lavish-hint">Enter to queue &middot; ' + (/Mac|iP(hone|ad|od)/.test(navigator.platform) ? "\u2318" : "Ctrl") + '+Enter to send now</div><div class="lavish-row"><button class="lavish-cancel" type="button">Cancel</button><button class="lavish-send" type="button">Queue</button></div>';
2014
+ root.appendChild(card);
2015
+ const left = Math.min(Math.max(12, rect.left), window.innerWidth - card.offsetWidth - 12);
2016
+ const top = Math.min(Math.max(12, rect.bottom + 8), window.innerHeight - card.offsetHeight - 12);
2017
+ card.style.left = left + "px";
2018
+ card.style.top = top + "px";
2019
+ const textarea = (
2020
+ /** @type {HTMLTextAreaElement | null} */
2021
+ card.querySelector("textarea")
2022
+ );
2023
+ const cancelButton = (
2024
+ /** @type {HTMLButtonElement | null} */
2025
+ card.querySelector(".lavish-cancel")
2026
+ );
2027
+ const sendButton = (
2028
+ /** @type {HTMLButtonElement | null} */
2029
+ card.querySelector(".lavish-send")
2030
+ );
2031
+ if (!textarea || !cancelButton || !sendButton) return;
2032
+ cancelButton.onclick = closeCard;
2033
+ sendButton.onclick = () => {
2034
+ const prompt = textarea.value.trim();
2035
+ if (prompt) queuePrompt(prompt, { ...c, queueKey: "" });
2036
+ closeCard();
2037
+ };
2038
+ textarea.addEventListener("keydown", (event) => {
2039
+ if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
2040
+ event.preventDefault();
2041
+ const sendNow = (event.ctrlKey || event.metaKey) && !!textarea.value.trim();
2042
+ sendButton.click();
2043
+ if (sendNow) sendQueuedPrompts();
2044
+ }
2045
+ });
2046
+ setTimeout(() => textarea.focus(), 0);
2047
+ }
2048
+ window.lavish = {
2049
+ queuePrompt,
2050
+ sendQueuedPrompts,
2051
+ endSession,
2052
+ getQueuedPrompts: () => [],
2053
+ setStatus: (message) => parent.postMessage({ type: "lavish:status", message: String(message) }, "*"),
2054
+ snapshot
2055
+ };
2056
+ window.addEventListener("message", (event) => {
2057
+ const msg = event.data || {};
2058
+ if (msg.type === "lavish:setAnnotationMode") setAnnotationMode(msg.enabled);
2059
+ if (msg.type === "lavish:requestSnapshot") {
2060
+ parent.postMessage({ type: "lavish:snapshot", snapshot: snapshot() }, "*");
2061
+ }
2062
+ if (msg.type === "lavish:restoreScroll") {
2063
+ window.scrollTo(Number(msg.x) || 0, Number(msg.y) || 0);
2064
+ }
2065
+ });
2066
+ let scrollFrame = 0;
2067
+ window.addEventListener(
2068
+ "scroll",
2069
+ () => {
2070
+ if (scrollFrame) return;
2071
+ scrollFrame = window.requestAnimationFrame(() => {
2072
+ scrollFrame = 0;
2073
+ parent.postMessage({ type: "lavish:scroll", x: window.scrollX, y: window.scrollY }, "*");
2074
+ });
2075
+ },
2076
+ { passive: true }
2077
+ );
2078
+ document.addEventListener(
2079
+ "mouseover",
2080
+ (event) => {
2081
+ if (!annotationMode || isLavishUi(event.target) || isLavishAction(event.target) || isInteractiveControl(event.target))
2082
+ return;
2083
+ if (event.target === selected) return;
2084
+ if (hovered && hovered !== selected) clearHighlight(hovered);
2085
+ hovered = event.target;
2086
+ highlightElement(hovered);
2087
+ },
2088
+ true
2089
+ );
2090
+ document.addEventListener(
2091
+ "mouseout",
2092
+ () => {
2093
+ if (hovered && hovered !== selected) {
2094
+ clearHighlight(hovered);
2095
+ hovered = null;
2096
+ }
2097
+ },
2098
+ true
2099
+ );
2100
+ document.addEventListener(
2101
+ "mouseup",
2102
+ (event) => {
2103
+ if (!annotationMode || isLavishUi(event.target) || isLavishAction(event.target) || isInteractiveControl(event.target))
2104
+ return;
2105
+ const c = textSelectionContext(document.getSelection());
2106
+ if (!c) return;
2107
+ ignoreNextClick = true;
2108
+ showAnnotationCard(c.element, { context: c, range: c.range });
2109
+ },
2110
+ true
2111
+ );
2112
+ document.addEventListener(
2113
+ "click",
2114
+ (event) => {
2115
+ if (!annotationMode || isLavishUi(event.target) || isLavishAction(event.target) || isInteractiveControl(event.target))
2116
+ return;
2117
+ event.preventDefault();
2118
+ event.stopPropagation();
2119
+ if (ignoreNextClick) {
2120
+ ignoreNextClick = false;
2121
+ return;
2122
+ }
2123
+ showAnnotationCard(event.target);
2124
+ },
2125
+ true
2126
+ );
2127
+ setAnnotationMode(annotationMode);
2128
+ if (document.readyState === "loading") {
2129
+ document.addEventListener("DOMContentLoaded", startLayoutAudit, { once: true });
2130
+ } else {
2131
+ startLayoutAudit();
2132
+ }
2133
+ }
2134
+
2135
+ // src/html-transform.js
2136
+ function injectLavishSdk(html, key) {
2137
+ const script = `<script src="/sdk.js?key=${encodeURIComponent(key)}"></script>`;
2138
+ if (/<\/body\s*>/i.test(html)) {
2139
+ return html.replace(/<\/body\s*>/i, `${script}</body>`);
2140
+ }
2141
+ return `${html}
2142
+ ${script}`;
2143
+ }
2144
+
2145
+ // src/session-store.js
2146
+ import crypto from "node:crypto";
2147
+ import { readFile, realpath, writeFile } from "node:fs/promises";
2148
+ import path3 from "node:path";
2149
+ var SessionStore = class {
2150
+ constructor(file) {
2151
+ this.file = file;
2152
+ }
2153
+ async listSessions() {
2154
+ const state = await this.readState();
2155
+ return Object.values(state.sessions).sort((a, b) => a.file.localeCompare(b.file));
2156
+ }
2157
+ async findByFile(file) {
2158
+ const absolute = await canonicalFile(file);
2159
+ const state = await this.readState();
2160
+ return state.sessions[sessionKey(absolute)] || null;
2161
+ }
2162
+ async findByKey(key) {
2163
+ const state = await this.readState();
2164
+ return state.sessions[key] || null;
2165
+ }
2166
+ async upsertSession(file, url) {
2167
+ const absolute = await canonicalFile(file);
2168
+ const key = sessionKey(absolute);
2169
+ const state = await this.readState();
2170
+ const existing = state.sessions[key] || {};
2171
+ const existingPrompts = existing.prompts || [];
2172
+ const existingStatus = existing.status === "ended" ? "open" : existing.status || "open";
2173
+ const session = {
2174
+ key,
2175
+ file: absolute,
2176
+ url,
2177
+ status: existingStatus === "feedback" && existingPrompts.length === 0 ? "open" : existingStatus,
2178
+ pending_prompts: existing.pending_prompts || 0,
2179
+ prompts: existingPrompts,
2180
+ layout_warnings: [],
2181
+ dom_snapshot: existing.dom_snapshot || "",
2182
+ chat: existing.chat || [],
2183
+ updated_at: (/* @__PURE__ */ new Date()).toISOString()
2184
+ };
2185
+ state.sessions[key] = session;
2186
+ await this.writeState(state);
2187
+ return session;
2188
+ }
2189
+ async queuePrompts(key, payload) {
2190
+ const state = await this.readState();
2191
+ const session = state.sessions[key];
2192
+ if (!session) {
2193
+ return null;
2194
+ }
2195
+ const prompts = Array.isArray(payload.prompts) ? payload.prompts : [];
2196
+ const normalizedPrompts = prompts.map(normalizePrompt);
2197
+ const userMessages = normalizedPrompts.filter((prompt) => prompt.tag === "message" && prompt.prompt).map((prompt) => ({ role: "user", text: prompt.prompt, at: (/* @__PURE__ */ new Date()).toISOString() }));
2198
+ session.prompts = [...session.prompts || [], ...normalizedPrompts];
2199
+ session.chat = [...session.chat || [], ...userMessages];
2200
+ session.pending_prompts = session.prompts.length;
2201
+ session.dom_snapshot = String(payload.domSnapshot || payload.dom_snapshot || "");
2202
+ session.status = "feedback";
2203
+ session.updated_at = (/* @__PURE__ */ new Date()).toISOString();
2204
+ await this.writeState(state);
2205
+ return session;
2206
+ }
2207
+ async recordLayoutWarnings(key, payload) {
2208
+ const state = await this.readState();
2209
+ const session = state.sessions[key];
2210
+ if (!session) {
2211
+ return null;
2212
+ }
2213
+ const layoutWarnings = normalizeLayoutWarnings(payload.layout_warnings || payload.layoutWarnings || []);
2214
+ const previousSignature = JSON.stringify(session.layout_warnings || []);
2215
+ const nextSignature = JSON.stringify(layoutWarnings);
2216
+ if (previousSignature === nextSignature) {
2217
+ return { session, changed: false, hasWarnings: layoutWarnings.length > 0 };
2218
+ }
2219
+ session.layout_warnings = layoutWarnings;
2220
+ if (layoutWarnings.length > 0 && session.status !== "ended") {
2221
+ session.status = "feedback";
2222
+ } else if ((session.prompts || []).length === 0 && session.status !== "ended") {
2223
+ session.status = "open";
2224
+ }
2225
+ session.updated_at = (/* @__PURE__ */ new Date()).toISOString();
2226
+ await this.writeState(state);
2227
+ return { session, changed: true, hasWarnings: layoutWarnings.length > 0 };
2228
+ }
2229
+ async takeFeedback(key) {
2230
+ const state = await this.readState();
2231
+ const session = state.sessions[key];
2232
+ if (!session) {
2233
+ return { status: "missing" };
2234
+ }
2235
+ const prompts = session.prompts || [];
2236
+ const layoutWarnings = session.layout_warnings || [];
2237
+ if (prompts.length === 0 && layoutWarnings.length === 0) {
2238
+ return session.status === "ended" ? { status: "ended" } : { status: "waiting" };
2239
+ }
2240
+ const result = {
2241
+ status: "feedback",
2242
+ dom_snapshot: session.dom_snapshot || "",
2243
+ prompts,
2244
+ ...layoutWarnings.length > 0 ? { layout_warnings: layoutWarnings } : {}
2245
+ };
2246
+ session.prompts = [];
2247
+ session.layout_warnings = [];
2248
+ session.pending_prompts = 0;
2249
+ session.dom_snapshot = "";
2250
+ if (session.status !== "ended") {
2251
+ session.status = "open";
2252
+ }
2253
+ session.updated_at = (/* @__PURE__ */ new Date()).toISOString();
2254
+ await this.writeState(state);
2255
+ return result;
2256
+ }
2257
+ async endSession(key) {
2258
+ const state = await this.readState();
2259
+ const session = state.sessions[key];
2260
+ if (!session) {
2261
+ return null;
2262
+ }
2263
+ session.status = "ended";
2264
+ session.updated_at = (/* @__PURE__ */ new Date()).toISOString();
2265
+ await this.writeState(state);
2266
+ return session;
2267
+ }
2268
+ async addAgentReply(key, text) {
2269
+ const state = await this.readState();
2270
+ const session = state.sessions[key];
2271
+ if (!session) {
2272
+ return null;
2273
+ }
2274
+ session.chat = [...session.chat || [], { role: "agent", text: String(text || ""), at: (/* @__PURE__ */ new Date()).toISOString() }];
2275
+ session.updated_at = (/* @__PURE__ */ new Date()).toISOString();
2276
+ await this.writeState(state);
2277
+ return session;
2278
+ }
2279
+ async readState() {
2280
+ try {
2281
+ const raw = await readFile(this.file, "utf8");
2282
+ const parsed = JSON.parse(raw);
2283
+ return { sessions: parsed.sessions || {} };
2284
+ } catch (error) {
2285
+ if (error && error.code === "ENOENT") {
2286
+ return { sessions: {} };
2287
+ }
2288
+ throw error;
2289
+ }
2290
+ }
2291
+ async writeState(state) {
2292
+ await writeFile(this.file, `${JSON.stringify(state, null, 2)}
2293
+ `);
2294
+ }
2295
+ };
2296
+ async function canonicalFile(file) {
2297
+ const absolute = path3.resolve(file);
2298
+ return realpath(absolute);
2299
+ }
2300
+ function sessionKey(file) {
2301
+ return crypto.createHash("sha256").update(file).digest("hex").slice(0, 16);
2302
+ }
2303
+ function normalizePrompt(prompt) {
2304
+ const normalized = {
2305
+ uid: String(prompt.uid || ""),
2306
+ prompt: String(prompt.prompt || ""),
2307
+ selector: String(prompt.selector || ""),
2308
+ tag: String(prompt.tag || ""),
2309
+ text: String(prompt.text || "")
2310
+ };
2311
+ const target = normalizeTarget(prompt.target);
2312
+ if (target) normalized.target = target;
2313
+ return normalized;
2314
+ }
2315
+ function normalizeLayoutWarnings(layoutWarnings) {
2316
+ if (!Array.isArray(layoutWarnings)) return [];
2317
+ return layoutWarnings.filter((warning) => warning && typeof warning === "object" && !Array.isArray(warning)).map((warning) => ({
2318
+ selector: String(warning.selector || ""),
2319
+ kind: String(warning.kind || "layout-warning"),
2320
+ overflowPx: normalizeFiniteNumber(warning.overflowPx),
2321
+ viewportWidth: normalizeFiniteNumber(warning.viewportWidth),
2322
+ severity: warning.severity === "warning" ? "warning" : "error"
2323
+ }));
2324
+ }
2325
+ function normalizeFiniteNumber(value) {
2326
+ const number = Number(value);
2327
+ return Number.isFinite(number) ? number : 0;
2328
+ }
2329
+ function normalizeTarget(target) {
2330
+ if (!target || typeof target !== "object" || Array.isArray(target)) return null;
2331
+ return JSON.parse(JSON.stringify(target));
2332
+ }
2333
+
2334
+ // src/server.js
2335
+ var chromeClientUrl = new URL("./chrome-client.js", import.meta.url);
2336
+ var chromeCssUrl = new URL("./chrome.css", import.meta.url);
2337
+ var designAssetUrls = {
2338
+ "daisyui.css": {
2339
+ packaged: new URL("./design/daisyui.css", import.meta.url),
2340
+ source: new URL("../node_modules/daisyui/daisyui.css", import.meta.url),
2341
+ type: "text/css"
2342
+ },
2343
+ "daisyui-themes.css": {
2344
+ packaged: new URL("./design/daisyui-themes.css", import.meta.url),
2345
+ source: new URL("../node_modules/daisyui/themes.css", import.meta.url),
2346
+ type: "text/css"
2347
+ },
2348
+ "tailwindcss-browser.js": {
2349
+ packaged: new URL("./design/tailwindcss-browser.js", import.meta.url),
2350
+ source: new URL("../node_modules/@tailwindcss/browser/dist/index.global.js", import.meta.url),
2351
+ type: "application/javascript"
2352
+ }
2353
+ };
2354
+ var DEFAULT_IDLE_TIMEOUT_MS = 30 * 6e4;
2355
+ function resolveIdleTimeoutMs(env = process.env) {
2356
+ const raw = env.LAVISH_AXI_IDLE_TIMEOUT_MS?.trim();
2357
+ if (raw === void 0 || raw === "") return DEFAULT_IDLE_TIMEOUT_MS;
2358
+ if (raw === "0" || raw.toLowerCase() === "off") return null;
2359
+ const value = Number(raw);
2360
+ if (!Number.isFinite(value) || value <= 0) return DEFAULT_IDLE_TIMEOUT_MS;
2361
+ return value;
2362
+ }
2363
+ async function serve({
2364
+ port,
2365
+ stateFile: stateFile2,
2366
+ version = "",
2367
+ debug = false,
2368
+ log = null,
2369
+ pollHeartbeatMs = 15e3,
2370
+ idleTimeoutMs = resolveIdleTimeoutMs(),
2371
+ host = bindHost(),
2372
+ linkHost: linkHostName = linkHost()
2373
+ }) {
2374
+ const app = express();
2375
+ const store = new SessionStore(stateFile2);
2376
+ const events = new EventEmitter();
2377
+ const watchers = /* @__PURE__ */ new Map();
2378
+ const activePolls = /* @__PURE__ */ new Map();
2379
+ const deliveredFeedback = /* @__PURE__ */ new Set();
2380
+ const sseClients = /* @__PURE__ */ new Set();
2381
+ const verbose = debug || process.env.LAVISH_AXI_DEBUG === "1";
2382
+ const writeLog = typeof log === "function" ? log : (line) => process.stderr.write(`${line}
2383
+ `);
2384
+ const logEvent = verbose ? (line) => writeLog(`[lavish] ${line}`) : null;
2385
+ let publicPort = port;
2386
+ app.use(express.json({ limit: "2mb" }));
2387
+ app.get("/health", (req, res) => {
2388
+ res.json({ ok: true, app: "lavish-axi", version });
2389
+ });
2390
+ let shutdownResolve;
2391
+ const done = new Promise((resolve) => {
2392
+ shutdownResolve = resolve;
2393
+ });
2394
+ app.post("/shutdown", (req, res) => {
2395
+ res.json({ status: "shutting-down" });
2396
+ setImmediate(shutdown);
2397
+ });
2398
+ app.post("/api/sessions", async (req, res, next) => {
2399
+ try {
2400
+ const file = await canonicalFile(req.body.file);
2401
+ const key = sessionKey(file);
2402
+ const sessionUrl = `http://${hostForUrl(linkHostName)}:${publicPort}/session/${key}`;
2403
+ const url = shouldDisableLayoutGateOpen(req.body || {}) ? appendNoGateParam(sessionUrl) : sessionUrl;
2404
+ const existing = await store.findByKey(key);
2405
+ const session = await store.upsertSession(file, sessionUrl);
2406
+ if (existing?.status === "ended") {
2407
+ clearFeedbackDelivery(key, activePolls, deliveredFeedback, events);
2408
+ }
2409
+ logEvent?.(`session opened key=${key} file=${file}`);
2410
+ await watchSession(session, watchers, events, logEvent);
2411
+ res.json({ key, file, url, status: "opened" });
2412
+ } catch (error) {
2413
+ next(error);
2414
+ }
2415
+ });
2416
+ app.get("/api/poll", async (req, res, next) => {
2417
+ try {
2418
+ let handleRespondError = function(error) {
2419
+ if (streamHeartbeat) {
2420
+ cleanup();
2421
+ if (!res.writableEnded) res.destroy(error);
2422
+ return;
2423
+ }
2424
+ next(error);
2425
+ };
2426
+ const file = await canonicalFile(String(req.query.file || ""));
2427
+ const key = sessionKey(file);
2428
+ const timeoutMs = req.query.timeoutMs === void 0 ? null : Math.max(0, Math.min(Number(req.query.timeoutMs || 0), 2147483647));
2429
+ const immediate = await store.takeFeedback(key);
2430
+ if (immediate.status !== "waiting") {
2431
+ if (immediate.status === "feedback") markFeedbackDelivered(key, activePolls, deliveredFeedback, events);
2432
+ res.json(immediate);
2433
+ return;
2434
+ }
2435
+ const streamHeartbeat = timeoutMs === null;
2436
+ let heartbeat = null;
2437
+ if (streamHeartbeat) {
2438
+ res.status(200).type("application/json");
2439
+ res.write(" ");
2440
+ heartbeat = setInterval(() => {
2441
+ if (!res.writableEnded) res.write(" ");
2442
+ }, pollHeartbeatMs);
2443
+ heartbeat.unref?.();
2444
+ }
2445
+ setPollActive(key, activePolls, deliveredFeedback, events, true);
2446
+ refreshIdleTimer();
2447
+ const timer = timeoutMs === null ? null : setTimeout(() => respond().catch(handleRespondError), timeoutMs);
2448
+ let cleaned = false;
2449
+ let responding = false;
2450
+ const cleanup = () => {
2451
+ if (cleaned) return;
2452
+ cleaned = true;
2453
+ if (timer) clearTimeout(timer);
2454
+ if (heartbeat) clearInterval(heartbeat);
2455
+ events.off("feedback", onFeedback);
2456
+ events.off("ended", onFeedback);
2457
+ setPollActive(key, activePolls, deliveredFeedback, events, false);
2458
+ refreshIdleTimer();
2459
+ };
2460
+ const respond = async () => {
2461
+ if (responding || res.writableEnded) return;
2462
+ responding = true;
2463
+ try {
2464
+ const result = await store.takeFeedback(key);
2465
+ if (result.status === "feedback") markFeedbackDelivered(key, activePolls, deliveredFeedback, events);
2466
+ if (streamHeartbeat) {
2467
+ res.end(JSON.stringify(result));
2468
+ } else {
2469
+ res.json(result);
2470
+ }
2471
+ } finally {
2472
+ cleanup();
2473
+ }
2474
+ };
2475
+ const onFeedback = (changedKey) => {
2476
+ if (changedKey !== key || res.writableEnded) {
2477
+ return;
2478
+ }
2479
+ respond().catch(handleRespondError);
2480
+ };
2481
+ events.on("feedback", onFeedback);
2482
+ events.on("ended", onFeedback);
2483
+ req.on("close", cleanup);
2484
+ } catch (error) {
2485
+ next(error);
2486
+ }
2487
+ });
2488
+ app.post("/api/:key/prompts", async (req, res, next) => {
2489
+ try {
2490
+ const session = await store.queuePrompts(req.params.key, req.body || {});
2491
+ if (!session) {
2492
+ res.status(404).json({ error: "session not found" });
2493
+ return;
2494
+ }
2495
+ events.emit("feedback", req.params.key);
2496
+ res.json({ status: "queued", pending_prompts: session.pending_prompts });
2497
+ } catch (error) {
2498
+ next(error);
2499
+ }
2500
+ });
2501
+ app.post("/api/:key/layout-warnings", async (req, res, next) => {
2502
+ try {
2503
+ const result = await store.recordLayoutWarnings(req.params.key, req.body || {});
2504
+ if (!result) {
2505
+ res.status(404).json({ error: "session not found" });
2506
+ return;
2507
+ }
2508
+ if (result.changed && result.hasWarnings) {
2509
+ events.emit("feedback", req.params.key);
2510
+ }
2511
+ res.json({ status: "recorded", layout_warnings: result.session.layout_warnings?.length || 0 });
2512
+ } catch (error) {
2513
+ next(error);
2514
+ }
2515
+ });
2516
+ app.post("/api/:key/end", async (req, res, next) => {
2517
+ try {
2518
+ await store.endSession(req.params.key);
2519
+ clearFeedbackDelivery(req.params.key, activePolls, deliveredFeedback, events);
2520
+ events.emit("ended", req.params.key);
2521
+ res.json({ status: "ended" });
2522
+ await shutdownIfNoLiveSessions();
2523
+ } catch (error) {
2524
+ next(error);
2525
+ }
2526
+ });
2527
+ app.post("/api/:key/agent-reply", async (req, res, next) => {
2528
+ try {
2529
+ const text = String(req.body?.text || "");
2530
+ const session = await store.addAgentReply(req.params.key, text);
2531
+ if (!session) {
2532
+ res.status(404).json({ error: "session not found" });
2533
+ return;
2534
+ }
2535
+ events.emit("agent-reply", req.params.key, text);
2536
+ res.json({ status: "sent" });
2537
+ } catch (error) {
2538
+ next(error);
2539
+ }
2540
+ });
2541
+ app.post("/api/end", async (req, res, next) => {
2542
+ try {
2543
+ const file = await canonicalFile(req.body.file);
2544
+ const key = sessionKey(file);
2545
+ await store.endSession(key);
2546
+ clearFeedbackDelivery(key, activePolls, deliveredFeedback, events);
2547
+ events.emit("ended", key);
2548
+ res.json({ status: "ended" });
2549
+ await shutdownIfNoLiveSessions();
2550
+ } catch (error) {
2551
+ next(error);
2552
+ }
2553
+ });
2554
+ app.get("/session/:key", async (req, res, next) => {
2555
+ try {
2556
+ const session = await store.findByKey(req.params.key);
2557
+ if (!session) {
2558
+ res.status(404).send("Session not found");
2559
+ return;
2560
+ }
2561
+ await watchSession(session, watchers, events, logEvent);
2562
+ res.type("html").send(createChromeHtml(session, { layoutGateEnabled: shouldEnableLayoutGate(req.query || {}) }));
2563
+ } catch (error) {
2564
+ next(error);
2565
+ }
2566
+ });
2567
+ app.get("/artifact/:key", (req, res) => {
2568
+ res.redirect(`/artifact/${req.params.key}/index.html`);
2569
+ });
2570
+ app.get(/^\/artifact\/([^/]+)\/index\.html$/, async (req, res, next) => {
2571
+ try {
2572
+ const key = req.params[0];
2573
+ const session = await store.findByKey(key);
2574
+ if (!session) {
2575
+ res.status(404).send("Session not found");
2576
+ return;
2577
+ }
2578
+ const html = await readFile2(session.file, "utf8");
2579
+ res.type("html").send(injectLavishSdk(html, key));
2580
+ } catch (error) {
2581
+ next(error);
2582
+ }
2583
+ });
2584
+ app.get(/^\/artifact\/([^/]+)\/(.+)$/, async (req, res, next) => {
2585
+ try {
2586
+ const key = req.params[0];
2587
+ const assetPath = req.params[1];
2588
+ const session = await store.findByKey(key);
2589
+ if (!session) {
2590
+ res.status(404).send("Session not found");
2591
+ return;
2592
+ }
2593
+ const root = path4.dirname(session.file);
2594
+ const file = resolveArtifactAsset(root, assetPath);
2595
+ if (!file) {
2596
+ res.status(403).send("Forbidden");
2597
+ return;
2598
+ }
2599
+ res.sendFile(file, { dotfiles: "allow" });
2600
+ } catch (error) {
2601
+ next(error);
2602
+ }
2603
+ });
2604
+ app.get("/events/:key", async (req, res, next) => {
2605
+ try {
2606
+ res.writeHead(200, {
2607
+ "content-type": "text/event-stream",
2608
+ "cache-control": "no-cache",
2609
+ connection: "keep-alive"
2610
+ });
2611
+ sseClients.add(res);
2612
+ refreshIdleTimer();
2613
+ const session = await store.findByKey(req.params.key);
2614
+ const sendReload = (key) => {
2615
+ if (key === req.params.key) {
2616
+ res.write("event: reload\ndata: {}\n\n");
2617
+ }
2618
+ };
2619
+ const sendAgentReply = (key, text) => {
2620
+ if (key === req.params.key) {
2621
+ res.write(`event: agent-reply
2622
+ data: ${JSON.stringify({ text })}
2623
+
2624
+ `);
2625
+ }
2626
+ };
2627
+ const sendPresence = (key, state) => {
2628
+ if (key === req.params.key) {
2629
+ res.write(`event: agent-presence
2630
+ data: ${JSON.stringify({ state })}
2631
+
2632
+ `);
2633
+ }
2634
+ };
2635
+ res.write(`event: chat-sync
2636
+ data: ${JSON.stringify({ chat: session?.chat || [] })}
2637
+
2638
+ `);
2639
+ res.write(
2640
+ `event: agent-presence
2641
+ data: ${JSON.stringify({ state: computePresence(req.params.key, activePolls, deliveredFeedback) })}
2642
+
2643
+ `
2644
+ );
2645
+ events.on("reload", sendReload);
2646
+ events.on("agent-reply", sendAgentReply);
2647
+ events.on("agent-presence", sendPresence);
2648
+ req.on("close", () => {
2649
+ sseClients.delete(res);
2650
+ events.off("reload", sendReload);
2651
+ events.off("agent-reply", sendAgentReply);
2652
+ events.off("agent-presence", sendPresence);
2653
+ refreshIdleTimer();
2654
+ });
2655
+ } catch (error) {
2656
+ next(error);
2657
+ }
2658
+ });
2659
+ app.get("/chrome-client.js", async (req, res, next) => {
2660
+ try {
2661
+ res.type("application/javascript").send(await readFile2(chromeClientUrl, "utf8"));
2662
+ } catch (error) {
2663
+ next(error);
2664
+ }
2665
+ });
2666
+ app.get("/chrome.css", async (req, res, next) => {
2667
+ try {
2668
+ res.type("text/css").send(await readFile2(chromeCssUrl, "utf8"));
2669
+ } catch (error) {
2670
+ next(error);
2671
+ }
2672
+ });
2673
+ app.get("/design/:asset", async (req, res, next) => {
2674
+ try {
2675
+ const asset = designAssetUrls[req.params.asset];
2676
+ if (!asset) {
2677
+ res.status(404).send("Not found");
2678
+ return;
2679
+ }
2680
+ res.type(asset.type).send(await readDesignAsset(asset));
2681
+ } catch (error) {
2682
+ next(error);
2683
+ }
2684
+ });
2685
+ app.get("/sdk.js", (req, res) => {
2686
+ res.type("application/javascript").send(createSdkJs(String(req.query.key || "")));
2687
+ });
2688
+ app.use((error, req, res, _next) => {
2689
+ res.status(500).json({ error: error instanceof Error ? error.message : String(error) });
2690
+ });
2691
+ const httpServer = await new Promise((resolve, reject) => {
2692
+ const s = app.listen(port, host, () => {
2693
+ if (s.address()) resolve(s);
2694
+ });
2695
+ s.once("error", reject);
2696
+ });
2697
+ publicPort = httpServer.address().port;
2698
+ let shuttingDown = false;
2699
+ function shutdown() {
2700
+ if (shuttingDown) return;
2701
+ shuttingDown = true;
2702
+ if (idleTimer) {
2703
+ clearTimeout(idleTimer);
2704
+ idleTimer = null;
2705
+ }
2706
+ for (const res of sseClients) {
2707
+ try {
2708
+ res.write("event: chrome-reload\ndata: {}\n\n");
2709
+ res.end();
2710
+ } catch {
2711
+ }
2712
+ }
2713
+ sseClients.clear();
2714
+ for (const w of watchers.values()) {
2715
+ w.close().catch(() => {
2716
+ });
2717
+ }
2718
+ watchers.clear();
2719
+ httpServer.close(() => shutdownResolve());
2720
+ if (typeof httpServer.closeAllConnections === "function") {
2721
+ httpServer.closeAllConnections();
2722
+ }
2723
+ }
2724
+ let idleTimer = null;
2725
+ function refreshIdleTimer() {
2726
+ if (idleTimer) {
2727
+ clearTimeout(idleTimer);
2728
+ idleTimer = null;
2729
+ }
2730
+ if (shuttingDown || idleTimeoutMs == null) return;
2731
+ if (sseClients.size > 0 || activePolls.size > 0) return;
2732
+ idleTimer = setTimeout(() => {
2733
+ idleTimer = null;
2734
+ if (!shuttingDown && sseClients.size === 0 && activePolls.size === 0) {
2735
+ logEvent?.(`idle for ${idleTimeoutMs}ms with no connections, shutting down`);
2736
+ shutdown();
2737
+ }
2738
+ }, idleTimeoutMs);
2739
+ idleTimer.unref?.();
2740
+ }
2741
+ async function shutdownIfNoLiveSessions() {
2742
+ if (sseClients.size > 0 || activePolls.size > 0) return;
2743
+ try {
2744
+ const sessions = await store.listSessions();
2745
+ if (sessions.every((session) => session.status === "ended")) {
2746
+ logEvent?.("last open session ended with no live connections, shutting down");
2747
+ setImmediate(shutdown);
2748
+ }
2749
+ } catch {
2750
+ }
2751
+ }
2752
+ refreshIdleTimer();
2753
+ return {
2754
+ port: httpServer.address().port,
2755
+ close: async () => {
2756
+ shutdown();
2757
+ await done;
2758
+ },
2759
+ done
2760
+ };
2761
+ }
2762
+ async function readDesignAsset(asset) {
2763
+ try {
2764
+ return await readFile2(asset.packaged, "utf8");
2765
+ } catch (error) {
2766
+ if (error && error.code !== "ENOENT") throw error;
2767
+ return readFile2(asset.source, "utf8");
2768
+ }
2769
+ }
2770
+ function resolveArtifactAsset(root, assetPath) {
2771
+ const file = path4.resolve(root, assetPath);
2772
+ const relative = path4.relative(root, file);
2773
+ if (relative.startsWith("..") || path4.isAbsolute(relative)) {
2774
+ return null;
2775
+ }
2776
+ return file;
2777
+ }
2778
+ async function watchSession(session, watchers, events, logEvent) {
2779
+ if (watchers.has(session.key)) {
2780
+ return;
2781
+ }
2782
+ const target = await resolveWatchTarget(session);
2783
+ if (watchers.has(session.key)) {
2784
+ return;
2785
+ }
2786
+ logEvent?.(`watch session=${session.key} scope=${target.scope} path=${target.path}`);
2787
+ const watcher = chokidar.watch(target.path, target.options);
2788
+ let timer = null;
2789
+ watcher.on("all", (event, file) => {
2790
+ logEvent?.(`watch event=${event} session=${session.key} file=${file ?? ""}`);
2791
+ clearTimeout(timer);
2792
+ timer = setTimeout(() => events.emit("reload", session.key), 100);
2793
+ });
2794
+ watcher.on("error", (error) => {
2795
+ const message = error instanceof Error ? error.message : String(error);
2796
+ logEvent?.(`watch error session=${session.key} message=${message}`);
2797
+ });
2798
+ watchers.set(session.key, watcher);
2799
+ }
2800
+ async function resolveWatchTarget(session) {
2801
+ const baseOptions = {
2802
+ ignoreInitial: true,
2803
+ awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 50 }
2804
+ };
2805
+ try {
2806
+ const html = await readFile2(session.file, "utf8");
2807
+ if (hasLiveReloadRootOptIn(html)) {
2808
+ return {
2809
+ path: path4.dirname(session.file),
2810
+ scope: "directory",
2811
+ options: {
2812
+ ...baseOptions,
2813
+ ignored: /(^|[/\\])(\.git|node_modules|dist|build|\.lavish-axi)([/\\]|$)/
2814
+ }
2815
+ };
2816
+ }
2817
+ } catch {
2818
+ }
2819
+ return { path: session.file, scope: "file", options: baseOptions };
2820
+ }
2821
+ function hasLiveReloadRootOptIn(html) {
2822
+ if (typeof html !== "string") return false;
2823
+ const searchableHtml = html.replace(/<!--[\s\S]*?-->/g, "");
2824
+ if (/<html\b[^>]*\sdata-lavish-live-reload-root(?:[\s=>/]|$)[^>]*>/i.test(searchableHtml)) return true;
2825
+ return /<meta\b(?=[^>]*name=["']lavish-live-reload["'])(?=[^>]*content=["']root["'])[^>]*>/i.test(searchableHtml);
2826
+ }
2827
+ function setPollActive(key, activePolls, deliveredFeedback, events, active) {
2828
+ const previousPresence = computePresence(key, activePolls, deliveredFeedback);
2829
+ const count = activePolls.get(key) || 0;
2830
+ const nextCount = active ? count + 1 : Math.max(0, count - 1);
2831
+ if (nextCount === count) return;
2832
+ if (nextCount === 0) {
2833
+ activePolls.delete(key);
2834
+ } else {
2835
+ activePolls.set(key, nextCount);
2836
+ deliveredFeedback.delete(key);
2837
+ }
2838
+ const nextPresence = computePresence(key, activePolls, deliveredFeedback);
2839
+ if (nextPresence !== previousPresence) events.emit("agent-presence", key, nextPresence);
2840
+ }
2841
+ function markFeedbackDelivered(key, activePolls, deliveredFeedback, events) {
2842
+ const previousPresence = computePresence(key, activePolls, deliveredFeedback);
2843
+ deliveredFeedback.add(key);
2844
+ const nextPresence = computePresence(key, activePolls, deliveredFeedback);
2845
+ if (nextPresence !== previousPresence) {
2846
+ events.emit("agent-presence", key, nextPresence);
2847
+ }
2848
+ }
2849
+ function clearFeedbackDelivery(key, activePolls, deliveredFeedback, events) {
2850
+ const previousPresence = computePresence(key, activePolls, deliveredFeedback);
2851
+ deliveredFeedback.delete(key);
2852
+ const nextPresence = computePresence(key, activePolls, deliveredFeedback);
2853
+ if (nextPresence !== previousPresence) {
2854
+ events.emit("agent-presence", key, nextPresence);
2855
+ }
2856
+ }
2857
+ function computePresence(key, activePolls, deliveredFeedback) {
2858
+ if (activePolls.has(key)) return "listening";
2859
+ if (deliveredFeedback.has(key)) return "working";
2860
+ return "waiting";
2861
+ }
2862
+ function chromeIcon(paths, size = 16, strokeWidth = 1.7) {
2863
+ return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="${strokeWidth}" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths}</svg>`;
2864
+ }
2865
+ var chromeIcons = {
2866
+ more: chromeIcon(
2867
+ '<circle cx="12" cy="5" r="1.4"/><circle cx="12" cy="12" r="1.4"/><circle cx="12" cy="19" r="1.4"/>'
2868
+ ),
2869
+ file: chromeIcon(
2870
+ '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>',
2871
+ 13
2872
+ ),
2873
+ copy: chromeIcon(
2874
+ '<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>',
2875
+ 12
2876
+ ),
2877
+ check: chromeIcon('<polyline points="20 6 9 17 4 12"/>', 12),
2878
+ refresh: chromeIcon(
2879
+ '<path d="M3 12a9 9 0 0 1 15-6.7L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-15 6.7L3 16"/><path d="M3 21v-5h5"/>',
2880
+ 15
2881
+ ),
2882
+ camera: chromeIcon(
2883
+ '<path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3z"/><circle cx="12" cy="13" r="3"/>',
2884
+ 15
2885
+ ),
2886
+ exit: chromeIcon(
2887
+ '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/>',
2888
+ 15
2889
+ ),
2890
+ send: chromeIcon('<path d="M22 2 11 13"/><path d="M22 2 15 22l-4-9-9-4Z"/>', 14),
2891
+ caret: chromeIcon('<path d="m6 9 6 6 6-6"/>', 13, 2)
2892
+ };
2893
+ function displayPathParts(file, home = homedir()) {
2894
+ const normalizedFile = file.replaceAll("\\", "/");
2895
+ const normalizedHome = home.replaceAll("\\", "/");
2896
+ const display = normalizedHome && normalizedFile.startsWith(`${normalizedHome}/`) ? `~/${normalizedFile.slice(normalizedHome.length + 1)}` : normalizedFile;
2897
+ const tailStart = display.lastIndexOf("/") + 1;
2898
+ return { head: display.slice(0, tailStart), tail: display.slice(tailStart) };
2899
+ }
2900
+ function shouldEnableLayoutGate(query = {}) {
2901
+ const noGate = query["no-gate"] ?? query.noGate ?? query.no_gate;
2902
+ if (isTruthyFlag(noGate)) return false;
2903
+ const gate2 = query.gate ?? query.layoutGate ?? query.layout_gate;
2904
+ if (isFalseyFlag(gate2)) return false;
2905
+ return true;
2906
+ }
2907
+ function shouldDisableLayoutGateOpen(body = {}) {
2908
+ const noGate = body["no-gate"] ?? body.noGate ?? body.no_gate;
2909
+ if (isTruthyFlag(noGate)) return true;
2910
+ const gate2 = body.gate ?? body.layoutGate ?? body.layout_gate;
2911
+ return isFalseyFlag(gate2);
2912
+ }
2913
+ function appendNoGateParam(url) {
2914
+ const parsed = new URL(url);
2915
+ parsed.searchParams.set("no-gate", "1");
2916
+ return parsed.toString();
2917
+ }
2918
+ function isTruthyFlag(value) {
2919
+ const normalized = normalizeFlagValue(value);
2920
+ return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
2921
+ }
2922
+ function isFalseyFlag(value) {
2923
+ const normalized = normalizeFlagValue(value);
2924
+ return normalized === "0" || normalized === "false" || normalized === "no" || normalized === "off";
2925
+ }
2926
+ function normalizeFlagValue(value) {
2927
+ if (Array.isArray(value)) return normalizeFlagValue(value[0]);
2928
+ return value === void 0 || value === null ? "" : String(value).trim().toLowerCase();
2929
+ }
2930
+ function createChromeHtml(session, { layoutGateEnabled = true } = {}) {
2931
+ const sessionJson = jsonScript({
2932
+ key: session.key,
2933
+ file: session.file,
2934
+ initialChat: session.chat || [],
2935
+ layoutGateEnabled
2936
+ });
2937
+ const { head: pathHead, tail: pathTail } = displayPathParts(session.file);
2938
+ const bodyClass = layoutGateEnabled ? "lavish layout-gate-active" : "lavish";
2939
+ const layoutGateHidden = layoutGateEnabled ? "" : " hidden";
2940
+ return `<!doctype html>
2941
+ <html>
2942
+ <head>
2943
+ <meta charset="utf-8">
2944
+ <meta name="viewport" content="width=device-width, initial-scale=1">
2945
+ <title>Loupe</title>
2946
+ <link rel="stylesheet" href="/chrome.css">
2947
+ </head>
2948
+ <body class="${bodyClass}">
2949
+ <div class="bar"><div class="brand"><span class="brand-mark">Loupe</span><span class="brand-support">review</span></div><div class="spacer" aria-hidden="true"></div><button class="annotate-switch" id="annotation" type="button" aria-pressed="true"><span class="switch-track" aria-hidden="true"><span class="switch-knob"></span></span><span>Annotate</span></button><div class="more-wrap" id="moreWrap"><button class="more-button" id="moreButton" type="button" title="More" aria-haspopup="menu" aria-expanded="false">${chromeIcons.more}</button><div class="menu more-menu" id="moreMenu" hidden><div class="menu-head"><div class="menu-label">Editing</div><button class="menu-file" id="copyPath" type="button" title="Copy path \xB7 ${escapeHtml2(session.file)}">${chromeIcons.file}<span class="menu-file-text"><span class="path-head">${escapeHtml2(pathHead)}</span><span class="path-tail">${escapeHtml2(pathTail)}</span></span><span class="copy-hint" id="copyHint"><span class="icon-copy">${chromeIcons.copy}</span><span class="icon-check">${chromeIcons.check}</span><span id="copyHintText">Copy</span></span></button></div><div class="menu-rule"></div><button class="menu-item" id="reloadArtifact" type="button">${chromeIcons.refresh}<span>Reload artifact</span></button><button class="menu-item" id="copySnapshot" type="button">${chromeIcons.camera}<span>Copy DOM snapshot</span></button><div class="menu-rule"></div><button class="menu-item danger" id="end" type="button">${chromeIcons.exit}<span>End session</span></button></div></div></div>
2950
+ <div class="layout"><div class="frame"><iframe id="artifact" sandbox="allow-scripts allow-forms allow-popups allow-downloads" data-artifact-src="/artifact/${session.key}/index.html"></iframe><div class="layout-issue-banner" id="layoutIssueBanner" hidden>This surface may have layout issues. Your agent has been notified.</div></div><aside class="panel"><h2>Conversation</h2><div class="chat" id="chatLog"></div><div class="composer"><div class="presence-banner" id="presenceBanner" hidden>Your agent is not listening. If this persists, ask your agent to poll for updates from Loupe.</div><div class="annotation-pills" id="annotationPills"></div><textarea id="chatInput" placeholder="Write a message for the agent..."></textarea><div class="actions" id="sendActions"><span class="send-hint" id="sendHint" hidden>Write a message or annotate an element first.</span><div class="split"><button class="button send-main" id="send">Send to Agent</button><button class="button send-caret" id="sendCaret" type="button" title="Send options" aria-haspopup="menu" aria-expanded="false">${chromeIcons.caret}</button></div><div class="menu send-menu" id="sendMenu" hidden><button class="menu-item" id="sendFromMenu" type="button">${chromeIcons.send}<span>Send to Agent</span></button><button class="menu-item danger" id="sendAndEnd" type="button">${chromeIcons.exit}<span>Send &amp; end session</span></button></div></div></div></aside></div>
2951
+ <div class="ended-overlay layout-gate-overlay" id="layoutGateOverlay"${layoutGateHidden}><div class="ended-card"><div class="ended-title" id="layoutGateTitle">Checking layout.<br>One moment.</div><p class="ended-copy" id="layoutGateCopy">Loupe is waiting for fonts and final geometry before revealing this artifact.</p><button class="button ended-action" id="layoutGateAction" type="button">Show anyway</button></div></div>
2952
+ <div class="ended-overlay" id="endedOverlay" hidden><div class="ended-card"><div class="ended-title">Session ended.<br>Return to your agent to continue.</div><p class="ended-copy">${escapeHtml2(session.file)}</p></div></div>
2953
+ <script id="lavish-session" type="application/json">${sessionJson}</script>
2954
+ <script src="/chrome-client.js"></script>
2955
+ </body>
2956
+ </html>`;
2957
+ }
2958
+ function createSdkJs(key) {
2959
+ return `(() => {
2960
+ const key=${JSON.stringify(key)};
2961
+ void key;
2962
+ const deriveQueueKey=${deriveLavishQueueKey.toString()};
2963
+ const isNativeInteractiveControl=${isNativeInteractiveControl.toString()};
2964
+ (${createArtifactSdk.toString()})(deriveQueueKey, isNativeInteractiveControl);
2965
+ })();`;
2966
+ }
2967
+ function escapeHtml2(value) {
2968
+ return String(value).replace(
2969
+ /[&<>"']/g,
2970
+ (char) => ({
2971
+ "&": "&amp;",
2972
+ "<": "&lt;",
2973
+ ">": "&gt;",
2974
+ '"': "&quot;",
2975
+ "'": "&#39;"
2976
+ })[char]
2977
+ );
2978
+ }
2979
+ function jsonScript(value) {
2980
+ return JSON.stringify(value).replace(/&/g, "\\u0026").replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
2981
+ }
2982
+
2983
+ // src/telemetry.js
2984
+ var NoopTelemetryClient = class {
2985
+ /** @param {string} [_name] @param {Record<string, unknown>} [_fields] */
2986
+ track(_name, _fields) {
2987
+ }
2988
+ /** @param {string} [_path] @param {Record<string, unknown>} [_fields] */
2989
+ pageview(_path, _fields) {
2990
+ }
2991
+ /** @param {number} [_timeoutMs] */
2992
+ async close(_timeoutMs) {
2993
+ }
2994
+ };
2995
+ var defaultClient = new NoopTelemetryClient();
2996
+ function initDefaultTelemetry(_init) {
2997
+ defaultClient = new NoopTelemetryClient();
2998
+ return defaultClient;
2999
+ }
3000
+
3001
+ // src/cli.js
3002
+ var COMMANDS = /* @__PURE__ */ new Set(["open", "new", "spec", "poll", "end", "stop", "server", "playbook", "design", "setup"]);
3003
+ var DESCRIPTION = "Loupe turns a proposed change into a structured visual review surface for the spec phase, so developers grasp a change by looking and clicking instead of reading walls of text. Every Loupe artifact has the same guaranteed shape: \xA7A Current World (a map of the relevant use cases with the blast radius highlighted), \xA7B Grill (interactive cards the developer answers by clicking, each with an open field), and \xA7C Goal Vision (a before -> after of the agreed change). Run `loupe new <html-file>` to scaffold that structure, fill the marked slots, then `loupe <html-file>` to open the review and `loupe poll <html-file>` to receive the developer's grill answers and annotations.";
3004
+ var VERSION = "0.1.0";
3005
+ async function run(argv) {
3006
+ await ensureStateDir();
3007
+ const normalizedArgv = normalizeArgv(argv);
3008
+ const isTopLevelHelp = argv.length === 1 && argv[0] === "--help";
3009
+ const command = telemetryCommandName(argv);
3010
+ const telemetry = initDefaultTelemetry({
3011
+ app: "lavish-axi",
3012
+ version: VERSION,
3013
+ platform: process.platform,
3014
+ arch: process.arch
3015
+ });
3016
+ telemetry.pageview(`/${command}`, { command });
3017
+ try {
3018
+ await runAxiCli({
3019
+ description: DESCRIPTION,
3020
+ version: VERSION,
3021
+ argv: isTopLevelHelp ? [] : normalizedArgv,
3022
+ topLevelHelp: TOP_LEVEL_HELP,
3023
+ home: async () => createHomeOutput({
3024
+ bin: process.argv[1] || "lavish-axi",
3025
+ sessions: isTopLevelHelp ? [] : await visibleSessions(),
3026
+ includeSessions: !isTopLevelHelp
3027
+ }),
3028
+ commands: {
3029
+ open: openCommand,
3030
+ new: newCommand,
3031
+ spec: specCommand,
3032
+ poll: pollCommand,
3033
+ end: endCommand,
3034
+ stop: stopCommand,
3035
+ playbook: playbookCommand,
3036
+ design: designCommand,
3037
+ setup: setupCommand,
3038
+ server: serverCommand
3039
+ },
3040
+ getCommandHelp
3041
+ });
3042
+ telemetry.track("command", { command, status: "success" });
3043
+ } catch (error) {
3044
+ telemetry.track("command", { command, status: "error" });
3045
+ throw error;
3046
+ } finally {
3047
+ await telemetry.close(1e3);
3048
+ }
3049
+ }
3050
+ function collapseHomeDirectory(file, home) {
3051
+ const normalizedFile = file.replaceAll("\\", "/");
3052
+ const normalizedHome = home.replaceAll("\\", "/");
3053
+ if (normalizedFile === normalizedHome) {
3054
+ return "~";
3055
+ }
3056
+ if (normalizedFile.startsWith(`${normalizedHome}/`)) {
3057
+ return `~/${normalizedFile.slice(normalizedHome.length + 1)}`;
3058
+ }
3059
+ return file;
3060
+ }
3061
+ function normalizeArgv(argv) {
3062
+ const first = argv[0];
3063
+ if (!first || COMMANDS.has(first)) {
3064
+ return argv;
3065
+ }
3066
+ if (first.startsWith("-")) {
3067
+ return argv.some((arg) => isHtmlPath(arg)) ? ["open", ...argv] : argv;
3068
+ }
3069
+ return ["open", ...argv];
3070
+ }
3071
+ function telemetryCommandName(argv) {
3072
+ const normalized = normalizeArgv(argv);
3073
+ return normalized[0] && !normalized[0].startsWith("-") ? normalized[0] : "home";
3074
+ }
3075
+ function createHomeOutput({ bin, sessions, includeSessions = true }) {
3076
+ return {
3077
+ bin: collapseHomeDirectory(bin, os2.homedir()),
3078
+ description: DESCRIPTION,
3079
+ ...includeSessions ? {
3080
+ sessions: sessions.map((session) => ({
3081
+ file: session.file,
3082
+ status: session.status,
3083
+ url: session.url,
3084
+ pending_prompts: session.pending_prompts || 0
3085
+ }))
3086
+ } : {},
3087
+ visual_guidance: [
3088
+ "Use visual hierarchy to make the most important decisions, risks, tradeoffs, and next actions obvious at a glance",
3089
+ "Use visual structure such as sections, cards, tables, diagrams, annotated snippets, and side-by-side comparisons instead of long prose",
3090
+ "Choose typography, spacing, color, and layout deliberately so the artifact has a clear point of view",
3091
+ "Prevent horizontal overflow at every nesting level: nested grid/flex children also need minmax(0, 1fr) tracks and min-width: 0, especially when badges, labels, or status text use wide pixel or monospace fonts; wrap, truncate, or contain long unbreakable text deliberately"
3092
+ ],
3093
+ playbooks: listPlaybooks(),
3094
+ help: [
3095
+ "Loupe is for the spec phase. `loupe new <html-file>` scaffolds two lenses, each with \xA7A Current World / \xA7B Grill / \xA7C Goal Vision. Fill the Product Lens first (draw \xA7A in mermaid and color the blast radius `class \u2026 hit`, write real click-to-answer Grill Cards, redraw \xA7C from the answers). Leave the Code Lens empty until the developer clicks `Lock product intent` (a soft signal \u2014 the Code Lens is always visible/editable/annotatable): when the poll returns `PRODUCT INTENT LOCKED`, read the real codebase and fill the Code Lens (current architecture + blast radius, architecture Grill Cards, interface/architecture before -> after); greenfield projects are designed from scratch. `REOPEN PRODUCT INTENT` means go back to the Product Lens. Use `--product-only` for small changes, or `--greenfield` when there is no codebase (the Code Lens becomes a from-scratch architecture proposal, no before/after). Open with `loupe <html-file>` and poll for answers, gate signals, and annotations. Clarity over polish: prefer mermaid, drop any visual that does not aid understanding.",
3096
+ "Run `loupe <html-file>` to open or resume a Loupe review session",
3097
+ "Unless the user specifies another location, create HTML artifacts in the current working directory under `.lavish/`",
3098
+ "Lavish serves the html file through a local express.js server. If your html needs to reference other filesystem assets such as images, CSS, fonts, and local scripts, copy them into the same directory as the HTML file, then reference them with relative paths from that directory. Never prepend `/` to those asset paths - root paths won't work",
3099
+ "Run `loupe poll <html-file>` to wait for user feedback or browser-reported layout_warnings. It long-polls and stays silent until the user sends feedback, ends the session, or the real browser reports fresh layout_warnings, so leave it running - never kill it. Fix layout_warnings before involving the human. If your harness limits how long a foreground command may run, run the poll as a background task; if it gets killed or times out anyway, just re-run it - queued feedback is never lost",
3100
+ "Triage each annotation the poll returns and say which grade you assigned: a Tweak (local/cosmetic) -> patch the HTML in place; a Follow-up (a question / more detail) -> adjust that section within the current lens; Directional feedback (challenges the agreed product intent) -> do not silently redraw, ask the developer to confirm reopening the product intent first.",
3101
+ "Run `loupe end <html-file>` to end a session",
3102
+ "Run `loupe stop` to shut down the background server (it also self-stops when idle or after the last session ends with nothing connected)",
3103
+ `Run \`loupe playbook <playbook_id>\` for focused artifact guidance. ${PLAYBOOK_ROUTER_HELP}`,
3104
+ DESIGN_SYSTEM_HINT,
3105
+ "Use loupe when the user asks for a visual artifact, HTML explainer, interactive prototype, review surface, product or technical plan, comparison, report, or browser-based feedback loop"
3106
+ ]
3107
+ };
3108
+ }
3109
+ function createPlaybookOutput(args) {
3110
+ const id = args[0];
3111
+ if (!id) {
3112
+ return {
3113
+ playbooks: listPlaybooks(),
3114
+ help: ["Run `loupe playbook <playbook_id>` for focused artifact guidance", PLAYBOOK_ROUTER_HELP]
3115
+ };
3116
+ }
3117
+ const playbook = findPlaybook(id);
3118
+ if (!playbook) {
3119
+ throw new AxiError(`Unknown playbook: ${id}`, "VALIDATION_ERROR", [
3120
+ `Run \`loupe playbook\` to list known IDs: ${playbookIds().join(", ")}`
3121
+ ]);
3122
+ }
3123
+ return { playbook };
3124
+ }
3125
+ function createOpenOutput({ file, url, status }) {
3126
+ return {
3127
+ session: { file, url, status },
3128
+ next_step: `Do not respond to the user just yet. Now you must run \`loupe poll ${file}\`. This command long-polls until the user sends feedback, ends the session, or the real browser reports layout_warnings from the in-iframe layout audit, and it stays silent the whole time - that is normal, never kill it. If layout_warnings arrive, fix overflow, clipped text, or overlapping unreadable content and re-check before involving the human. Do not pass --timeout-ms during normal agent use. If your harness limits how long a foreground command may run, run the poll as a background task and wait for it to finish; if the poll still gets killed or times out, just re-run it - queued feedback is never lost. After applying feedback, run \`loupe poll ${file} --agent-reply "<message for the user>"\` without --timeout-ms to show your response in Loupe and wait for more feedback.`
3129
+ };
3130
+ }
3131
+ async function openCommand(args) {
3132
+ const file = args.find((arg) => !arg.startsWith("-"));
3133
+ if (!file) {
3134
+ throw new AxiError("HTML file path is required", "VALIDATION_ERROR", ["Run `loupe <html-file>`"]);
3135
+ }
3136
+ await assertHtmlFile(file);
3137
+ const absolute = await canonicalFile(file);
3138
+ const noGate = args.includes("--no-gate");
3139
+ const baseUrl = await ensureServer({ forceRestart: shouldForceRestartForLocalBuild(process.argv[1] || "") });
3140
+ const response = await postJson(`${baseUrl}/api/sessions`, { file: absolute, noGate });
3141
+ if (shouldOpenBrowser(args, process.env)) {
3142
+ try {
3143
+ const open = (await import("open")).default;
3144
+ await open(response.url);
3145
+ } catch {
3146
+ response.status = "ready";
3147
+ }
3148
+ }
3149
+ return createOpenOutput({ file: absolute, url: response.url, status: response.status || "opened" });
3150
+ }
3151
+ function shouldOpenBrowser(args, env) {
3152
+ return !args.includes("--no-open") && env.LAVISH_AXI_NO_OPEN !== "1";
3153
+ }
3154
+ function deriveTitleFromPath(file) {
3155
+ const base = path5.basename(file).replace(/\.html?$/i, "");
3156
+ const words = base.replace(/[-_]+/g, " ").trim();
3157
+ if (!words) return "Untitled change";
3158
+ return words.charAt(0).toUpperCase() + words.slice(1);
3159
+ }
3160
+ function createNewOutput({ file, productOnly = false, greenfield = false }) {
3161
+ const productSteps = `Fill the Product Lens \`LOUPE \u2014 fill:\` slots first: redraw \xA7A as the real use-case flow with the Blast Radius colored (\`class \u2026 hit\`; quote mermaid labels with punctuation, e.g. \`X["Pay (guest)"]\`, since a bare \`&\` or \`(\` breaks the parse), replace the example Grill Cards with real product questions, and redraw \xA7C Goal Vision once the user's grill answers arrive.`;
3162
+ const codeSteps = greenfield ? ` Leave the Code Lens empty until the user clicks "Lock product intent". When the poll returns "PRODUCT INTENT LOCKED", design the architecture FROM SCRATCH (this is greenfield \u2014 there is no codebase to read): \xA7A proposed architecture marking the new pieces (\`class \u2026 new\`), design Grill Cards, then \xA7C the interfaces/contracts to add (no before/after).` : ` Leave the Code Lens empty until the user clicks "Lock product intent" (a soft signal that never locks the UI). When the poll returns "PRODUCT INTENT LOCKED", read the real codebase and fill the Code Lens (\xA7A current architecture + blast radius, architecture Grill Cards, then the interface/architecture before \u2192 after). If the poll returns "REOPEN PRODUCT INTENT", go back and re-grill the Product Lens.`;
3163
+ const gateSteps = productOnly ? "" : codeSteps;
3164
+ return {
3165
+ scaffold: {
3166
+ file,
3167
+ lenses: productOnly ? ["Product"] : ["Product", "Code"],
3168
+ mode: productOnly ? "product-only" : greenfield ? "greenfield" : "brownfield",
3169
+ sections: ["\xA7A Current World", "\xA7B Grill", "\xA7C Goal Vision"],
3170
+ gated: !productOnly
3171
+ },
3172
+ next_step: `Loupe scaffold written to ${file}. ${productSteps}${gateSteps} Then run \`loupe ${file}\` to open the review and \`loupe poll ${file}\` to receive grill answers, gate signals, and annotations. Do not respond to the user until the Product Lens is filled and opened.`
3173
+ };
3174
+ }
3175
+ async function newCommand(args) {
3176
+ const file = args.find((arg) => !arg.startsWith("-"));
3177
+ if (!file) {
3178
+ throw new AxiError("HTML file path is required", "VALIDATION_ERROR", ["Run `loupe new <html-file>`"]);
3179
+ }
3180
+ if (!isHtmlPath(file)) {
3181
+ throw new AxiError("Loupe expects an HTML file path", "VALIDATION_ERROR", ["Run `loupe new <html-file>`"]);
3182
+ }
3183
+ const absolute = path5.resolve(file);
3184
+ if (existsSync(absolute) && !args.includes("--force")) {
3185
+ throw new AxiError(`File already exists: ${file}`, "VALIDATION_ERROR", [
3186
+ "Pass --force to overwrite it, or choose a different path"
3187
+ ]);
3188
+ }
3189
+ const productOnly = args.includes("--product-only");
3190
+ const greenfield = args.includes("--greenfield");
3191
+ const title = flagValue(args, "--title") || deriveTitleFromPath(file);
3192
+ const problem = flagValue(args, "--problem") || "";
3193
+ await mkdir2(path5.dirname(absolute), { recursive: true });
3194
+ await writeFile2(absolute, createScaffoldHtml({ title, problem, productOnly, greenfield }), "utf8");
3195
+ return createNewOutput({ file: absolute, productOnly, greenfield: greenfield && !productOnly });
3196
+ }
3197
+ function createSpecOutput({ specFile, htmlFile }) {
3198
+ return {
3199
+ spec: { file: specFile, canonical: htmlFile },
3200
+ next_step: `Companion spec written to ${specFile}. Fill the \`\u2026\` slots with the decisions this Loupe Session actually settled (problem, blast radius, before \u2192 after for each lens, interface delta). It links back to the canonical artifact ${htmlFile} and is the source of truth for implementation \u2014 keep it in version control. Once filled, begin building the change.`
3201
+ };
3202
+ }
3203
+ async function specCommand(args) {
3204
+ const file = args.find((arg) => !arg.startsWith("-"));
3205
+ if (!file) {
3206
+ throw new AxiError("HTML file path is required", "VALIDATION_ERROR", ["Run `loupe spec <html-file>`"]);
3207
+ }
3208
+ if (!isHtmlPath(file)) {
3209
+ throw new AxiError("Loupe expects the canonical HTML file path", "VALIDATION_ERROR", [
3210
+ "Run `loupe spec <html-file>`"
3211
+ ]);
3212
+ }
3213
+ const absoluteHtml = path5.resolve(file);
3214
+ if (!existsSync(absoluteHtml)) {
3215
+ throw new AxiError(`File not found: ${file}`, "NOT_FOUND", [
3216
+ "Create the artifact first with `loupe new <html-file>`"
3217
+ ]);
3218
+ }
3219
+ const out = flagValue(args, "--out");
3220
+ const specFile = out ? path5.resolve(out) : deriveSpecPath(absoluteHtml);
3221
+ if (existsSync(specFile) && !args.includes("--force")) {
3222
+ throw new AxiError(`Spec already exists: ${specFile}`, "VALIDATION_ERROR", [
3223
+ "Pass --force to overwrite it, or choose a different --out path"
3224
+ ]);
3225
+ }
3226
+ const html = readFileSync(absoluteHtml, "utf8");
3227
+ const productOnly = !html.includes('id="code-lens"');
3228
+ const greenfield = html.includes("data-loupe-greenfield");
3229
+ const title = deriveTitleFromPath(absoluteHtml);
3230
+ await mkdir2(path5.dirname(specFile), { recursive: true });
3231
+ await writeFile2(
3232
+ specFile,
3233
+ createSpecMarkdown({ title, htmlBasename: path5.basename(absoluteHtml), productOnly, greenfield }),
3234
+ "utf8"
3235
+ );
3236
+ return createSpecOutput({ specFile, htmlFile: absoluteHtml });
3237
+ }
3238
+ async function pollCommand(args) {
3239
+ const file = args[0];
3240
+ if (!file) {
3241
+ throw new AxiError("HTML file path is required", "VALIDATION_ERROR", ["Run `loupe poll <html-file>`"]);
3242
+ }
3243
+ const absolute = await canonicalFile(file);
3244
+ const baseUrl = await ensureServer();
3245
+ const agentReply = flagValue(args, "--agent-reply");
3246
+ if (agentReply) {
3247
+ await postJson(`${baseUrl}/api/${sessionKey(absolute)}/agent-reply`, { text: agentReply });
3248
+ }
3249
+ const timeoutMs = flagValue(args, "--timeout-ms");
3250
+ const timeoutQuery = timeoutMs ? `&timeoutMs=${encodeURIComponent(timeoutMs)}` : "";
3251
+ const onPollSignal = (signal) => {
3252
+ process.stderr.write(`
3253
+ ${pollInterruptedText(absolute)}
3254
+ `);
3255
+ process.exit(signal === "SIGINT" ? 130 : 143);
3256
+ };
3257
+ if (!timeoutMs) {
3258
+ process.on("SIGINT", onPollSignal);
3259
+ process.on("SIGTERM", onPollSignal);
3260
+ }
3261
+ const waitReporter = timeoutMs ? null : startPollWaitReporter({ file: absolute });
3262
+ try {
3263
+ const response = await fetchJson(`${baseUrl}/api/poll?file=${encodeURIComponent(absolute)}${timeoutQuery}`, {
3264
+ retries: 3,
3265
+ retryDelayMs: 500
3266
+ });
3267
+ return createPollOutput({ file: absolute, response });
3268
+ } finally {
3269
+ waitReporter?.stop();
3270
+ if (!timeoutMs) {
3271
+ process.off("SIGINT", onPollSignal);
3272
+ process.off("SIGTERM", onPollSignal);
3273
+ }
3274
+ }
3275
+ }
3276
+ function pollWaitBannerText(file) {
3277
+ return `[loupe] Long-polling for user feedback or layout_warnings on ${file}. This stays silent until the user sends feedback, ends the session, or the browser reports fresh layout_warnings - leave it running. If it gets killed or times out, re-run \`loupe poll ${file}\` - queued feedback is never lost.`;
3278
+ }
3279
+ function pollWaitTickText(elapsedMs) {
3280
+ const minutes = Math.round(elapsedMs / 6e4);
3281
+ return `[loupe] Still waiting for user feedback (${minutes}m). Also waiting for fresh layout_warnings. Leave this running until the user acts or the browser reports fresh layout_warnings.`;
3282
+ }
3283
+ function pollInterruptedText(file) {
3284
+ return `[loupe] Poll interrupted before user feedback arrived. The user may still be reviewing - re-run \`loupe poll ${file}\` to keep waiting; queued feedback is never lost.`;
3285
+ }
3286
+ function startPollWaitReporter({
3287
+ file,
3288
+ write = (line) => {
3289
+ process.stderr.write(line);
3290
+ },
3291
+ intervalMs = 6e4
3292
+ }) {
3293
+ write(`${pollWaitBannerText(file)}
3294
+ `);
3295
+ let elapsedMs = 0;
3296
+ const timer = setInterval(() => {
3297
+ elapsedMs += intervalMs;
3298
+ write(`${pollWaitTickText(elapsedMs)}
3299
+ `);
3300
+ }, intervalMs);
3301
+ timer.unref?.();
3302
+ return { stop: () => clearInterval(timer) };
3303
+ }
3304
+ function createPollOutput({ file, response }) {
3305
+ if (response.status === "missing") {
3306
+ throw new AxiError("No active Loupe session for this file", "NOT_FOUND", [`Run \`loupe ${file}\` first`]);
3307
+ }
3308
+ if (response.status === "feedback") {
3309
+ const layoutWarnings = Array.isArray(response.layout_warnings) ? response.layout_warnings : [];
3310
+ return {
3311
+ session: { file, status: "feedback" },
3312
+ dom_snapshot: response.dom_snapshot || "",
3313
+ prompts: response.prompts || [],
3314
+ ...layoutWarnings.length > 0 ? { layout_warnings: layoutWarnings } : {},
3315
+ next_step: createFeedbackNextStep(file, layoutWarnings.length)
3316
+ };
3317
+ }
3318
+ if (response.status === "ended") {
3319
+ return { session: { file, status: "ended" } };
3320
+ }
3321
+ return {
3322
+ session: { file, status: response.status || "waiting" },
3323
+ next_step: `No user feedback arrived before the optional timeout. Run \`loupe poll ${file}\` without --timeout-ms to wait indefinitely - queued feedback is never lost, so re-running the poll is always safe.`
3324
+ };
3325
+ }
3326
+ function createFeedbackNextStep(file, layoutWarningCount) {
3327
+ const layoutPrefix = layoutWarningCount > 0 ? `${layoutWarningCount} layout warning${layoutWarningCount === 1 ? "" : "s"} detected - fix horizontal overflow, clipped text, or overlapping unreadable content in ${file}, then reload or re-open the artifact and re-check before involving the human. ` : `Apply the requested changes to ${file}. `;
3328
+ return `${layoutPrefix}Do not respond to the user just yet. Now you must run \`loupe poll ${file} --agent-reply "<message for the user>"\` without --timeout-ms unless the user ended the session. The poll waits silently until the user sends more feedback, ends the session, or reports fresh layout_warnings - never kill it. If your harness limits how long a foreground command may run, run the poll as a background task; if it still gets killed or times out, just re-run it - queued feedback is never lost.`;
3329
+ }
3330
+ async function endCommand(args) {
3331
+ const file = args[0];
3332
+ if (!file) {
3333
+ throw new AxiError("HTML file path is required", "VALIDATION_ERROR", ["Run `loupe end <html-file>`"]);
3334
+ }
3335
+ const absolute = await canonicalFile(file);
3336
+ const baseUrl = await ensureServer();
3337
+ const response = await postJson(`${baseUrl}/api/end`, { file: absolute });
3338
+ return { session: { file: absolute, status: response.status || "ended" } };
3339
+ }
3340
+ async function stopCommand(args) {
3341
+ const port = Number(flagValue(args, "--port") || defaultPort());
3342
+ const baseUrl = `http://${hostForUrl(clientHost())}:${port}`;
3343
+ return shutdownServerOnPort(port, { baseUrl, currentVersion: VERSION });
3344
+ }
3345
+ async function shutdownServerOnPort(port, {
3346
+ baseUrl = `http://${hostForUrl(clientHost())}:${port}`,
3347
+ currentVersion = VERSION,
3348
+ fetchHealth: healthFetcher = fetchHealth,
3349
+ requestShutdown: shutdownRequester = requestShutdown,
3350
+ waitForPortFree: portFreeWaiter = waitForPortFree,
3351
+ killProcessOnPort: portKiller = killProcessOnPort,
3352
+ processMatchesLavish = processOnPortMatchesLavish
3353
+ } = {}) {
3354
+ const health = await healthFetcher(baseUrl);
3355
+ if (!health) {
3356
+ return { server: { status: "not-running", port } };
3357
+ }
3358
+ if (!await canControlServerOnPort(port, health, processMatchesLavish)) {
3359
+ return { server: { status: "not-lavish", port } };
3360
+ }
3361
+ await shutdownRequester(baseUrl);
3362
+ let freed = await portFreeWaiter(baseUrl, 3e3);
3363
+ if (!freed && shouldKillProcessOnPort(currentVersion, health)) {
3364
+ portKiller(port);
3365
+ freed = await portFreeWaiter(baseUrl, 3e3);
3366
+ }
3367
+ return { server: { status: freed ? "stopped" : "stopping", port } };
3368
+ }
3369
+ async function playbookCommand(args) {
3370
+ return createPlaybookOutput(args);
3371
+ }
3372
+ async function designCommand() {
3373
+ return createDesignOutput();
3374
+ }
3375
+ async function setupCommand(args) {
3376
+ if (args.length !== 1 || args[0] !== "hooks") {
3377
+ throw new AxiError("Unknown setup action", "VALIDATION_ERROR", ["Run `loupe setup hooks`"]);
3378
+ }
3379
+ const errors = [];
3380
+ installSessionStartHooks({
3381
+ marker: "loupe",
3382
+ binaryNames: ["loupe"],
3383
+ distEntrypoints: ["dist/cli.mjs", "bin/lavish-axi.js"],
3384
+ homeDir: resolveHookHomeDir(),
3385
+ onError: (message) => errors.push(message)
3386
+ });
3387
+ if (errors.length > 0) {
3388
+ throw new AxiError("Failed to install loupe agent hooks", "SERVER_ERROR", errors);
3389
+ }
3390
+ return {
3391
+ hooks: { status: "installed", integrations: "Claude Code, Codex, OpenCode" },
3392
+ help: ["Restart your agent session to receive loupe ambient context"]
3393
+ };
3394
+ }
3395
+ function resolveHookHomeDir(env = process.env, fallback = os2.homedir()) {
3396
+ return env.HOME || fallback;
3397
+ }
3398
+ async function serverCommand(args) {
3399
+ const port = Number(flagValue(args, "--port") || defaultPort());
3400
+ const debug = args.includes("--verbose") || process.env.LAVISH_AXI_DEBUG === "1";
3401
+ const server = await serve({ port, stateFile: stateFile(), version: VERSION, debug });
3402
+ await server.done;
3403
+ return "";
3404
+ }
3405
+ async function visibleSessions() {
3406
+ const store = new SessionStore(stateFile());
3407
+ return (await store.listSessions()).filter((session) => session.status !== "ended");
3408
+ }
3409
+ async function assertHtmlFile(file) {
3410
+ if (!isHtmlPath(file)) {
3411
+ throw new AxiError("Loupe expects an HTML file", "VALIDATION_ERROR", ["Run `loupe <html-file>`"]);
3412
+ }
3413
+ try {
3414
+ await access(file);
3415
+ } catch {
3416
+ throw new AxiError(`File not found: ${file}`, "NOT_FOUND", [
3417
+ "Create the HTML artifact first, then run `loupe <html-file>`"
3418
+ ]);
3419
+ }
3420
+ }
3421
+ function isHtmlPath(file) {
3422
+ return file.toLowerCase().endsWith(".html") || file.toLowerCase().endsWith(".htm");
3423
+ }
3424
+ async function ensureServer({ forceRestart = false } = {}) {
3425
+ const port = defaultPort();
3426
+ const baseUrl = `http://${hostForUrl(clientHost())}:${port}`;
3427
+ const existing = await fetchHealth(baseUrl);
3428
+ if (existing && !shouldRestartServer(VERSION, existing, forceRestart)) {
3429
+ return baseUrl;
3430
+ }
3431
+ if (existing) {
3432
+ if (!await canControlServerOnPort(port, existing, processOnPortMatchesLavish)) {
3433
+ throw new AxiError(`Port ${port} is occupied by a non-Lavish server`, "SERVER_ERROR", [
3434
+ `Stop the process using port ${port}, or set LAVISH_AXI_PORT to another port`
3435
+ ]);
3436
+ }
3437
+ await requestShutdown(baseUrl);
3438
+ const freed = await waitForPortFree(baseUrl, 2e3);
3439
+ if (!freed) {
3440
+ if (shouldKillProcessOnPort(VERSION, existing)) {
3441
+ killProcessOnPort(port);
3442
+ await waitForPortFree(baseUrl, 3e3);
3443
+ }
3444
+ }
3445
+ }
3446
+ await startServer(port);
3447
+ const deadline = Date.now() + 5e3;
3448
+ while (Date.now() < deadline) {
3449
+ const health = await fetchHealth(baseUrl);
3450
+ if (health && !shouldRestartServer(VERSION, health)) {
3451
+ return baseUrl;
3452
+ }
3453
+ await delay(100);
3454
+ }
3455
+ throw new AxiError("Loupe server did not start", "SERVER_ERROR", [
3456
+ `Run \`loupe server --port ${port}\` to inspect server startup`
3457
+ ]);
3458
+ }
3459
+ function shouldRestartServer(currentVersion, healthBody, forceRestart = false) {
3460
+ if (!healthBody || typeof healthBody !== "object") return false;
3461
+ if (forceRestart && healthBody.app === "lavish-axi") return true;
3462
+ if (typeof healthBody.version !== "string" || healthBody.version === "") return true;
3463
+ return healthBody.version !== currentVersion;
3464
+ }
3465
+ function shouldForceRestartForLocalBuild(executablePath, sourceServerExists = localSourceServerExists()) {
3466
+ const localBuildEntry = fileURLToPath(new URL("../dist/cli.mjs", import.meta.url));
3467
+ return sourceServerExists && path5.resolve(executablePath) === path5.resolve(localBuildEntry);
3468
+ }
3469
+ function localSourceServerExists() {
3470
+ return existsSync(fileURLToPath(new URL("../src/server.js", import.meta.url)));
3471
+ }
3472
+ function shouldKillProcessOnPort(currentVersion, healthBody) {
3473
+ if (!healthBody || typeof healthBody !== "object") return false;
3474
+ if (typeof healthBody.version !== "string" || healthBody.version === "") return true;
3475
+ if (healthBody.app !== "lavish-axi") return false;
3476
+ return healthBody.version !== currentVersion;
3477
+ }
3478
+ async function canControlServerOnPort(port, healthBody, processMatchesLavish) {
3479
+ if (!healthBody || typeof healthBody !== "object") return false;
3480
+ if (healthBody.app === "lavish-axi") return true;
3481
+ if (typeof healthBody.version === "string" && healthBody.version !== "") return false;
3482
+ return processMatchesLavish(port);
3483
+ }
3484
+ async function fetchHealth(baseUrl) {
3485
+ try {
3486
+ const response = await fetch(`${baseUrl}/health`);
3487
+ if (!response.ok) return null;
3488
+ return await response.json();
3489
+ } catch {
3490
+ return null;
3491
+ }
3492
+ }
3493
+ async function requestShutdown(baseUrl) {
3494
+ try {
3495
+ await fetch(`${baseUrl}/shutdown`, { method: "POST" });
3496
+ } catch {
3497
+ }
3498
+ }
3499
+ async function waitForPortFree(baseUrl, timeoutMs) {
3500
+ const deadline = Date.now() + timeoutMs;
3501
+ while (Date.now() < deadline) {
3502
+ if (!await fetchHealth(baseUrl)) return true;
3503
+ await delay(100);
3504
+ }
3505
+ return false;
3506
+ }
3507
+ function killProcessOnPort(port) {
3508
+ try {
3509
+ const result = spawnSync("lsof", ["-t", `-iTCP:${port}`, "-sTCP:LISTEN"], { encoding: "utf8" });
3510
+ if (result.status !== 0) return;
3511
+ for (const line of result.stdout.split("\n")) {
3512
+ const pid = Number(line.trim());
3513
+ if (Number.isInteger(pid) && pid > 0 && pid !== process.pid) {
3514
+ try {
3515
+ process.kill(pid, "SIGTERM");
3516
+ } catch {
3517
+ }
3518
+ }
3519
+ }
3520
+ } catch {
3521
+ }
3522
+ }
3523
+ function processOnPortMatchesLavish(port) {
3524
+ try {
3525
+ const pids = spawnSync("lsof", ["-t", `-iTCP:${port}`, "-sTCP:LISTEN"], { encoding: "utf8" });
3526
+ if (pids.status !== 0) return false;
3527
+ for (const line of pids.stdout.split("\n")) {
3528
+ const pid = Number(line.trim());
3529
+ if (!Number.isInteger(pid) || pid <= 0 || pid === process.pid) continue;
3530
+ const command = spawnSync("ps", ["-p", String(pid), "-o", "command="], { encoding: "utf8" });
3531
+ if (command.status === 0 && /lavish-axi/.test(command.stdout)) {
3532
+ return true;
3533
+ }
3534
+ }
3535
+ } catch {
3536
+ return false;
3537
+ }
3538
+ return false;
3539
+ }
3540
+ async function startServer(port) {
3541
+ await ensureStateDir();
3542
+ const entry = resolveServerEntry();
3543
+ let logFd = null;
3544
+ try {
3545
+ logFd = openSync(serverLogFile(), "a");
3546
+ } catch {
3547
+ }
3548
+ try {
3549
+ const child = spawn(process.execPath, [entry, "server", "--port", String(port)], createServerSpawnOptions(logFd));
3550
+ child.unref();
3551
+ } finally {
3552
+ if (logFd !== null) closeSync(logFd);
3553
+ }
3554
+ }
3555
+ function resolveServerEntry() {
3556
+ const binEntry = fileURLToPath(new URL("../bin/lavish-axi.js", import.meta.url));
3557
+ if (existsSync(binEntry)) return binEntry;
3558
+ return fileURLToPath(import.meta.url);
3559
+ }
3560
+ function createServerSpawnOptions(logFd = null) {
3561
+ const stdio = (
3562
+ /** @type {import("node:child_process").StdioOptions} */
3563
+ logFd === null ? "ignore" : ["ignore", logFd, logFd]
3564
+ );
3565
+ return {
3566
+ detached: true,
3567
+ stdio,
3568
+ env: { ...process.env, LAVISH_AXI_NO_OPEN: "1" }
3569
+ };
3570
+ }
3571
+ async function fetchJson(url, { retries = 0, retryDelayMs = 250 } = {}) {
3572
+ let response;
3573
+ for (let attempt = 0; attempt <= retries; attempt += 1) {
3574
+ try {
3575
+ response = await fetch(url);
3576
+ break;
3577
+ } catch (error) {
3578
+ if (error instanceof AxiError) throw error;
3579
+ if (attempt >= retries) throw serverConnectionError();
3580
+ await delay(retryDelayMs);
3581
+ }
3582
+ }
3583
+ if (!response) throw serverConnectionError();
3584
+ if (!response.ok) {
3585
+ throw new AxiError(`Loupe request failed: ${response.status}`, "SERVER_ERROR");
3586
+ }
3587
+ try {
3588
+ return await response.json();
3589
+ } catch {
3590
+ throw pollResponseInterruptedError();
3591
+ }
3592
+ }
3593
+ async function postJson(url, body) {
3594
+ let response;
3595
+ try {
3596
+ response = await fetch(url, {
3597
+ method: "POST",
3598
+ headers: { "content-type": "application/json" },
3599
+ body: JSON.stringify(body)
3600
+ });
3601
+ } catch {
3602
+ throw serverConnectionError();
3603
+ }
3604
+ if (!response.ok) {
3605
+ throw new AxiError(`Loupe request failed: ${response.status}`, "SERVER_ERROR");
3606
+ }
3607
+ return response.json();
3608
+ }
3609
+ function serverConnectionError() {
3610
+ return new AxiError("Loupe server connection failed", "SERVER_ERROR", [
3611
+ "Run `loupe server --verbose` or inspect `~/.lavish-axi/server.log` (`LAVISH_AXI_STATE_DIR/server.log` when set) for server startup or crash diagnostics",
3612
+ "Re-run the last `loupe poll <html-file>` command after the server is healthy"
3613
+ ]);
3614
+ }
3615
+ function pollResponseInterruptedError() {
3616
+ return new AxiError("Loupe poll response was interrupted", "SERVER_ERROR", [
3617
+ "Run `loupe server --verbose` or inspect `~/.lavish-axi/server.log` (`LAVISH_AXI_STATE_DIR/server.log` when set) for server startup or crash diagnostics",
3618
+ "Re-run the last `loupe poll <html-file>` command after the server is healthy"
3619
+ ]);
3620
+ }
3621
+ function flagValue(args, flag) {
3622
+ const index = args.indexOf(flag);
3623
+ if (index === -1) {
3624
+ return null;
3625
+ }
3626
+ return args[index + 1] || null;
3627
+ }
3628
+ function delay(ms) {
3629
+ return new Promise((resolve) => setTimeout(resolve, ms));
3630
+ }
3631
+ function getCommandHelp(command) {
3632
+ return COMMAND_HELP[command] || null;
3633
+ }
3634
+ var TOP_LEVEL_HELP = `loupe - Loupe
3635
+
3636
+ Usage:
3637
+ loupe
3638
+ loupe <html-file> [--no-open] [--no-gate]
3639
+ loupe new <html-file> [--title "..."] [--problem "..."] [--product-only] [--greenfield] [--force]
3640
+ loupe spec <html-file> [--out <file.md>] [--force]
3641
+ loupe poll <html-file> [--agent-reply "..."]
3642
+ loupe end <html-file>
3643
+ loupe stop
3644
+ loupe playbook [playbook_id]
3645
+ loupe design
3646
+ loupe setup hooks
3647
+
3648
+ ${DESIGN_SYSTEM_HINT}
3649
+
3650
+ Note: poll long-polls indefinitely by default until the user sends feedback, ends the session, or the browser reports fresh layout_warnings, staying silent while it waits - never kill it. Fix layout_warnings before involving the human. Do not pass --timeout-ms during normal agent use; it is for tests and debugging only. If your harness limits how long a foreground command may run, run the poll as a background task; if it gets killed or times out anyway, just re-run it - queued feedback is never lost.
3651
+
3652
+ `;
3653
+ var COMMAND_HELP = {
3654
+ open: `Usage: loupe <html-file> [--no-open] [--no-gate]
3655
+
3656
+ Open or resume a Loupe review session for an HTML artifact. Use --no-open when you need to ensure the server/session exists without opening another browser window. Use --no-gate to skip the open-time layout curtain for this browser open.
3657
+ `,
3658
+ new: `Usage: loupe new <html-file> [--title "..."] [--problem "..."] [--product-only] [--force]
3659
+
3660
+ Write a Loupe scaffold: a self-contained HTML artifact with two lenses, each holding \xA7A Current World, \xA7B Grill, and \xA7C Goal Vision, mermaid containers, and auto-wired Grill Cards. Fill the Product Lens first; leave the Code Lens empty until the user locks the product intent, then fill it from the real codebase. The gate is a soft signal \u2014 the Code Lens is never UI-locked, so it stays editable and annotatable. Fill the \`LOUPE \u2014 fill:\` slots, then open it with \`loupe <html-file>\`. Pass --product-only for a single Product Lens (small changes), or --greenfield when there is no codebase yet (the Code Lens becomes a from-scratch architecture proposal instead of current \u2192 after). Refuses to overwrite an existing file unless --force is passed.
3661
+ `,
3662
+ spec: `Usage: loupe spec <html-file> [--out <file.md>] [--force]
3663
+
3664
+ Write the companion spec for a Loupe artifact: a terse markdown file (default \`<html-file>.spec.md\`) with the decisions list and a link back to the canonical HTML. Run this when the developer clicks Execute; fill the \`\u2026\` slots with what the Session settled, keep it in version control, and use it as the source of truth for implementation. Detects product-only vs dual-lens from the artifact. Refuses to overwrite unless --force.
3665
+ `,
3666
+ poll: `Usage: loupe poll <html-file> [--agent-reply "..."]
3667
+
3668
+ This command long-polls indefinitely for queued user prompts and browser-reported layout_warnings, then returns them to the agent. It stays silent while it waits - that is normal, never kill it. Fix layout_warnings before involving the human. Do not pass --timeout-ms during normal agent use; it is for tests and debugging only. If your harness limits how long a foreground command may run, run the poll as a background task and wait for it to finish; if it still gets killed or times out, just re-run it - queued feedback is never lost. Use --agent-reply after applying prior feedback to display your response in Loupe before waiting again.
3669
+ `,
3670
+ end: `Usage: loupe end <html-file>
3671
+
3672
+ End a Loupe session.
3673
+ `,
3674
+ stop: `Usage: loupe stop [--port <port>]
3675
+
3676
+ Shut down the background Loupe server. The server also stops itself when no browser or poll has been connected for a while (LAVISH_AXI_IDLE_TIMEOUT_MS, default 30m) and immediately when the last session ends with nothing connected.
3677
+ `,
3678
+ playbook: `Usage: loupe playbook [playbook_id]
3679
+
3680
+ List focused artifact guidance playbooks, or show one playbook by ID. Known IDs: diagram, table, comparison, plan, code, input, slides.
3681
+
3682
+ ${PLAYBOOK_ROUTER_HELP}
3683
+
3684
+ Examples:
3685
+ loupe playbook
3686
+ loupe playbook diagram
3687
+ loupe playbook input
3688
+ `,
3689
+ design: `Usage: loupe design
3690
+
3691
+ Show a copy-pasteable CDN snippet for Tailwind CSS browser runtime v4 + DaisyUI v5 + themes, Mermaid diagram tooling, a content-to-playbook router, an optional layout safety CSS snippet, plus technical reference for DaisyUI components. ${PLAYBOOK_ROUTER_HELP} Lavish artifacts stay portable HTML. This CDN snippet is the design fallback, not the default: inspect the subject project before falling back, and paste the layout safety CSS only when useful for dense nested grid/flex layouts, badges, wide fonts, or local media. The strict priority order is: (1) if the user asked for a specific look or named design system, follow that; (2) otherwise, match the design system of the project the artifact is about, not necessarily your current working directory. If the artifact previews, proposes, or mocks a specific app's UI, use that app's own design system; (3) only when both come up empty, prefer the Lavish-recommended Tailwind + DaisyUI CDN snippet over hand-writing styles unless explicitly instructed otherwise by the user.
3692
+ `,
3693
+ setup: `Usage: loupe setup hooks
3694
+
3695
+ Install or repair agent SessionStart hooks for loupe ambient context in Claude Code, Codex, and OpenCode. Restart your agent session afterward to receive the context.
3696
+ `,
3697
+ server: `Usage: loupe server [--port 4387] [--verbose]
3698
+
3699
+ Run the local Loupe server. Pass --verbose (or set LAVISH_AXI_DEBUG=1) to log session and watcher events to stderr. Detached server output is appended to ~/.lavish-axi/server.log, or LAVISH_AXI_STATE_DIR/server.log when set, for startup and crash diagnostics.
3700
+
3701
+ LAVISH_AXI_HOST sets the bind address (default 127.0.0.1; a wildcard 0.0.0.0 or :: binds every interface). Binding beyond loopback exposes an unauthenticated server that can read and serve arbitrary local files to anything that can reach it, so only do so on a trusted network. LAVISH_AXI_LINK_HOST sets the hostname written into generated session links (default: the bind address, or loopback when bound to a wildcard). LAVISH_AXI_NO_OPEN=1 (or --no-open) suppresses the local browser launch.
3702
+ `
3703
+ };
3704
+
3705
+ // bin/lavish-axi.js
3706
+ await run(process.argv.slice(2));