From 70a92464014d2021bb913a493ea28ebc69208183 Mon Sep 17 00:00:00 2001
From: Paul Bakaus
Date: Mon, 27 Apr 2026 13:29:02 -0700
Subject: [PATCH] =?UTF-8?q?fix(plugin):=20slim=20Claude=20Code=20install?=
=?UTF-8?q?=20(291MB=E2=86=92770KB)=20+=20fix=20skills=20path?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Marketplace source moved from "./" to "./plugin", a thin generated
subtree containing only the plugin manifest and the impeccable skill.
Per-version plugin cache shrinks ~378× (~770 KB instead of ~291 MB),
and the lockfile is no longer included in the source path so the
cache extraction never runs bun install. (#107)
- skills field in plugin.json now ends with a trailing slash to match
the documented schema (code.claude.com/docs/en/plugins-reference,
every directory example uses ./path/). Three reporters converged on
this fix because Claude Code's plugin loader skips command
registration on some setups when the slash is missing. (#86)
- Anti-patterns maintenance agent moved out of .claude/agents/ into
CLAUDE.md / AGENTS.md as concise inline guidance, since it is
repo-internal dev workflow, not user-facing. The plugin was also
the only place this agent was exposed to install users.
- Skills version bumped to 3.0.2 so existing users pick up the new
install path on next /plugin update.
- Top-level harness directories (.claude/skills/, .cursor/skills/, ...)
intentionally stay where they are; npx skills add reads them
directly from the GitHub repo and that path is unaffected.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
.claude-plugin/marketplace.json | 4 +-
.claude-plugin/plugin.json | 4 +-
.claude/agents/anti-patterns.md | 257 -
.claude/skills/impeccable/SKILL.md | 2 +-
.cursor/skills/impeccable/SKILL.md | 2 +-
.gemini/skills/impeccable/SKILL.md | 2 +-
.github/skills/impeccable/SKILL.md | 2 +-
.kiro/skills/impeccable/SKILL.md | 2 +-
.opencode/skills/impeccable/SKILL.md | 2 +-
.pi/skills/impeccable/SKILL.md | 2 +-
.rovodev/skills/impeccable/SKILL.md | 2 +-
.trae-cn/skills/impeccable/SKILL.md | 2 +-
.trae/skills/impeccable/SKILL.md | 2 +-
AGENTS.md | 15 +
CLAUDE.md | 36 +
plugin/.claude-plugin/plugin.json | 12 +
plugin/skills/impeccable/SKILL.md | 158 +
plugin/skills/impeccable/reference/adapt.md | 190 +
plugin/skills/impeccable/reference/animate.md | 173 +
plugin/skills/impeccable/reference/audit.md | 134 +
plugin/skills/impeccable/reference/bolder.md | 113 +
plugin/skills/impeccable/reference/brand.md | 104 +
plugin/skills/impeccable/reference/clarify.md | 174 +
.../impeccable/reference/cognitive-load.md | 106 +
.../reference/color-and-contrast.md | 105 +
.../skills/impeccable/reference/colorize.md | 154 +
plugin/skills/impeccable/reference/craft.md | 138 +
.../skills/impeccable/reference/critique.md | 213 +
plugin/skills/impeccable/reference/delight.md | 302 ++
plugin/skills/impeccable/reference/distill.md | 111 +
.../skills/impeccable/reference/document.md | 427 ++
plugin/skills/impeccable/reference/extract.md | 70 +
plugin/skills/impeccable/reference/harden.md | 347 ++
.../reference/heuristics-scoring.md | 234 +
.../reference/interaction-design.md | 195 +
plugin/skills/impeccable/reference/layout.md | 141 +
plugin/skills/impeccable/reference/live.md | 513 ++
.../impeccable/reference/motion-design.md | 99 +
plugin/skills/impeccable/reference/onboard.md | 234 +
.../skills/impeccable/reference/optimize.md | 258 +
.../skills/impeccable/reference/overdrive.md | 130 +
.../skills/impeccable/reference/personas.md | 178 +
plugin/skills/impeccable/reference/polish.md | 232 +
plugin/skills/impeccable/reference/product.md | 62 +
plugin/skills/impeccable/reference/quieter.md | 99 +
.../impeccable/reference/responsive-design.md | 114 +
plugin/skills/impeccable/reference/shape.md | 136 +
.../impeccable/reference/spatial-design.md | 100 +
plugin/skills/impeccable/reference/teach.md | 137 +
plugin/skills/impeccable/reference/typeset.md | 124 +
.../skills/impeccable/reference/typography.md | 159 +
.../skills/impeccable/reference/ux-writing.md | 107 +
.../impeccable/scripts/cleanup-deprecated.mjs | 284 +
.../impeccable/scripts/command-metadata.json | 94 +
.../impeccable/scripts/design-parser.mjs | 820 +++
.../skills/impeccable/scripts/detect-csp.mjs | 198 +
.../impeccable/scripts/is-generated.mjs | 69 +
.../skills/impeccable/scripts/live-accept.mjs | 465 ++
.../skills/impeccable/scripts/live-browser.js | 4684 +++++++++++++++++
.../skills/impeccable/scripts/live-inject.mjs | 436 ++
.../skills/impeccable/scripts/live-poll.mjs | 187 +
.../skills/impeccable/scripts/live-server.mjs | 679 +++
.../skills/impeccable/scripts/live-wrap.mjs | 395 ++
plugin/skills/impeccable/scripts/live.mjs | 247 +
.../impeccable/scripts/load-context.mjs | 93 +
.../scripts/modern-screenshot.umd.js | 14 +
plugin/skills/impeccable/scripts/pin.mjs | 214 +
public/index.html | 13 +-
scripts/build.js | 31 +
69 files changed, 15270 insertions(+), 272 deletions(-)
delete mode 100644 .claude/agents/anti-patterns.md
create mode 100644 plugin/.claude-plugin/plugin.json
create mode 100644 plugin/skills/impeccable/SKILL.md
create mode 100644 plugin/skills/impeccable/reference/adapt.md
create mode 100644 plugin/skills/impeccable/reference/animate.md
create mode 100644 plugin/skills/impeccable/reference/audit.md
create mode 100644 plugin/skills/impeccable/reference/bolder.md
create mode 100644 plugin/skills/impeccable/reference/brand.md
create mode 100644 plugin/skills/impeccable/reference/clarify.md
create mode 100644 plugin/skills/impeccable/reference/cognitive-load.md
create mode 100644 plugin/skills/impeccable/reference/color-and-contrast.md
create mode 100644 plugin/skills/impeccable/reference/colorize.md
create mode 100644 plugin/skills/impeccable/reference/craft.md
create mode 100644 plugin/skills/impeccable/reference/critique.md
create mode 100644 plugin/skills/impeccable/reference/delight.md
create mode 100644 plugin/skills/impeccable/reference/distill.md
create mode 100644 plugin/skills/impeccable/reference/document.md
create mode 100644 plugin/skills/impeccable/reference/extract.md
create mode 100644 plugin/skills/impeccable/reference/harden.md
create mode 100644 plugin/skills/impeccable/reference/heuristics-scoring.md
create mode 100644 plugin/skills/impeccable/reference/interaction-design.md
create mode 100644 plugin/skills/impeccable/reference/layout.md
create mode 100644 plugin/skills/impeccable/reference/live.md
create mode 100644 plugin/skills/impeccable/reference/motion-design.md
create mode 100644 plugin/skills/impeccable/reference/onboard.md
create mode 100644 plugin/skills/impeccable/reference/optimize.md
create mode 100644 plugin/skills/impeccable/reference/overdrive.md
create mode 100644 plugin/skills/impeccable/reference/personas.md
create mode 100644 plugin/skills/impeccable/reference/polish.md
create mode 100644 plugin/skills/impeccable/reference/product.md
create mode 100644 plugin/skills/impeccable/reference/quieter.md
create mode 100644 plugin/skills/impeccable/reference/responsive-design.md
create mode 100644 plugin/skills/impeccable/reference/shape.md
create mode 100644 plugin/skills/impeccable/reference/spatial-design.md
create mode 100644 plugin/skills/impeccable/reference/teach.md
create mode 100644 plugin/skills/impeccable/reference/typeset.md
create mode 100644 plugin/skills/impeccable/reference/typography.md
create mode 100644 plugin/skills/impeccable/reference/ux-writing.md
create mode 100644 plugin/skills/impeccable/scripts/cleanup-deprecated.mjs
create mode 100644 plugin/skills/impeccable/scripts/command-metadata.json
create mode 100644 plugin/skills/impeccable/scripts/design-parser.mjs
create mode 100644 plugin/skills/impeccable/scripts/detect-csp.mjs
create mode 100644 plugin/skills/impeccable/scripts/is-generated.mjs
create mode 100644 plugin/skills/impeccable/scripts/live-accept.mjs
create mode 100644 plugin/skills/impeccable/scripts/live-browser.js
create mode 100644 plugin/skills/impeccable/scripts/live-inject.mjs
create mode 100644 plugin/skills/impeccable/scripts/live-poll.mjs
create mode 100644 plugin/skills/impeccable/scripts/live-server.mjs
create mode 100644 plugin/skills/impeccable/scripts/live-wrap.mjs
create mode 100644 plugin/skills/impeccable/scripts/live.mjs
create mode 100644 plugin/skills/impeccable/scripts/load-context.mjs
create mode 100644 plugin/skills/impeccable/scripts/modern-screenshot.umd.js
create mode 100644 plugin/skills/impeccable/scripts/pin.mjs
diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json
index 87fd62d13..fa37cfbdf 100644
--- a/.claude-plugin/marketplace.json
+++ b/.claude-plugin/marketplace.json
@@ -12,12 +12,12 @@
{
"name": "impeccable",
"description": "Design fluency for frontend development. 1 skill with 23 commands (/impeccable polish, /impeccable audit, /impeccable critique, etc.) and curated anti-pattern detection.",
- "version": "3.0.1",
+ "version": "3.0.2",
"author": {
"name": "Paul Bakaus",
"email": "paul@paulbakaus.com"
},
- "source": "./",
+ "source": "./plugin",
"category": "design",
"homepage": "https://impeccable.style",
"tags": ["design", "frontend", "ui", "ux", "skills", "commands"]
diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json
index 3d9b26d9d..7155a776c 100644
--- a/.claude-plugin/plugin.json
+++ b/.claude-plugin/plugin.json
@@ -1,12 +1,12 @@
{
"name": "impeccable",
"description": "Design fluency for frontend development. 1 skill with 23 commands (/impeccable polish, /impeccable audit, /impeccable critique, etc.) and curated anti-pattern detection.",
- "version": "3.0.1",
+ "version": "3.0.2",
"author": {
"name": "Paul Bakaus",
"email": "paul@paulbakaus.com"
},
"homepage": "https://impeccable.style",
"repository": "https://github.com/pbakaus/impeccable",
- "skills": "./.claude/skills"
+ "skills": "./.claude/skills/"
}
diff --git a/.claude/agents/anti-patterns.md b/.claude/agents/anti-patterns.md
deleted file mode 100644
index 75528a381..000000000
--- a/.claude/agents/anti-patterns.md
+++ /dev/null
@@ -1,257 +0,0 @@
----
-name: anti-patterns
-description: Use when adding, modifying, or debugging an anti-pattern detection rule in this repo. Walks through the TDD recipe, the rule schema, all five plug-in points, jsdom constraints, and the post-implementation checklist. Trigger this for any work touching src/detect-antipatterns.mjs, tests/fixtures/antipatterns/, or extension/detector/.
-tools: Read, Edit, Write, Glob, Grep, Bash, mcp__claude-in-chrome__navigate, mcp__claude-in-chrome__javascript_tool, mcp__claude-in-chrome__tabs_context_mcp, mcp__claude-in-chrome__tabs_create_mcp
----
-
-# Anti-Pattern Engine Maintenance
-
-This agent handles every step of adding or modifying an anti-pattern detection rule in the impeccable repo. The rule engine is wired into many places — tests, browser bundle, extension detector, extension panel JSON, homepage count, and the skill content — and missing a step causes silent drift between them.
-
-## The five things that need to stay in sync
-
-When you add a rule, all of these update or get regenerated:
-
-| Where | What | How it stays in sync |
-|---|---|---|
-| `src/detect-antipatterns.mjs` `ANTIPATTERNS` | Rule metadata (id, category, name, description, skillSection, skillGuideline) and the detection logic (`checkXxx`) | **Hand-edited.** Source of truth. |
-| `src/detect-antipatterns-browser.js` | Browser-bundled engine for the public site overlay | Generated by `bun run build:browser` |
-| `extension/detector/detect.js` | Browser-bundled engine for the Chrome extension | Generated by `bun run build:extension` |
-| `extension/detector/antipatterns.json` | Rule list (id, name, category, description) for the extension's devtools panel — drives rule toggles UI | Generated by `bun run build:extension` |
-| `public/js/generated/counts.js` | `DETECTION_COUNT` integer for homepage display | Generated by `bun run build` |
-| `source/skills/impeccable/SKILL.md` and `reference/*.md` | Design guidance that a human or LLM reads. Can reference anti-patterns in its own voice. | **Hand-edited**, alongside the rule. Drift is a code-review concern, not a programmatic one. |
-
-The CLI (`bin/cli.js`) imports `ANTIPATTERNS` directly from `src/detect-antipatterns.mjs` — no separate sync needed.
-
-## Rule schema
-
-Each entry in the `ANTIPATTERNS` array (around src/detect-antipatterns.mjs:77) looks like this:
-
-```js
-{
- id: 'icon-tile-stack', // kebab-case, unique, stable
- category: 'slop', // 'slop' or 'quality' (see below)
- name: 'Icon tile stacked above heading', // human-readable, used in extension UI
- description: // 1–2 sentences. Used in CLI output, extension tooltips, web overlay labels
- 'A small rounded-square icon container above a heading is the universal AI feature-card template — every generator outputs this exact shape. Try a side-by-side icon and heading, or let the icon sit in flow without its own container.',
- skillSection: 'Typography', // OPTIONAL. The logical skill section this rule maps to; used for /docs/impeccable deep-links.
- skillGuideline: 'large icons with rounded corners above every heading', // OPTIONAL. Canonical short phrasing for the rule; used in CLI output and as a linkable fragment.
-}
-```
-
-### Categories
-
-- **`slop`** = "AI tells". Patterns that scream *AI generated this*. Things like purple gradients, gradient text, dark glow accents, thick side borders, icon-tile-stacks. Flagging these is about taste and freshness, not correctness.
-- **`quality`** = real design or accessibility issues regardless of who wrote the code. WCAG contrast, line length, padding, line height, justified text, skipped headings, etc.
-
-If you're not sure, ask: *"would a human designer who's careful and tasteful still ship this?"* If no, it's `quality`. If they would (because it works fine, it just looks templated), it's `slop`.
-
-### `skillSection` values
-
-The value is used by `scripts/build-sub-pages.js` to build deep links into the impeccable docs page. Use one of the logical sections the skill groups rules under (e.g. `Typography`, `Color`, `Layout`, `Motion`, `Visual Details`). If the section you pick matches an `### Heading` somewhere in the skill body, the deep link will land precisely; otherwise it falls back to the section's top. Omit entirely for rules that don't have a natural home in the skill.
-
-### `skillGuideline` phrasing
-
-Canonical short phrasing for the rule (3–6 words). Used as the CLI output label when `npx impeccable detect` reports a violation, and as human-readable text in the extension's devtools panel. The skill's prose may or may not echo this phrasing verbatim — the skill is the design-guidance document, not a rule manifest.
-
-Examples: `'AI color palette'`, `'large icons with rounded corners above every heading'`, `'WCAG AA contrast'`.
-
-Omit if the rule doesn't need a short label (rare — only niche a11y-only rules).
-
-## The TDD recipe (always do it in this order)
-
-This order is non-negotiable. Fixture and failing test before implementation. The full suite must run between the rule going in and you committing.
-
-### 1. Write the fixture (two-column convention)
-
-A single HTML file at `tests/fixtures/antipatterns/{rule-id}.html` with two columns: left = should-flag, right = should-pass. Each test case carries a unique heading text so the test can match snippets back to expectations.
-
-Convention skeleton:
-
-```html
-
-
-
-
-
-
-
-
-
Should flag
-
-
-
-
Should pass
-
-
-
-
-
-
-```
-
-The script tag at the bottom is critical — it lets you load the fixture in the browser via `http://localhost:3000/fixtures/antipatterns/{rule-id}.html` (served by `server/index.js:62` route for `/fixtures/*`).
-
-**Should-pass cases must cover the false-positive shapes you can think of in advance.** A good fixture has 5+ pass cases. The icon-tile-stack fixture covers: round avatar, wide thumbnail, side-by-side, no-icon, too-tiny, too-huge.
-
-### 2. Write the failing test
-
-Add to `tests/detect-antipatterns-fixtures.test.mjs` in its own `describe` block. Use the snippet-substring matching pattern — the test parses heading text out of each finding's snippet and asserts membership against expected lists:
-
-```js
-describe('detectHtml — {rule-id}', () => {
- const SHOULD_FLAG = ['Heading One', 'Heading Two', /* ... */];
- const SHOULD_PASS = ['Pass Heading One', /* ... */];
-
- it('{rule-id}: flags only the should-flag column', async () => {
- const f = await detectHtml(path.join(FIXTURES, '{rule-id}.html'));
- const flagged = new Set();
- for (const r of f) {
- if (r.antipattern !== '{rule-id}') continue;
- const m = (r.snippet || '').match(/"([^"]+)"/);
- if (m) flagged.add(m[1]);
- }
- for (const text of SHOULD_FLAG) {
- assert.ok(flagged.has(text), `expected "${text}" to be flagged`);
- }
- for (const text of SHOULD_PASS) {
- assert.ok(!flagged.has(text), `"${text}" should NOT be flagged`);
- }
- });
-});
-```
-
-For this to work, the rule's snippet **must include the heading text in quotes**. See "Snippet conventions" below.
-
-Run `node --test tests/detect-antipatterns-fixtures.test.mjs` and **watch it fail**. If it doesn't fail, your test is wrong.
-
-### 3. Add the rule definition
-
-Add a new entry to the `ANTIPATTERNS` array in `src/detect-antipatterns.mjs`. Place it in the right category section (slop or quality). Fill in all fields including `skillSection` and `skillGuideline`.
-
-### 4. Implement the pure check function
-
-Add a `checkXxx(opts)` function alongside the others (`checkColors`, `checkBorders`, `checkMotion`, `checkGlow`, `checkIconTile`, etc.). The pure function takes a plain options object — no DOM access — and returns an array of `{ id, snippet }`. This makes it testable and reusable across the browser/Node adapters.
-
-Example shape (see `checkIconTile` in src/detect-antipatterns.mjs for a real one):
-
-```js
-function checkXxx(opts) {
- const { tag, /* whatever fields the rule needs */ } = opts;
- if (SAFE_TAGS.has(tag)) return [];
- // ... your detection logic ...
- if (matches) {
- return [{ id: 'rule-id', snippet: `... "${headingText}"` }];
- }
- return [];
-}
-```
-
-### 5. Add the two adapters
-
-Two adapters wrap the pure function with environment-specific input gathering:
-
-- **`checkElementXxxDOM(el)`** — for the browser. Uses `getComputedStyle(el)` and `el.getBoundingClientRect()`.
-- **`checkElementXxx(el, tag, window)`** — for jsdom (Node). Uses `window.getComputedStyle(el)` and **must read explicit pixel dimensions from `parseFloat(style.width)`** instead of bounding rects, because **jsdom does not lay out** — `getBoundingClientRect()` returns 0×0 for everything.
-
-If your rule needs vertical positioning info (e.g. "icon must be above heading"), that check is browser-only — gate it behind `if (headingTop && siblingBottom)` so the Node path skips it. The structural checks alone (sizes, sibling identity, classes) are enough for the fixture.
-
-### 6. Wire into both element-iteration loops
-
-Two loops iterate every element on the page. You need to add your DOM-adapter call to **both**:
-
-- **Browser loop** at src/detect-antipatterns.mjs:1837 (`for (const el of document.querySelectorAll('*'))` with the `findings` spread). Add a line like:
- ```js
- ...checkElementXxxDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
- ```
-- **Node (jsdom) loop** at src/detect-antipatterns.mjs:2058 (in `detectHtml`). Add a block like:
- ```js
- for (const f of checkElementXxx(el, tag, window)) {
- findings.push(finding(f.id, filePath, f.snippet));
- }
- ```
-
-Forgetting one of these is the most common mistake — the test passes but the live page doesn't show anything (or vice versa).
-
-### 7. Decide whether the skill needs an update
-
-If the rule introduces a new design concept not already covered by the impeccable skill, update `source/skills/impeccable/SKILL.md` (or the appropriate register file in `reference/editorial.md` / `reference/product.md`) to teach the concept. The skill is a design-guidance document — it doesn't need to echo every rule verbatim, and one skill line can cover multiple engine rules. Only add prose if there's a real gap in the guidance.
-
-### 8. Run the build (regenerates everything)
-
-```bash
-bun run build && bun run build:browser && bun run build:extension
-```
-
-This regenerates:
-- `src/detect-antipatterns-browser.js` (public-site detector)
-- `extension/detector/detect.js` (extension detector)
-- `extension/detector/antipatterns.json` (extension rule list, includes description)
-- `public/js/generated/counts.js` (DETECTION_COUNT)
-
-### 9. Run the test suite
-
-```bash
-bun run test
-```
-
-166 unit tests + N fixture tests, including your new one. All should be green.
-
-### 10. Verify on a live page in the browser
-
-Don't skip this. The jsdom path uses `parseFloat(style.width)` and the browser path uses `getBoundingClientRect()` — they can disagree. The fixture test catches one path; manual browser verification catches the other.
-
-```
-http://localhost:3000/fixtures/antipatterns/{rule-id}.html
-http://localhost:3000/antipattern-examples/{your-example}.html (if relevant)
-http://localhost:3000/ (no false positives on real pages)
-```
-
-Use the chrome MCP tools (`mcp__claude-in-chrome__navigate` + `mcp__claude-in-chrome__javascript_tool`) to inject `window.impeccableScan()` and read `.impeccable-overlay` / `.impeccable-label` from the DOM to verify. Don't try to screenshot — the overlays are decorative; read them programmatically.
-
-## Snippet conventions
-
-The fixture-test convention extracts the heading text from a finding's snippet using regex `/"([^"]+)"/` — so **wrap the identifying heading text in straight double quotes** in your snippet. Examples:
-
-- `'80x80px icon tile above h3 "Lightning Fast"'`
-- `'4.5:1 (need 4.5:1) — text #808080 on #3b82f6'` ← uses element identifiers instead, since this rule isn't anchored to a heading
-
-If your rule isn't naturally anchored to a heading, pick another stable identifier (a class name, the parent element's text, etc.) and document the test pattern in the test itself.
-
-## jsdom constraints (the most common gotcha)
-
-- **No layout.** `getBoundingClientRect()` returns `0×0` always. Read `parseFloat(style.width)` and `parseFloat(style.height)` instead — jsdom does honor explicit pixel widths in `
+
+
+
+
+
+
+
+
+
+```
+
+**Each variant div contains exactly one top-level element — the full replacement for the original.** Use the same tag as the original (e.g. `` if the user picked a ``). Loose siblings (heading + paragraph + div as direct children of the variant div) break the outline tracking and the accept flow, which both assume one child.
+
+The first variant has no `display: none` (visible by default). All others do. If variants use only inline styles and no scoped CSS, omit the `' : ''));
+ if (paramValues && Object.keys(paramValues).length > 0) {
+ // Preserve the user's knob positions for the carbonize-cleanup agent
+ // to bake into the final CSS when it collapses scoped rules.
+ replacement.push(indent + commentSyntax.open + ' impeccable-param-values ' + id + ': ' + JSON.stringify(paramValues) + ' ' + commentSyntax.close);
+ }
+ replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close);
+ }
+
+ // Keep the `@scope ([data-impeccable-variant="N"])` selectors in the
+ // carbonize CSS block working visually by re-wrapping the accepted content
+ // in a data-impeccable-variant="N" div with `display: contents` (so layout
+ // isn't affected). The carbonize agent strips this attribute + wrapper when
+ // it moves the CSS to a proper stylesheet.
+ //
+ // Style attribute syntax has to follow the host file's flavor — JSX files
+ // need the object form, otherwise React 19 throws "Failed to set indexed
+ // property [0] on CSSStyleDeclaration" while parsing the string char-by-char.
+ if (cssContent) {
+ const isJsx = commentSyntax.open === '{/*';
+ const styleAttr = isJsx ? "style={{ display: 'contents' }}" : 'style="display: contents"';
+ replacement.push(indent + '');
+ replacement.push(...restored);
+ replacement.push(indent + '
');
+ } else {
+ replacement.push(...restored);
+ }
+
+ const newLines = [
+ ...lines.slice(0, block.start),
+ ...replacement,
+ ...lines.slice(block.end + 1),
+ ];
+ fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
+
+ return { carbonize: needsCarbonize };
+}
+
+// ---------------------------------------------------------------------------
+// Parsing helpers
+// ---------------------------------------------------------------------------
+
+/**
+ * Find the start/end marker lines for a session.
+ * Returns { start, end } (0-indexed line numbers) or null.
+ */
+function findMarkerBlock(id, lines) {
+ let start = -1;
+ let end = -1;
+ const startPattern = 'impeccable-variants-start ' + id;
+ const endPattern = 'impeccable-variants-end ' + id;
+
+ for (let i = 0; i < lines.length; i++) {
+ if (start === -1 && lines[i].includes(startPattern)) start = i;
+ if (lines[i].includes(endPattern)) { end = i; break; }
+ }
+
+ return (start !== -1 && end !== -1) ? { start, end } : null;
+}
+
+/**
+ * Join wrapper lines into a single string with `` to close on)
+ * - Same-line `` blocks
+ * - Multi-line `` blocks
+ */
+function stripStyleAndJoin(lines, block) {
+ const out = [];
+ let inStyle = false;
+ for (let i = block.start; i <= block.end; i++) {
+ let line = lines[i];
+
+ if (!inStyle) {
+ // Strip any complete .
+ const closeIdx = line.search(/<\/style\s*>/);
+ if (closeIdx !== -1) {
+ inStyle = false;
+ out.push(line.slice(closeIdx).replace(/<\/style\s*>/, ''));
+ }
+ // else: skip line entirely
+ }
+ }
+ return out.join('\n');
+}
+
+/**
+ * Find the inner content of `…` inside `text`,
+ * handling nested same-tag elements via depth counting. `attrMatch` is a
+ * regex source fragment that must appear inside the opener tag.
+ * Returns the inner string (may be empty), or null if not found.
+ */
+function extractInnerByAttr(text, attrMatch) {
+ const openerRe = new RegExp('<([A-Za-z][A-Za-z0-9]*)\\b[^>]*' + attrMatch + '[^>]*>');
+ const openMatch = text.match(openerRe);
+ if (!openMatch) return null;
+
+ const tagName = openMatch[1];
+ const innerStart = openMatch.index + openMatch[0].length;
+
+ // Match any opener or closer of this tag name after innerStart.
+ // (Does not match self-closing , which doesn't contribute to depth.)
+ const tagRe = new RegExp('<(?:/)?' + tagName + '\\b[^>]*>', 'g');
+ tagRe.lastIndex = innerStart;
+
+ let depth = 1;
+ let m;
+ while ((m = tagRe.exec(text))) {
+ const isClose = m[0].startsWith('');
+ const isSelfClose = !isClose && /\/\s*>$/.test(m[0]);
+ if (isClose) {
+ depth--;
+ if (depth === 0) return text.slice(innerStart, m.index);
+ } else if (!isSelfClose) {
+ depth++;
+ }
+ }
+ return null;
+}
+
+/**
+ * Extract the original element content from within the variant wrapper.
+ * Returns an array of lines.
+ */
+function extractOriginal(lines, block) {
+ const text = stripStyleAndJoin(lines, block);
+ const inner = extractInnerByAttr(text, 'data-impeccable-variant="original"');
+ if (inner === null) return [];
+ return inner.split('\n');
+}
+
+/**
+ * Extract a specific variant's inner content (stripping the wrapper div).
+ * Returns an array of lines, or null if not found.
+ */
+function extractVariant(lines, block, variantNum) {
+ const text = stripStyleAndJoin(lines, block);
+ const inner = extractInnerByAttr(text, 'data-impeccable-variant="' + variantNum + '"');
+ if (inner === null) return null;
+ const result = inner.split('\n');
+ // Collapse a lone empty leading/trailing line (common after string splice).
+ while (result.length > 1 && result[0].trim() === '') result.shift();
+ while (result.length > 1 && result[result.length - 1].trim() === '') result.pop();
+ return result.length > 0 ? result : null;
+}
+
+/**
+ * Extract the colocated ` — return the inner content.
+ * 3. Multi-line: `` on a later line — return
+ * the lines between them.
+ */
+function extractCss(lines, block, id) {
+ const styleAttr = 'data-impeccable-css="' + id + '"';
+ let inStyle = false;
+ const content = [];
+
+ for (let i = block.start; i <= block.end; i++) {
+ const line = lines[i];
+
+ if (!inStyle && line.includes(styleAttr)) {
+ // Self-closing: nothing to carbonize.
+ if (/ anywhere on the line — JSX template-literal closes
+ // (`}`) put the close mid-line, and we don't want to absorb the
+ // template-literal punctuation as CSS content.
+ const closeIdx = line.indexOf('');
+ if (closeIdx !== -1) break;
+ content.push(line);
+ }
+ }
+
+ return content.length > 0 ? content : null;
+}
+
+/**
+ * De-indent content that was indented by live-wrap.mjs.
+ * The wrap script adds `indent + ' '` (4 extra spaces) to each line.
+ * We restore to just `indent` level.
+ */
+function deindentContent(contentLines, baseIndent) {
+ // Find the minimum indentation in the content to determine how much was added
+ let minIndent = Infinity;
+ for (const line of contentLines) {
+ if (line.trim() === '') continue;
+ const leadingSpaces = line.match(/^(\s*)/)[1].length;
+ minIndent = Math.min(minIndent, leadingSpaces);
+ }
+ if (minIndent === Infinity) minIndent = 0;
+
+ // Strip the extra indentation and re-add base indent
+ return contentLines.map(line => {
+ if (line.trim() === '') return '';
+ return baseIndent + line.slice(minIndent);
+ });
+}
+
+function detectCommentSyntax(filePath) {
+ const ext = path.extname(filePath).toLowerCase();
+ if (ext === '.jsx' || ext === '.tsx') {
+ return { open: '{/*', close: '*/}' };
+ }
+ return { open: '' };
+}
+
+// ---------------------------------------------------------------------------
+// File search (find the file containing session markers)
+// ---------------------------------------------------------------------------
+
+function findSessionFile(id, cwd) {
+ const marker = 'impeccable-variants-start ' + id;
+ const searchDirs = ['src', 'app', 'pages', 'components', 'public', 'views', 'templates', '.'];
+ const seen = new Set();
+
+ for (const dir of searchDirs) {
+ const absDir = path.join(cwd, dir);
+ if (!fs.existsSync(absDir)) continue;
+ const result = searchDir(absDir, marker, seen, 0);
+ if (result) {
+ const content = fs.readFileSync(result, 'utf-8');
+ return { file: result, content, lines: content.split('\n') };
+ }
+ }
+ return null;
+}
+
+function searchDir(dir, query, seen, depth) {
+ if (depth > 5) return null;
+ let realDir;
+ try { realDir = fs.realpathSync(dir); } catch { return null; }
+ if (seen.has(realDir)) return null;
+ seen.add(realDir);
+
+ let entries;
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
+ catch { return null; }
+
+ for (const entry of entries) {
+ if (!entry.isFile()) continue;
+ if (!EXTENSIONS.includes(path.extname(entry.name).toLowerCase())) continue;
+ const filePath = path.join(dir, entry.name);
+ try {
+ const content = fs.readFileSync(filePath, 'utf-8');
+ if (content.includes(query)) return filePath;
+ } catch { /* skip */ }
+ }
+
+ for (const entry of entries) {
+ if (!entry.isDirectory()) continue;
+ if (['node_modules', '.git', 'dist', 'build'].includes(entry.name)) continue;
+ const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1);
+ if (result) return result;
+ }
+
+ return null;
+}
+
+// ---------------------------------------------------------------------------
+// Utilities
+// ---------------------------------------------------------------------------
+
+function argVal(args, flag) {
+ const idx = args.indexOf(flag);
+ return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : null;
+}
+
+// Auto-execute when run directly
+const _running = process.argv[1];
+if (_running?.endsWith('live-accept.mjs') || _running?.endsWith('live-accept.mjs/')) {
+ acceptCli();
+}
+
+export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax };
diff --git a/plugin/skills/impeccable/scripts/live-browser.js b/plugin/skills/impeccable/scripts/live-browser.js
new file mode 100644
index 000000000..66a54435a
--- /dev/null
+++ b/plugin/skills/impeccable/scripts/live-browser.js
@@ -0,0 +1,4684 @@
+/**
+ * Impeccable Live Variant Mode — Browser Script
+ *
+ * Injected into the user's page via \n' +
+ open + ' ' + MARKER_CLOSE_TEXT + ' ' + close + '\n'
+ );
+}
+
+function insertTag(content, config, port) {
+ const block = buildTagBlock(config.commentSyntax, port);
+ // insertBefore: match the LAST occurrence. Anchors like `
` open near the top of the document.
+ const idx = content.indexOf(config.insertAfter);
+ if (idx === -1) return content;
+ const after = idx + config.insertAfter.length;
+ // Preserve a single trailing newline if the anchor didn't end with one
+ const prefix = content[after] === '\n' ? content.slice(0, after + 1) : content.slice(0, after) + '\n';
+ return prefix + block + content.slice(prefix.length);
+}
+
+/**
+ * Remove the live script block. Matches either HTML or JSX comment markers
+ * regardless of config (so stale tags from a wrong config can still be cleaned).
+ *
+ * Indent-preserving: captures any whitespace immediately preceding the opener
+ * marker and re-emits it in place of the removed block. `insertTag` inserted
+ * the block *after* the original line's indent and *before* the anchor (e.g.
+ * `` naturally
+ // belong at the end, and the same literal can appear earlier in code blocks
+ // within rendered documentation pages.
+ if (config.insertBefore) {
+ const idx = content.lastIndexOf(config.insertBefore);
+ if (idx === -1) return content;
+ return content.slice(0, idx) + block + content.slice(idx);
+ }
+ // insertAfter: match the FIRST occurrence — typical anchors like `
` or
+ // `
`), which moved the indent onto the opener line and left the anchor
+ * unindented. Replacing the whole block (plus its trailing newline) with just
+ * the captured indent hands the indent back to the anchor that follows.
+ */
+function removeTag(content, _syntax) {
+ const patterns = [
+ /([ \t]*)[\s\S]*?[ \t]*\n/,
+ /([ \t]*)\{\/\*\s*impeccable-live-start\s*\*\/\}[\s\S]*?\{\/\*\s*impeccable-live-end\s*\*\/\}[ \t]*\n/,
+ ];
+ for (const pat of patterns) {
+ const next = content.replace(pat, '$1');
+ if (next !== content) return next;
+ }
+ return content;
+}
+
+// ---------------------------------------------------------------------------
+// Content-Security-Policy meta-tag patcher
+//
+// When the user's HTML carries ``,
+// the cross-origin load of /live.js (and the SSE/POST connection back to
+// localhost:PORT) is blocked unless the CSP explicitly allows that origin.
+//
+// On insert: append `http://localhost:PORT` to `script-src` and `connect-src`,
+// and stash the original `content` value in a `data-impeccable-csp-original`
+// attribute (base64) so revert is exact.
+//
+// On remove: detect the marker attribute, decode it, restore the original
+// content value verbatim, drop the marker.
+//
+// Header-based CSP (Next.js headers, Nuxt routeRules, SvelteKit kit.csp,
+// shared helpers) is NOT patched here — those need framework-specific config
+// edits and are handled via the existing detect-csp.mjs reference output.
+// Only the in-source meta-tag form gets the auto-patch.
+// ---------------------------------------------------------------------------
+
+const CSP_MARKER_ATTR = 'data-impeccable-csp-original';
+
+function findCspMetaTags(content) {
+ const out = [];
+ const tagRe = /]*?)\/?>/gis;
+ let m;
+ while ((m = tagRe.exec(content)) !== null) {
+ const attrs = m[1];
+ if (!/(http-equiv|httpEquiv)\s*=\s*(['"])Content-Security-Policy\2/i.test(attrs)) continue;
+ out.push({ start: m.index, end: m.index + m[0].length, full: m[0], attrs });
+ }
+ return out;
+}
+
+function getAttr(attrs, name) {
+ const re = new RegExp(`\\b${name}\\s*=\\s*(['"])([\\s\\S]*?)\\1`, 'i');
+ const m = attrs.match(re);
+ return m ? { quote: m[1], value: m[2], full: m[0] } : null;
+}
+
+function appendOriginToDirective(csp, directive, origin) {
+ const re = new RegExp(`(^|;)(\\s*)(${directive})\\s+([^;]*)`, 'i');
+ const m = csp.match(re);
+ if (m) {
+ const tokens = m[4].trim().split(/\s+/);
+ if (tokens.includes(origin)) return csp;
+ return csp.replace(re, `${m[1]}${m[2]}${m[3]} ${[...tokens, origin].join(' ')}`);
+ }
+ // Directive missing — add it. Use 'self' + origin so we don't inadvertently
+ // narrow the policy compared to the default-src fallback (most users with
+ // an explicit CSP have 'self' there).
+ return csp.trim().replace(/;?\s*$/, '') + `; ${directive} 'self' ${origin}`;
+}
+
+export function patchCspMeta(content, port) {
+ const tags = findCspMetaTags(content);
+ if (tags.length === 0) return content;
+ const origin = `http://localhost:${port}`;
+
+ // Walk last-to-first so prior splices don't invalidate later indices.
+ let result = content;
+ for (let i = tags.length - 1; i >= 0; i--) {
+ const tag = tags[i];
+ const attrs = tag.attrs;
+ if (getAttr(attrs, CSP_MARKER_ATTR)) continue; // already patched
+ const contentAttr = getAttr(attrs, 'content');
+ if (!contentAttr) continue;
+
+ const original = contentAttr.value;
+ let patched = original;
+ patched = appendOriginToDirective(patched, 'script-src', origin);
+ patched = appendOriginToDirective(patched, 'connect-src', origin);
+ // The shader overlay during 'generating' creates a screenshot via
+ // URL.createObjectURL, producing a `blob:` URL — img-src 'self' rejects
+ // those. Add `blob:` so the overlay doesn't throw a CSP violation.
+ patched = appendOriginToDirective(patched, 'img-src', 'blob:');
+ if (patched === original) continue;
+
+ const newContentAttr = `content=${contentAttr.quote}${patched}${contentAttr.quote}`;
+ const marker = `${CSP_MARKER_ATTR}="${Buffer.from(original, 'utf-8').toString('base64')}"`;
+ const newAttrs = attrs.replace(contentAttr.full, newContentAttr) + ' ' + marker;
+ const newTag = tag.full.replace(attrs, newAttrs);
+
+ result = result.slice(0, tag.start) + newTag + result.slice(tag.end);
+ }
+ return result;
+}
+
+export function revertCspMeta(content) {
+ const tags = findCspMetaTags(content);
+ if (tags.length === 0) return content;
+
+ let result = content;
+ for (let i = tags.length - 1; i >= 0; i--) {
+ const tag = tags[i];
+ const origAttr = getAttr(tag.attrs, CSP_MARKER_ATTR);
+ if (!origAttr) continue;
+ const contentAttr = getAttr(tag.attrs, 'content');
+ if (!contentAttr) continue;
+
+ let originalValue;
+ try { originalValue = Buffer.from(origAttr.value, 'base64').toString('utf-8'); }
+ catch { continue; }
+
+ const newContentAttr = `content=${contentAttr.quote}${originalValue}${contentAttr.quote}`;
+ let newAttrs = tag.attrs.replace(contentAttr.full, newContentAttr);
+ // Drop the marker attribute and any single space immediately preceding it.
+ newAttrs = newAttrs.replace(new RegExp(`\\s*${origAttr.full}`), '');
+ const newTag = tag.full.replace(tag.attrs, newAttrs);
+
+ result = result.slice(0, tag.start) + newTag + result.slice(tag.end);
+ }
+ return result;
+}
+
+// ---------------------------------------------------------------------------
+// Auto-execute
+// ---------------------------------------------------------------------------
+
+const _running = process.argv[1];
+if (_running?.endsWith('live-inject.mjs') || _running?.endsWith('live-inject.mjs/')) {
+ injectCli();
+}
+
+export { insertTag, removeTag, validateConfig, buildTagBlock };
+// patchCspMeta + revertCspMeta are exported above where they're defined.
diff --git a/plugin/skills/impeccable/scripts/live-poll.mjs b/plugin/skills/impeccable/scripts/live-poll.mjs
new file mode 100644
index 000000000..5cece1a43
--- /dev/null
+++ b/plugin/skills/impeccable/scripts/live-poll.mjs
@@ -0,0 +1,187 @@
+/**
+ * CLI client for the live variant mode poll/reply protocol.
+ *
+ * Usage:
+ * npx impeccable poll # Block until browser event, print JSON
+ * npx impeccable poll --timeout=600000 # Custom timeout (ms); default is long-poll friendly
+ * npx impeccable poll --reply done # Reply "done" to event
+ * npx impeccable poll --reply error "msg" # Reply with error
+ */
+
+import { execSync } from 'node:child_process';
+import fs from 'node:fs';
+import path from 'node:path';
+import os from 'node:os';
+import { fileURLToPath } from 'node:url';
+
+// Node's built-in fetch (undici under the hood) enforces a 300s headers
+// timeout that can't be lowered per-request. We cap each request below
+// that ceiling and loop in `pollOnce` to synthesize a long poll without
+// depending on the standalone undici package.
+const PER_REQUEST_TIMEOUT_MS = 270_000;
+
+const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json');
+
+function readServerInfo() {
+ try {
+ return JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
+ } catch {
+ console.error('No running live server found. Start one with: npx impeccable live');
+ process.exit(1);
+ }
+}
+
+export async function pollCli() {
+ const args = process.argv.slice(2);
+
+ if (args.includes('--help') || args.includes('-h')) {
+ console.log(`Usage: impeccable poll [options]
+
+Wait for a browser event from the live variant server, or reply to one.
+
+Modes:
+ poll Block until a browser event arrives, print JSON
+ poll --reply done Reply "done" to event
+ poll --reply error "msg" Reply with an error message
+
+Options:
+ --timeout=MS Long-poll timeout in ms (default: 600000). Use the default unless the user asked to pause live; never use a short timeout to end the chat turn
+ --help Show this help message`);
+ process.exit(0);
+ }
+
+ const info = readServerInfo();
+ const base = `http://localhost:${info.port}`;
+
+ // Reply mode: npx impeccable poll --reply [--file path] [message]
+ const replyIdx = args.indexOf('--reply');
+ if (replyIdx !== -1) {
+ const id = args[replyIdx + 1];
+ const status = args[replyIdx + 2] || 'done';
+ const fileIdx = args.indexOf('--file');
+ const filePath = fileIdx !== -1 && fileIdx + 1 < args.length ? args[fileIdx + 1] : undefined;
+ // Message is any remaining positional arg that isn't a flag
+ const message = args.find((a, i) => i > replyIdx + 2 && !a.startsWith('--') && i !== fileIdx + 1) || undefined;
+
+ if (!id) {
+ console.error('Usage: npx impeccable poll --reply [--file path] [message]');
+ process.exit(1);
+ }
+
+ try {
+ const res = await fetch(`${base}/poll`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ token: info.token,
+ id,
+ type: status,
+ message,
+ file: filePath,
+ }),
+ });
+
+ if (!res.ok) {
+ const body = await res.json().catch(() => ({}));
+ console.error(`Reply failed (${res.status}):`, body.error || res.statusText);
+ process.exit(1);
+ }
+
+ // Success — silent exit (agent doesn't need output for replies)
+ } catch (err) {
+ if (err.cause?.code === 'ECONNREFUSED') {
+ console.error('Live server not running. Start one with: npx impeccable live');
+ } else {
+ console.error('Reply failed:', err.message);
+ }
+ process.exit(1);
+ }
+ return;
+ }
+
+ // Poll mode: block until browser event. Default 10 min. Node's built-in
+ // fetch enforces a 300s headers timeout, so we loop in slices under that
+ // ceiling and keep re-polling until we get a real event or the user's
+ // total timeout runs out.
+ const timeoutArg = args.find(a => a.startsWith('--timeout='));
+ const totalTimeout = timeoutArg ? parseInt(timeoutArg.split('=')[1], 10) : 600000;
+
+ const deadline = Date.now() + totalTimeout;
+ let event;
+ try {
+ while (true) {
+ const remaining = deadline - Date.now();
+ if (remaining <= 0) {
+ event = { type: 'timeout' };
+ break;
+ }
+ const slice = Math.min(remaining, PER_REQUEST_TIMEOUT_MS);
+ const res = await fetch(`${base}/poll?token=${info.token}&timeout=${slice}`);
+
+ if (res.status === 401) {
+ console.error('Authentication failed. The server token may have changed.');
+ console.error('Try restarting: npx impeccable live stop && npx impeccable live');
+ process.exit(1);
+ }
+
+ if (!res.ok) {
+ console.error(`Poll failed: ${res.status} ${res.statusText}`);
+ process.exit(1);
+ }
+
+ const next = await res.json();
+ // Server-side timeout means no browser event arrived in this slice.
+ // Loop and re-poll until we get a real event or we hit the user's
+ // total deadline.
+ if (next?.type === 'timeout' && Date.now() < deadline) continue;
+ event = next;
+ break;
+ }
+
+ // Auto-handle accept/discard via deterministic script
+ if (event.type === 'accept' || event.type === 'discard') {
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
+ const acceptScript = path.join(__dirname, 'live-accept.mjs');
+ const scriptArgs = event.type === 'discard'
+ ? ['--id', event.id, '--discard']
+ : ['--id', event.id, '--variant', event.variantId];
+ if (event.type === 'accept' && event.paramValues && Object.keys(event.paramValues).length > 0) {
+ // Pass through a JSON blob; the shell-safe wrap uses single quotes because
+ // values are finite {id, number|string|boolean} pairs from a validated payload.
+ scriptArgs.push('--param-values', `'${JSON.stringify(event.paramValues).replace(/'/g, "'\\''")}'`);
+ }
+ try {
+ const out = execSync(
+ `node "${acceptScript}" ${scriptArgs.join(' ')}`,
+ { encoding: 'utf-8', cwd: process.cwd(), timeout: 30_000 }
+ );
+ event._acceptResult = JSON.parse(out.trim());
+ } catch (err) {
+ event._acceptResult = { handled: false, error: err.message };
+ }
+ }
+
+ // Second signal path: stderr banner in case the agent parses stdout
+ // JSON but skips nested fields. One line is enough — the full checklist
+ // is in reference/live.md.
+ if (event._acceptResult?.carbonize === true) {
+ process.stderr.write('\n⚠ Carbonize cleanup REQUIRED before next poll. See reference/live.md "Required after accept".\n\n');
+ }
+
+ // Print the event as JSON — the agent reads this from stdout
+ console.log(JSON.stringify(event));
+ } catch (err) {
+ if (err.cause?.code === 'ECONNREFUSED') {
+ console.error('Live server not running. Start one with: npx impeccable live');
+ } else {
+ console.error('Poll failed:', err.message);
+ }
+ process.exit(1);
+ }
+}
+
+// Auto-execute when run directly
+const _running = process.argv[1];
+if (_running?.endsWith('live-poll.mjs') || _running?.endsWith('live-poll.mjs/')) {
+ pollCli();
+}
diff --git a/plugin/skills/impeccable/scripts/live-server.mjs b/plugin/skills/impeccable/scripts/live-server.mjs
new file mode 100644
index 000000000..3d608e3c0
--- /dev/null
+++ b/plugin/skills/impeccable/scripts/live-server.mjs
@@ -0,0 +1,679 @@
+#!/usr/bin/env node
+/**
+ * Live variant mode server (self-contained, zero dependencies).
+ *
+ * Serves the browser script (/live.js), the detection overlay (/detect.js),
+ * uses Server-Sent Events (SSE) for server→browser push, and HTTP POST for
+ * browser→server events. Agent communicates via HTTP long-poll (/poll).
+ *
+ * Usage:
+ * node /live-server.mjs # start
+ * node /live-server.mjs stop # stop + remove injected live.js tag
+ * node /live-server.mjs stop --keep-inject # stop only
+ * node /live-server.mjs --help
+ */
+
+import http from 'node:http';
+import { randomUUID } from 'node:crypto';
+import { spawn, execFileSync } from 'node:child_process';
+import fs from 'node:fs';
+import path from 'node:path';
+import net from 'node:net';
+import { fileURLToPath } from 'node:url';
+import { parseDesignMd } from './design-parser.mjs';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+// PID file in the project root so both the server and agent can find it
+// predictably (os.tmpdir() varies across platforms).
+const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json');
+const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway
+const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s
+
+// ---------------------------------------------------------------------------
+// Port detection
+// ---------------------------------------------------------------------------
+
+async function findOpenPort(start = 8400) {
+ return new Promise((resolve) => {
+ const srv = net.createServer();
+ srv.listen(start, '127.0.0.1', () => {
+ const port = srv.address().port;
+ srv.close(() => resolve(port));
+ });
+ srv.on('error', () => resolve(findOpenPort(start + 1)));
+ });
+}
+
+// ---------------------------------------------------------------------------
+// Session state
+// ---------------------------------------------------------------------------
+
+const state = {
+ token: null,
+ port: null,
+ sseClients: new Set(), // SSE response objects (server→browser push)
+ pendingEvents: [], // browser events waiting for agent poll
+ pendingPolls: [], // agent poll callbacks waiting for browser events
+ exitTimer: null,
+ sessionDir: null, // per-session tmp dir for annotation screenshots
+};
+
+// Cap per-annotation upload size. A full 1920×1080 PNG is typically <1 MB;
+// cap at 10 MB to guard against runaway writes from a misbehaving client.
+const MAX_ANNOTATION_BYTES = 10 * 1024 * 1024;
+
+function enqueueEvent(event) {
+ if (state.pendingPolls.length > 0) {
+ state.pendingPolls.shift()(event);
+ } else {
+ state.pendingEvents.push(event);
+ }
+}
+
+/** Push a message to all connected SSE clients. */
+function broadcast(msg) {
+ const data = 'data: ' + JSON.stringify(msg) + '\n\n';
+ for (const res of state.sseClients) {
+ try { res.write(data); } catch { /* client gone */ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Load scripts
+// ---------------------------------------------------------------------------
+
+function loadBrowserScripts() {
+ // Detection script: look relative to the skill scripts dir, then fall back
+ // to the npm package location (src/detect-antipatterns-browser.js).
+ // This one IS cached — detect.js rarely changes during a session.
+ const detectPaths = [
+ path.join(__dirname, '..', '..', '..', '..', 'src', 'detect-antipatterns-browser.js'),
+ path.join(process.cwd(), 'node_modules', 'impeccable', 'src', 'detect-antipatterns-browser.js'),
+ ];
+ let detectScript = '';
+ for (const p of detectPaths) {
+ try { detectScript = fs.readFileSync(p, 'utf-8'); break; } catch { /* try next */ }
+ }
+
+ // live-browser.js: DO NOT cache. Return the path so the /live.js handler
+ // can re-read on every request. Editing the browser script during iteration
+ // should land on the next tab reload, not require a server restart.
+ const livePath = path.join(__dirname, 'live-browser.js');
+ if (!fs.existsSync(livePath)) {
+ process.stderr.write('Error: live-browser.js not found at ' + livePath + '\n');
+ process.exit(1);
+ }
+
+ return { detectScript, livePath };
+}
+
+function hasProjectContext() {
+ // PRODUCT.md carries brand voice / anti-references — that's what determines
+ // whether variants are brand-aware. DESIGN.md (visual tokens) is a separate
+ // concern, surfaced by the design panel's own empty state. Legacy
+ // .impeccable.md is auto-migrated to PRODUCT.md by load-context.mjs.
+ try {
+ fs.accessSync(path.join(process.cwd(), 'PRODUCT.md'), fs.constants.R_OK);
+ return true;
+ } catch { return false; }
+}
+
+function statOrNull(filePath) {
+ try { return fs.statSync(filePath); } catch { return null; }
+}
+
+// ---------------------------------------------------------------------------
+// Validation (inline — no external import needed for self-contained script)
+// ---------------------------------------------------------------------------
+
+const VISUAL_ACTIONS = [
+ 'impeccable', 'bolder', 'quieter', 'distill', 'polish', 'typeset',
+ 'colorize', 'layout', 'adapt', 'animate', 'delight', 'overdrive',
+];
+
+function validateEvent(msg) {
+ if (!msg || typeof msg !== 'object' || !msg.type) return 'Missing or invalid message';
+ switch (msg.type) {
+ case 'generate':
+ if (!msg.id || typeof msg.id !== 'string') return 'generate: missing id';
+ if (!msg.action || !VISUAL_ACTIONS.includes(msg.action)) return 'generate: invalid action';
+ if (!Number.isInteger(msg.count) || msg.count < 1 || msg.count > 8) return 'generate: count must be 1-8';
+ if (!msg.element || !msg.element.outerHTML) return 'generate: missing element context';
+ // Optional annotation fields (all-or-nothing: if any present, all must be well-formed).
+ if (msg.screenshotPath !== undefined && typeof msg.screenshotPath !== 'string') return 'generate: screenshotPath must be string';
+ if (msg.comments !== undefined && !Array.isArray(msg.comments)) return 'generate: comments must be array';
+ if (msg.strokes !== undefined && !Array.isArray(msg.strokes)) return 'generate: strokes must be array';
+ return null;
+ case 'accept':
+ if (!msg.id) return 'accept: missing id';
+ if (!msg.variantId) return 'accept: missing variantId';
+ if (msg.paramValues !== undefined) {
+ if (typeof msg.paramValues !== 'object' || msg.paramValues === null || Array.isArray(msg.paramValues)) {
+ return 'accept: paramValues must be an object';
+ }
+ }
+ return null;
+ case 'discard':
+ return msg.id ? null : 'discard: missing id';
+ case 'exit':
+ return null;
+ case 'prefetch':
+ if (!msg.pageUrl || typeof msg.pageUrl !== 'string') return 'prefetch: missing pageUrl';
+ return null;
+ default:
+ return 'Unknown event type: ' + msg.type;
+ }
+}
+
+// ---------------------------------------------------------------------------
+// HTTP request handler
+// ---------------------------------------------------------------------------
+
+function createRequestHandler({ detectScript, livePath }) {
+ return (req, res) => {
+ const url = new URL(req.url, `http://localhost:${state.port}`);
+ res.setHeader('Access-Control-Allow-Origin', '*');
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
+ if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
+
+ const p = url.pathname;
+
+ // --- Scripts ---
+ if (p === '/live.js') {
+ // Re-read from disk each request so edits to live-browser.js land on
+ // the next tab reload. No-store headers prevent browser caching across
+ // sessions — during iteration, a cached old script silently breaks
+ // every subsequent session.
+ let liveScript;
+ try {
+ liveScript = fs.readFileSync(livePath, 'utf-8');
+ } catch (err) {
+ res.writeHead(500, { 'Content-Type': 'text/plain' });
+ res.end('Error reading live-browser.js: ' + err.message);
+ return;
+ }
+ const body =
+ `window.__IMPECCABLE_TOKEN__ = '${state.token}';\n` +
+ `window.__IMPECCABLE_PORT__ = ${state.port};\n` +
+ liveScript;
+ res.writeHead(200, {
+ 'Content-Type': 'application/javascript',
+ 'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0',
+ 'Pragma': 'no-cache',
+ });
+ res.end(body);
+ return;
+ }
+ if (p === '/detect.js' || p === '/') {
+ if (!detectScript) { res.writeHead(404); res.end('Not available'); return; }
+ res.writeHead(200, { 'Content-Type': 'application/javascript' });
+ res.end(detectScript);
+ return;
+ }
+
+ // --- Vendored modern-screenshot (UMD build) ---
+ // Lazy-loaded by live.js when the user clicks Go; exposes
+ // window.modernScreenshot.domToBlob(...) for capture.
+ if (p === '/modern-screenshot.js') {
+ const vendorPath = path.join(__dirname, 'modern-screenshot.umd.js');
+ try {
+ res.writeHead(200, {
+ 'Content-Type': 'application/javascript',
+ 'Cache-Control': 'public, max-age=31536000, immutable',
+ });
+ res.end(fs.readFileSync(vendorPath));
+ } catch {
+ res.writeHead(404); res.end('Vendor script not found');
+ }
+ return;
+ }
+
+ // --- Annotation upload (browser → server, raw PNG body) ---
+ // Client generates the eventId, POSTs the PNG, then POSTs the generate
+ // event with screenshotPath already set. Keeps bytes out of the SSE/poll
+ // bridge and preserves the "one shot from the user's POV" UX.
+ if (p === '/annotation' && req.method === 'POST') {
+ const token = url.searchParams.get('token');
+ if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
+ const eventId = url.searchParams.get('eventId');
+ if (!eventId || !/^[A-Za-z0-9_-]{1,64}$/.test(eventId)) {
+ res.writeHead(400, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: 'Invalid eventId' }));
+ return;
+ }
+ if ((req.headers['content-type'] || '').toLowerCase() !== 'image/png') {
+ res.writeHead(415, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: 'Content-Type must be image/png' }));
+ return;
+ }
+ if (!state.sessionDir) {
+ res.writeHead(500, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: 'Session dir unavailable' }));
+ return;
+ }
+ const chunks = [];
+ let total = 0;
+ let aborted = false;
+ req.on('data', (c) => {
+ if (aborted) return;
+ total += c.length;
+ if (total > MAX_ANNOTATION_BYTES) {
+ aborted = true;
+ res.writeHead(413, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: 'Payload too large' }));
+ req.destroy();
+ return;
+ }
+ chunks.push(c);
+ });
+ req.on('end', () => {
+ if (aborted) return;
+ const absPath = path.join(state.sessionDir, eventId + '.png');
+ try {
+ fs.writeFileSync(absPath, Buffer.concat(chunks));
+ } catch (err) {
+ res.writeHead(500, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: 'Write failed: ' + err.message }));
+ return;
+ }
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ ok: true, path: absPath }));
+ });
+ req.on('error', () => {
+ if (!aborted) {
+ res.writeHead(500, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: 'Upload failed' }));
+ }
+ });
+ return;
+ }
+
+ // --- Health ---
+ if (p === '/health') {
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({
+ status: 'ok', port: state.port, mode: 'variant',
+ hasProjectContext: hasProjectContext(),
+ connectedClients: state.sseClients.size,
+ }));
+ return;
+ }
+
+ // --- Design system (unified v2 response) + raw ---
+ // /design-system.json returns both parsed DESIGN.md and DESIGN.json
+ // sidecar when present. Panel merges them:
+ // { present, parsed, sidecar, hasMd, hasSidecar,
+ // mdNewerThanJson, parseError?, sidecarError? }
+ // - parsed: output of parseDesignMd (frontmatter
+ // + six canonical sections) when DESIGN.md exists.
+ // - sidecar: DESIGN.json contents when present.
+ // Expected shape: schemaVersion 2, carrying
+ // extensions + components + narrative.
+ // /design-system/raw returns DESIGN.md markdown verbatim
+ if (p === '/design-system.json' || p === '/design-system/raw') {
+ const token = url.searchParams.get('token');
+ if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
+
+ const mdPath = path.join(process.cwd(), 'DESIGN.md');
+ const jsonPath = path.join(process.cwd(), 'DESIGN.json');
+ const mdStat = statOrNull(mdPath);
+ const jsonStat = statOrNull(jsonPath);
+
+ if (p === '/design-system/raw') {
+ if (!mdStat) { res.writeHead(404); res.end('Not found'); return; }
+ res.writeHead(200, { 'Content-Type': 'text/markdown; charset=utf-8' });
+ res.end(fs.readFileSync(mdPath, 'utf-8'));
+ return;
+ }
+
+ if (!mdStat && !jsonStat) {
+ res.writeHead(404, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ present: false }));
+ return;
+ }
+
+ const response = {
+ present: true,
+ hasMd: !!mdStat,
+ hasSidecar: !!jsonStat,
+ mdNewerThanJson: !!(mdStat && jsonStat && mdStat.mtimeMs > jsonStat.mtimeMs + 1000),
+ };
+
+ if (mdStat) {
+ try {
+ response.parsed = parseDesignMd(fs.readFileSync(mdPath, 'utf-8'));
+ } catch (err) {
+ response.parseError = err.message;
+ }
+ }
+
+ if (jsonStat) {
+ try {
+ response.sidecar = JSON.parse(fs.readFileSync(jsonPath, 'utf-8'));
+ } catch (err) {
+ response.sidecarError = 'Failed to parse DESIGN.json: ' + err.message;
+ }
+ }
+
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify(response));
+ return;
+ }
+
+ // --- Source file (no-HMR fallback) ---
+ if (p === '/source') {
+ const token = url.searchParams.get('token');
+ if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
+ const filePath = url.searchParams.get('path');
+ if (!filePath || filePath.includes('..')) { res.writeHead(400); res.end('Bad path'); return; }
+ const absPath = path.resolve(process.cwd(), filePath);
+ if (!absPath.startsWith(process.cwd())) { res.writeHead(403); res.end('Forbidden'); return; }
+ let content;
+ try { content = fs.readFileSync(absPath, 'utf-8'); }
+ catch { res.writeHead(404); res.end('File not found'); return; }
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
+ res.end(content);
+ return;
+ }
+
+ // --- SSE: server→browser push (replaces WebSocket) ---
+ if (p === '/events' && req.method === 'GET') {
+ const token = url.searchParams.get('token');
+ if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
+ res.writeHead(200, {
+ 'Content-Type': 'text/event-stream',
+ 'Cache-Control': 'no-cache',
+ 'Connection': 'keep-alive',
+ });
+ res.write('data: ' + JSON.stringify({
+ type: 'connected',
+ hasProjectContext: hasProjectContext(),
+ }) + '\n\n');
+
+ state.sseClients.add(res);
+ clearTimeout(state.exitTimer);
+
+ // Keepalive: SSE comment every 30s prevents silent connection drops.
+ const heartbeat = setInterval(() => {
+ try { res.write(': keepalive\n\n'); } catch { clearInterval(heartbeat); }
+ }, SSE_HEARTBEAT_INTERVAL);
+
+ req.on('close', () => {
+ clearInterval(heartbeat);
+ state.sseClients.delete(res);
+ if (state.sseClients.size === 0) {
+ clearTimeout(state.exitTimer);
+ state.exitTimer = setTimeout(() => {
+ if (state.sseClients.size === 0) enqueueEvent({ type: 'exit' });
+ }, 8000);
+ }
+ });
+ return;
+ }
+
+ // --- Browser→server events (replaces WebSocket messages) ---
+ if (p === '/events' && req.method === 'POST') {
+ let body = '';
+ req.on('data', (c) => { body += c; });
+ req.on('end', () => {
+ let msg;
+ try { msg = JSON.parse(body); } catch {
+ res.writeHead(400, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: 'Invalid JSON' }));
+ return;
+ }
+ if (msg.token !== state.token) {
+ res.writeHead(401, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: 'Unauthorized' }));
+ return;
+ }
+ const error = validateEvent(msg);
+ if (error) {
+ res.writeHead(400, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error }));
+ return;
+ }
+ enqueueEvent(msg);
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ ok: true }));
+ });
+ return;
+ }
+
+ // --- Stop ---
+ if (p === '/stop') {
+ const token = url.searchParams.get('token');
+ if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
+ res.writeHead(200, { 'Content-Type': 'text/plain' });
+ res.end('stopping');
+ shutdown();
+ return;
+ }
+
+ // --- Agent poll ---
+ if (p === '/poll' && req.method === 'GET') {
+ handlePollGet(req, res, url);
+ return;
+ }
+ if (p === '/poll' && req.method === 'POST') {
+ handlePollPost(req, res);
+ return;
+ }
+
+ res.writeHead(404); res.end('Not found');
+ };
+}
+
+// ---------------------------------------------------------------------------
+// Agent poll endpoints (unchanged from WS version)
+// ---------------------------------------------------------------------------
+
+function handlePollGet(req, res, url) {
+ const token = url.searchParams.get('token');
+ if (token !== state.token) {
+ res.writeHead(401, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: 'Unauthorized' }));
+ return;
+ }
+ const timeout = parseInt(url.searchParams.get('timeout') || DEFAULT_POLL_TIMEOUT, 10);
+ if (state.pendingEvents.length > 0) {
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify(state.pendingEvents.shift()));
+ return;
+ }
+ const timer = setTimeout(() => {
+ const idx = state.pendingPolls.indexOf(resolve);
+ if (idx !== -1) state.pendingPolls.splice(idx, 1);
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ type: 'timeout' }));
+ }, timeout);
+ function resolve(event) {
+ clearTimeout(timer);
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify(event));
+ }
+ state.pendingPolls.push(resolve);
+ req.on('close', () => {
+ clearTimeout(timer);
+ const idx = state.pendingPolls.indexOf(resolve);
+ if (idx !== -1) state.pendingPolls.splice(idx, 1);
+ });
+}
+
+function handlePollPost(req, res) {
+ let body = '';
+ req.on('data', (c) => { body += c; });
+ req.on('end', () => {
+ let msg;
+ try { msg = JSON.parse(body); } catch {
+ res.writeHead(400, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: 'Invalid JSON' }));
+ return;
+ }
+ if (msg.token !== state.token) {
+ res.writeHead(401, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: 'Unauthorized' }));
+ return;
+ }
+ // Forward the reply to the browser via SSE
+ broadcast({ type: msg.type || 'done', id: msg.id, message: msg.message, file: msg.file, data: msg.data });
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ ok: true }));
+ });
+}
+
+// ---------------------------------------------------------------------------
+// Lifecycle
+// ---------------------------------------------------------------------------
+
+let httpServer = null;
+
+function shutdown() {
+ try { fs.unlinkSync(LIVE_PID_FILE); } catch {}
+ if (state.sessionDir) {
+ try { fs.rmSync(state.sessionDir, { recursive: true, force: true }); } catch {}
+ }
+ for (const res of state.sseClients) { try { res.end(); } catch {} }
+ state.sseClients.clear();
+ for (const resolve of state.pendingPolls) resolve({ type: 'exit' });
+ state.pendingPolls.length = 0;
+ if (httpServer) httpServer.close();
+ process.exit(0);
+}
+
+// ---------------------------------------------------------------------------
+// Main
+// ---------------------------------------------------------------------------
+
+const args = process.argv.slice(2);
+
+if (args.includes('--help') || args.includes('-h')) {
+ console.log(`Usage: node live-server.mjs [options]
+
+Start the live variant mode server (zero dependencies).
+
+Commands:
+ (default) Start the server (foreground)
+ stop Stop the server and remove the injected live.js script tag
+ stop --keep-inject Stop the server only (leave the script tag in the HTML entry)
+
+Options:
+ --background Start detached, print connection JSON to stdout, then exit
+ --port=PORT Use a specific port (default: auto-detect starting at 8400)
+ --keep-inject Only with stop: skip live-inject.mjs --remove
+ --help Show this help
+
+Endpoints:
+ /live.js Browser script (element picker + variant cycling)
+ /detect.js Detection overlay (backwards compatible)
+ /modern-screenshot.js Vendored modern-screenshot UMD build (lazy-loaded by live.js)
+ /annotation POST raw image/png to stage a variant screenshot
+ /events SSE stream (server→browser) + POST (browser→server)
+ /poll Long-poll for agent CLI
+ /source Raw source file reader (no-HMR fallback)
+ /health Health check`);
+ process.exit(0);
+}
+
+if (args.includes('stop')) {
+ const keepInject = args.includes('--keep-inject');
+ try {
+ const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
+ const res = await fetch(`http://localhost:${info.port}/stop?token=${info.token}`);
+ if (res.ok) console.log(`Stopped live server on port ${info.port}.`);
+ } catch {
+ console.log('No running live server found.');
+ }
+ if (!keepInject) {
+ const injectPath = path.join(__dirname, 'live-inject.mjs');
+ try {
+ const out = execFileSync(process.execPath, [injectPath, '--remove'], {
+ encoding: 'utf-8',
+ cwd: process.cwd(),
+ });
+ const line = out.trim().split('\n').filter(Boolean).pop();
+ if (line) {
+ try {
+ const j = JSON.parse(line);
+ if (j.removed === true) {
+ console.log(`Removed live script tag from ${j.file}.`);
+ }
+ } catch {
+ /* ignore non-JSON lines */
+ }
+ }
+ } catch (err) {
+ const detail = err.stderr?.toString?.().trim?.()
+ || err.stdout?.toString?.().trim?.()
+ || err.message
+ || String(err);
+ console.warn(`Note: could not remove live script tag (${detail.split('\n')[0]})`);
+ }
+ }
+ process.exit(0);
+}
+
+// --background: spawn a detached child server, wait for it to be ready,
+// print the connection JSON, then exit. This keeps the startup command
+// simple (no shell backgrounding or chained commands).
+if (args.includes('--background')) {
+ const childArgs = args.filter(a => a !== '--background');
+ const child = spawn(process.execPath, [fileURLToPath(import.meta.url), ...childArgs], {
+ detached: true,
+ stdio: 'ignore',
+ cwd: process.cwd(),
+ });
+ child.unref();
+
+ // Poll for the PID file (the child writes it once the HTTP server is listening).
+ const deadline = Date.now() + 10_000;
+ while (Date.now() < deadline) {
+ try {
+ const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
+ if (info.pid !== process.pid) {
+ // Output JSON so the agent can read port + token from stdout.
+ console.log(JSON.stringify(info));
+ process.exit(0);
+ }
+ } catch { /* not ready yet */ }
+ await new Promise(r => setTimeout(r, 200));
+ }
+ console.error('Timed out waiting for live server to start.');
+ process.exit(1);
+}
+
+// Check for existing session
+try {
+ const existing = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
+ try { process.kill(existing.pid, 0);
+ console.error(`Live server already running on port ${existing.port} (pid ${existing.pid}).`);
+ console.error('Stop it first with: node ' + path.basename(fileURLToPath(import.meta.url)) + ' stop');
+ process.exit(1);
+ } catch { fs.unlinkSync(LIVE_PID_FILE); }
+} catch {}
+
+state.token = randomUUID();
+const portArg = args.find(a => a.startsWith('--port='));
+state.port = portArg ? parseInt(portArg.split('=')[1], 10) : await findOpenPort();
+// Annotation screenshots live in the project root so the agent's Read tool
+// doesn't trip a per-file permission prompt. Sessioned by token so concurrent
+// projects (or quick restarts) don't collide.
+const annotRoot = path.join(process.cwd(), '.impeccable-live', 'annotations');
+fs.mkdirSync(annotRoot, { recursive: true });
+state.sessionDir = fs.mkdtempSync(path.join(annotRoot, 'session-'));
+
+const { detectScript, livePath } = loadBrowserScripts();
+httpServer = http.createServer(createRequestHandler({ detectScript, livePath }));
+
+httpServer.listen(state.port, '127.0.0.1', () => {
+ fs.writeFileSync(LIVE_PID_FILE, JSON.stringify({ pid: process.pid, port: state.port, token: state.token }));
+ const url = `http://localhost:${state.port}`;
+ console.log(`\nImpeccable live server running on ${url}`);
+ console.log(`Token: ${state.token}\n`);
+ console.log(`Inject: