mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +03:00
feat(live): make live sessions recoverable (#125)
* feat(live): make live sessions recoverable tired of live mode losing the plot when the browser moved faster than the agent. now the state is boring: journal it, resume it, finish it. --- - add durable live-session journal, checkpoint events, and status/resume/complete commands - split browser session storage into a testable helper and harden accept/discard completion - fix Astro live CSS preview mode and add recovery/live E2E coverage - declare Bun as the package manager and add a Bun-native audit script * fix(live): acknowledge fallback recovery states * fix(live): flush recoverable handoffs promptly * fix(live): keep recovery handoffs accurate * fix(live): preserve poll reply metadata * fix(live): treat event HTTP failures as failed sends * fix(live): acknowledge manual completion through helper * Add .impeccable project state paths * Fix live disconnect recovery phase * Refine live CSS authoring contract * Test live CSS authoring guidance * Harden live LLM E2E recovery * Fix live recovery review issues --------- Co-authored-by: Paul Bakaus <paulbakaus@pauls-mbp-3.lan>
This commit is contained in:
co-authored by
Paul Bakaus
parent
88b82ae5f5
commit
d874af046a
@@ -237,11 +237,11 @@ Concrete, forceful guardrails. Lead each with "Do" or "Don't". Be specific: incl
|
||||
- **Don't** [...]
|
||||
```
|
||||
|
||||
### Step 4b: Write DESIGN.json sidecar (extensions only)
|
||||
### Step 4b: Write .impeccable/design.json sidecar (extensions only)
|
||||
|
||||
The frontmatter owns token primitives (colors, typography, rounded, spacing, components). The sidecar at `DESIGN.json` carries **what Stitch's schema can't hold**: tonal ramps per color, shadow/elevation tokens, motion tokens, breakpoints, full component HTML/CSS snippets (the panel renders these into a shadow DOM), and narrative (north star, rules, do's/don'ts). It extends the frontmatter, it doesn't duplicate it.
|
||||
The frontmatter owns token primitives (colors, typography, rounded, spacing, components). The sidecar at `.impeccable/design.json` carries **what Stitch's schema can't hold**: tonal ramps per color, shadow/elevation tokens, motion tokens, breakpoints, full component HTML/CSS snippets (the panel renders these into a shadow DOM), and narrative (north star, rules, do's/don'ts). It extends the frontmatter, it doesn't duplicate it.
|
||||
|
||||
Regenerate the sidecar whenever you regenerate DESIGN.md. If the user only asks to refresh the sidecar (e.g., from the live panel's stale-hint), preserve DESIGN.md and write only DESIGN.json.
|
||||
Regenerate the sidecar whenever you regenerate root `DESIGN.md`. If the user only asks to refresh the sidecar (e.g., from the live panel's stale-hint), preserve `DESIGN.md` and write only `.impeccable/design.json`.
|
||||
|
||||
#### Schema
|
||||
|
||||
@@ -310,7 +310,7 @@ Aim for a tight set of **5-10 components** that best represent the visual system
|
||||
- **Signature components (include if distinctive):** hero CTA, featured card, filter pill, any custom pattern the user mentioned as important in PRODUCT.md.
|
||||
- **Skip the rest.** Utility components, form building blocks, wrapper layouts: not worth documenting unless visually distinctive.
|
||||
|
||||
If the project has **no component library yet** (bare landing page, new project), synthesize canonical primitives from the tokens using best-practice defaults consistent with the DESIGN.md's rules. Every DESIGN.json has *something* to render, even on day zero.
|
||||
If the project has **no component library yet** (bare landing page, new project), synthesize canonical primitives from the tokens using best-practice defaults consistent with the DESIGN.md's rules. Every `.impeccable/design.json` has *something* to render, even on day zero.
|
||||
|
||||
#### Tonal ramps
|
||||
|
||||
@@ -331,7 +331,7 @@ Do not reword. The panel shows these as secondary collapsible context; the same
|
||||
### Step 5: Confirm, refine, and refresh session cache
|
||||
|
||||
1. Show the user the full DESIGN.md you wrote. Briefly highlight the non-obvious creative choices (descriptive color names, atmosphere language, named rules).
|
||||
2. Mention that `DESIGN.json` was also written alongside; the live panel will now render this project's actual button/input/nav primitives instead of generic approximations.
|
||||
2. Mention that `.impeccable/design.json` was also written alongside; the live panel will now render this project's actual button/input/nav primitives instead of generic approximations.
|
||||
3. Offer to refine any section: "Want me to revise a section, add component patterns I missed, or adjust the atmosphere language?"
|
||||
4. **Refresh the session cache.** Run `node .agents/skills/impeccable/scripts/load-context.mjs` one final time so the newly-written DESIGN.md lands in conversation. Subsequent commands in this session will use the fresh version automatically without re-reading.
|
||||
|
||||
@@ -392,7 +392,7 @@ Per-section guidance in seed mode:
|
||||
- **Components**: omit entirely; no components exist yet.
|
||||
- **Do's and Don'ts**: carry PRODUCT.md's anti-references directly plus the anti-reference named in Q5.
|
||||
|
||||
Seed mode writes a minimal frontmatter with `name` and `description` only; no colors, typography, rounded, spacing, or components yet. Real tokens land on the next Scan-mode run. Skip the `DESIGN.json` sidecar in seed mode for the same reason: nothing to render.
|
||||
Seed mode writes a minimal frontmatter with `name` and `description` only; no colors, typography, rounded, spacing, or components yet. Real tokens land on the next Scan-mode run. Skip the `.impeccable/design.json` sidecar in seed mode for the same reason: nothing to render.
|
||||
|
||||
### Step 4: Confirm and refresh session cache
|
||||
|
||||
|
||||
@@ -12,8 +12,9 @@ Execute in order. No step skipped, no step reordered.
|
||||
2. Navigate to the URL that serves `pageFile` (infer from `package.json`, docs, terminal output, or an open tab). If you can't infer it confidently, tell the user once to open their dev/preview URL. Never use `serverPort` as that URL; it's the helper, not the app.
|
||||
3. Poll loop with the default long timeout (600000 ms). After every event or `--reply`, run `live-poll.mjs` again immediately. Never pass a short `--timeout=`.
|
||||
4. On `generate`: read screenshot if present; load the action's reference; plan three distinct directions; write all variants in one edit; `--reply done`; poll again.
|
||||
5. On `accept` / `discard`: the poll script already cleaned up; just poll again.
|
||||
6. On `exit`: run the cleanup at the bottom.
|
||||
5. On `accept` / `discard`: the poll script runs `live-accept.mjs`, acknowledges the delivered event, and prints `_completionAck`. Plain accepts/discards are terminal immediately; carbonize accepts remain recoverable until you finish cleanup, run `live-complete.mjs --id EVENT_ID`, and only then poll again.
|
||||
6. If interrupted, run `live-status.mjs` or `live-resume.mjs` before guessing. The durable journal replays unacknowledged work after helper restart.
|
||||
7. On `exit`: run the cleanup at the bottom.
|
||||
|
||||
Harness policy:
|
||||
- **Claude Code**: run the poll as a **background task** (no short timeout). The harness notifies you when it completes, so the main conversation stays free. Do not block the shell.
|
||||
@@ -43,13 +44,31 @@ LOOP:
|
||||
Read JSON; dispatch on "type"
|
||||
|
||||
"generate" → Handle Generate; reply done; LOOP
|
||||
"accept" → Handle Accept; LOOP
|
||||
"accept" → Handle Accept; complete carbonize cleanup if required; LOOP
|
||||
"discard" → Handle Discard; LOOP
|
||||
"prefetch" → Handle Prefetch; LOOP
|
||||
"timeout" → LOOP
|
||||
"exit" → break → Cleanup
|
||||
```
|
||||
|
||||
## Recovery commands
|
||||
|
||||
The live helper persists an append-only journal under `.impeccable/live/sessions/`. Browser checkpoints are advisory but durable; the journal is canonical. This is local durable recovery state, not project source.
|
||||
|
||||
Use these commands when the chat was interrupted, polling was missed, the helper restarted, or the browser reloaded:
|
||||
|
||||
```bash
|
||||
node .agents/skills/impeccable/scripts/live-status.mjs
|
||||
node .agents/skills/impeccable/scripts/live-resume.mjs --id SESSION_ID
|
||||
node .agents/skills/impeccable/scripts/live-complete.mjs --id SESSION_ID
|
||||
```
|
||||
|
||||
- `live-status.mjs` prints connected helper state, active durable sessions, and queued pending events. It works even when the helper is down by reading the journal directly.
|
||||
- `live-resume.mjs` prints the active snapshot, pending event, checkpoint phase, visible variant, parameter values, and the next safe agent action.
|
||||
- `live-complete.mjs` is the canonical manual final acknowledgement. Use it after carbonize/manual cleanup is verified and no further poll acknowledgement will happen automatically.
|
||||
|
||||
Server restart rule: start `live-server.mjs` again, then poll. Startup requeues unacknowledged pending events from the journal, so do not ask the user to click Go again unless `live-resume.mjs` says no active session exists.
|
||||
|
||||
## Handle `generate`
|
||||
|
||||
Event: `{id, action, freeformPrompt?, count, pageUrl, element, screenshotPath?, comments?, strokes?}`.
|
||||
@@ -88,7 +107,14 @@ The helper searches ID first, then classes, then tag + class combo. If `event.pa
|
||||
|
||||
If `--text` matches multiple candidates equally well, wrap exits with `{ error: "element_ambiguous", candidates: [...] }` and `fallback: "agent-driven"`: read the candidate line ranges, decide which one matches the picked element from page context, and write the wrapper manually per the fallback flow.
|
||||
|
||||
Output on success: `{ file, insertLine, commentSyntax }`.
|
||||
Output on success: `{ file, insertLine, commentSyntax, styleMode, styleTag, cssSelectorPrefixExamples, cssAuthoring }`.
|
||||
|
||||
`styleMode` controls how preview CSS must be authored. Treat it as a detected capability mode, not a framework guess:
|
||||
|
||||
- `scoped`: use `@scope ([data-impeccable-variant="N"])` rules.
|
||||
- `astro-global-prefixed`: use explicit `[data-impeccable-variant="N"]` selector prefixes and the exact `styleTag` returned by the tool.
|
||||
|
||||
Use `cssAuthoring` as the source of truth for the current file. It includes the exact `styleTag`, selector strategy, selector examples, requirements, and forbidden patterns. Do not apply a framework-specific exception unless the returned `styleMode` / `cssAuthoring.mode` says to.
|
||||
|
||||
**Fallback errors.** Wrap only writes into files it judges to be source (tracked by git, not marked GENERATED, not listed in config's `generatedFiles`). If it can't land on a source file, it errors without writing; accepting a variant into a generated file is silent data loss. Three shapes:
|
||||
|
||||
@@ -208,13 +234,14 @@ When the prompt and PRODUCT.md anti-references conflict (the prompt asks for X,
|
||||
|
||||
Complete HTML replacement of the original element for each variant, not a CSS-only patch. Consider the element's context (computed styles, parent structure, CSS variables from `event.element`).
|
||||
|
||||
Write CSS + all variants in ONE edit at the `insertLine` reported by `wrap`. Colocate scoped CSS as a `<style>` tag inside the variant wrapper; `<style>` works anywhere in modern browsers and this ensures CSS and HTML arrive atomically (no FOUC).
|
||||
Write CSS + all variants in ONE edit at the `insertLine` reported by `wrap`. Colocate CSS as a `<style>` tag inside the variant wrapper; `<style>` works anywhere in modern browsers and this ensures CSS and HTML arrive atomically (no FOUC).
|
||||
|
||||
Use the `cssAuthoring` object returned by `live-wrap.mjs` to author the temporary preview CSS. The style opening tag shown below is the common case; replace it with `cssAuthoring.styleTag` when the tool returns a different one. The variant markup shape is otherwise stable:
|
||||
|
||||
```html
|
||||
<!-- Variants: insert below this line -->
|
||||
<style data-impeccable-css="SESSION_ID">
|
||||
@scope ([data-impeccable-variant="1"]) { ... }
|
||||
@scope ([data-impeccable-variant="2"]) { ... }
|
||||
/* rules matching cssAuthoring.rulePattern */
|
||||
</style>
|
||||
<div data-impeccable-variant="1">
|
||||
<!-- variant 1: full element replacement (single top-level element) -->
|
||||
@@ -229,11 +256,11 @@ Write CSS + all variants in ONE edit at the `insertLine` reported by `wrap`. Col
|
||||
|
||||
**Each variant div contains exactly one top-level element: the full replacement for the original.** Use the same tag as the original (e.g. `<section>` if the user picked a `<section>`). 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 `<style>` tag entirely. Use `@scope` for CSS isolation (Chrome 118+ / Firefox 128+ / Safari 17.4+).
|
||||
The first variant has no `display: none` (visible by default). All others do. If variants use only inline styles and no preview CSS, omit the `<style>` tag entirely.
|
||||
|
||||
One edit, all variants; the browser's MutationObserver picks everything up in one pass.
|
||||
|
||||
**Author every `:scope` rule with a descendant combinator.** The `@scope` boundary is the **variant wrapper `<div data-impeccable-variant="N">`**, not the element you're designing. A bare `:scope { background: cream; }` styles the wrapper, not the inner replacement, so the cream lands on a `display: contents` shell while the actual element keeps page defaults. Always step in: `:scope > .card`, `:scope > section`, `:scope .hero-title`, etc. The fake test agent's CSS in `tests/live-e2e/agent.mjs` is a faithful template; every rule starts `:scope > ...`.
|
||||
For `styleMode: "scoped"`, author every `:scope` rule with a descendant combinator. The `@scope` boundary is the **variant wrapper `<div data-impeccable-variant="N">`**, not the element you're designing. A bare `:scope { background: cream; }` styles the wrapper, not the inner replacement, so the cream lands on a `display: contents` shell while the actual element keeps page defaults. Always step in: `:scope > .card`, `:scope > section`, `:scope .hero-title`, etc. The fake test agent's CSS in `tests/live-e2e/agent.mjs` is a faithful template; every scoped rule starts `:scope > ...`.
|
||||
|
||||
**JSX / TSX target files.** Wrap `<style>` content in a template literal so the CSS `{` / `}` aren't parsed as JSX expressions, and use `className=` / `style={{…}}` on every variant element. Keep `data-impeccable-*` attributes as-is; they're plain strings:
|
||||
|
||||
@@ -369,10 +396,11 @@ Remove the wrapper you inserted in Step 2. Nothing else to do.
|
||||
|
||||
## Handle `accept`
|
||||
|
||||
Event: `{id, variantId, _acceptResult}`. The poll script already ran `live-accept.mjs` to handle the file operation deterministically; the browser DOM is already updated.
|
||||
Event: `{id, variantId, _acceptResult, _completionAck}`. The poll script already ran `live-accept.mjs` to handle the file operation deterministically, then acknowledged event delivery to the helper. The browser DOM is already updated.
|
||||
|
||||
- `_completionAck.ok !== true`: do not poll yet. Run `live-status.mjs` / `live-resume.mjs`, complete the cleanup manually if needed, then run `live-complete.mjs --id EVENT_ID`.
|
||||
- `_acceptResult.handled: true` and `carbonize: false`: nothing to do. Poll again.
|
||||
- `_acceptResult.handled: true` and `carbonize: true`: **post-accept cleanup is required before the next poll.** See the "Required after accept (carbonize)" section below. The `event._acceptResult.todo` field and a stderr banner both list the steps explicitly; neither is decorative.
|
||||
- `_acceptResult.handled: true` and `carbonize: true`: **post-accept cleanup is required before the next poll.** See the "Required after accept (carbonize)" section below. The `event._acceptResult.todo` field, `_completionAck.requiresComplete`, and a stderr banner all point at this required follow-up; none are decorative. After cleanup, run `live-complete.mjs --id EVENT_ID`, then poll again.
|
||||
- `_acceptResult.handled: false, mode: "fallback"`: the session lived in a generated file and the script refused to persist there. You've already written the accepted variant into true source during Handle fallback Step 3; just clean up the temporary wrapper in the served file if any, and poll again.
|
||||
- `_acceptResult.handled: false` without `mode`: manual cleanup: read file, find markers, edit.
|
||||
|
||||
@@ -388,13 +416,13 @@ Do these five steps in the current thread, synchronously, before the next poll.
|
||||
4. **Unwrap the accepted content.** Delete the `<div data-impeccable-variant="N" style="display: contents">` that wraps it. Drop `data-impeccable-params` and any `data-p-*` attributes from it; those are live-mode plumbing, not source.
|
||||
5. **Delete the inline `<style>` block, the `<!-- impeccable-param-values -->` comment if present, and both `<!-- impeccable-carbonize-start/end -->` markers.** Also drop any `@scope` rules for variants other than the accepted one; those are dead code now.
|
||||
|
||||
Then poll again.
|
||||
After the file is clean, run `live-complete.mjs --id SESSION_ID`, verify it reports `phase: "completed"`, then poll again.
|
||||
|
||||
A background agent may be used for the rewrite, but the current thread is responsible for verifying the five steps are complete before issuing the next poll. In practice, inline is usually faster and less error-prone.
|
||||
|
||||
## Handle `discard`
|
||||
|
||||
Event: `{id, _acceptResult}`. The poll script already restored the original and removed all variant markers. Nothing to do. Poll again.
|
||||
Event: `{id, _acceptResult, _completionAck}`. The poll script already restored the original, removed all variant markers, and acknowledged `discarded` durable completion. Nothing to do unless `_completionAck.ok !== true`; in that case run `live-complete.mjs --id EVENT_ID --discarded`, then poll again.
|
||||
|
||||
## Handle `prefetch`
|
||||
|
||||
@@ -424,7 +452,7 @@ When the poll returns `exit`, proceed to cleanup. If the poll is still running a
|
||||
node .agents/skills/impeccable/scripts/live-server.mjs stop
|
||||
```
|
||||
|
||||
Stops the HTTP server and runs `live-inject.mjs --remove` to strip `localhost:…/live.js` from the HTML entry. To stop the server but keep the inject tag (for a quick restart), use `stop --keep-inject`. `config.json` persists for future sessions.
|
||||
Stops the HTTP server and runs `live-inject.mjs --remove` to strip `localhost:…/live.js` from the HTML entry. To stop the server but keep the inject tag (for a quick restart), use `stop --keep-inject`. `.impeccable/live/config.json` persists as project config for future sessions.
|
||||
|
||||
Then:
|
||||
- Remove any leftover variant wrappers (search for `impeccable-variants-start` markers).
|
||||
@@ -432,7 +460,7 @@ Then:
|
||||
|
||||
## First-time setup (config missing or invalid)
|
||||
|
||||
If `live.mjs` outputs `{ ok: false, error: "config_missing" | "config_invalid", path }`, write `config.json` at the reported path.
|
||||
If `live.mjs` outputs `{ ok: false, error: "config_missing" | "config_invalid", path }`, write the live config at the reported path. By default this is `.impeccable/live/config.json`.
|
||||
|
||||
Schema:
|
||||
|
||||
@@ -512,7 +540,7 @@ node .agents/skills/impeccable/scripts/detect-csp.mjs
|
||||
|
||||
Output: `{ shape, signals }` where `shape` is one of `append-arrays`, `append-string`, `middleware`, `meta-tag`, or `null`. The shape is named by *patch mechanism*, so one template covers many frameworks.
|
||||
|
||||
- **`null`**: no CSP; skip to writing `config.json` with `cspChecked: true`.
|
||||
- **`null`**: no CSP; skip to writing `.impeccable/live/config.json` with `cspChecked: true`.
|
||||
- **`append-arrays`**: CSP defined as structured directive arrays. Auto-patchable. See *append-arrays* below. Covers:
|
||||
- Monorepo helpers with `additionalScriptSrc` / `additionalConnectSrc` options (Next.js + shared config package)
|
||||
- SvelteKit `kit.csp.directives`
|
||||
@@ -589,6 +617,6 @@ Reference outputs:
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
If a user says "no" to the CSP patch at setup time and later complains that live doesn't work: their dev CSP blocks `http://localhost:8400`. Fix: delete `cspChecked` from `config.json` and re-run `live.mjs`: setup will ask again.
|
||||
If a user says "no" to the CSP patch at setup time and later complains that live doesn't work: their dev CSP blocks `http://localhost:8400`. Fix: delete `cspChecked` from `.impeccable/live/config.json` and re-run `live.mjs`: setup will ask again.
|
||||
|
||||
Then re-run `live.mjs`.
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
Gathers design context for a project and writes two complementary files at the project root:
|
||||
|
||||
- **PRODUCT.md** (strategic): register, target users, product purpose, brand personality, anti-references, strategic design principles. Answers "who/what/why".
|
||||
- **DESIGN.md** (visual): visual theme, color palette, typography, components, layout. Follows the [Google Stitch DESIGN.md format](https://stitch.withgoogle.com/docs/design-md/format/). Answers "how it looks".
|
||||
- **PRODUCT.md** (strategic): root project file for register, target users, product purpose, brand personality, anti-references, strategic design principles. Answers "who/what/why".
|
||||
- **DESIGN.md** (visual): root project file for visual theme, color palette, typography, components, layout. Follows the [Google Stitch DESIGN.md format](https://stitch.withgoogle.com/docs/design-md/format/). Answers "how it looks".
|
||||
|
||||
Every other impeccable command reads these files before doing any work.
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
export const IMPECCABLE_DIR = '.impeccable';
|
||||
export const LIVE_DIR = 'live';
|
||||
|
||||
export function getImpeccableDir(cwd = process.cwd()) {
|
||||
return path.join(cwd, IMPECCABLE_DIR);
|
||||
}
|
||||
|
||||
export function getDesignSidecarPath(cwd = process.cwd()) {
|
||||
return path.join(getImpeccableDir(cwd), 'design.json');
|
||||
}
|
||||
|
||||
export function getDesignSidecarCandidates(cwd = process.cwd(), contextDir = cwd) {
|
||||
const candidates = [
|
||||
getDesignSidecarPath(cwd),
|
||||
path.join(cwd, 'DESIGN.json'),
|
||||
];
|
||||
const contextLegacy = path.join(contextDir, 'DESIGN.json');
|
||||
if (!candidates.includes(contextLegacy)) candidates.push(contextLegacy);
|
||||
return candidates;
|
||||
}
|
||||
|
||||
export function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd) {
|
||||
return firstExisting(getDesignSidecarCandidates(cwd, contextDir));
|
||||
}
|
||||
|
||||
export function getLiveDir(cwd = process.cwd()) {
|
||||
return path.join(getImpeccableDir(cwd), LIVE_DIR);
|
||||
}
|
||||
|
||||
export function getLiveConfigPath(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'config.json');
|
||||
}
|
||||
|
||||
export function getLegacyLiveConfigPath(scriptsDir) {
|
||||
return path.join(scriptsDir, 'config.json');
|
||||
}
|
||||
|
||||
export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = process.env } = {}) {
|
||||
if (env.IMPECCABLE_LIVE_CONFIG && env.IMPECCABLE_LIVE_CONFIG.trim()) {
|
||||
const configured = env.IMPECCABLE_LIVE_CONFIG.trim();
|
||||
return path.isAbsolute(configured) ? configured : path.resolve(cwd, configured);
|
||||
}
|
||||
const primary = getLiveConfigPath(cwd);
|
||||
if (fs.existsSync(primary)) return primary;
|
||||
if (scriptsDir) {
|
||||
const legacy = getLegacyLiveConfigPath(scriptsDir);
|
||||
if (fs.existsSync(legacy)) return legacy;
|
||||
}
|
||||
return primary;
|
||||
}
|
||||
|
||||
export function getLiveServerPath(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'server.json');
|
||||
}
|
||||
|
||||
export function getLegacyLiveServerPath(cwd = process.cwd()) {
|
||||
return path.join(cwd, '.impeccable-live.json');
|
||||
}
|
||||
|
||||
export function readLiveServerInfo(cwd = process.cwd()) {
|
||||
for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) {
|
||||
try {
|
||||
return { info: JSON.parse(fs.readFileSync(filePath, 'utf-8')), path: filePath };
|
||||
} catch {
|
||||
/* try next */
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function writeLiveServerInfo(cwd = process.cwd(), info) {
|
||||
const filePath = getLiveServerPath(cwd);
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, JSON.stringify(info));
|
||||
return filePath;
|
||||
}
|
||||
|
||||
export function removeLiveServerInfo(cwd = process.cwd()) {
|
||||
for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) {
|
||||
try { fs.unlinkSync(filePath); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
export function getLiveSessionsDir(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'sessions');
|
||||
}
|
||||
|
||||
export function getLegacyLiveSessionsDir(cwd = process.cwd()) {
|
||||
return path.join(cwd, '.impeccable-live', 'sessions');
|
||||
}
|
||||
|
||||
export function getLiveAnnotationsDir(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'annotations');
|
||||
}
|
||||
|
||||
export function getLegacyLiveAnnotationsDir(cwd = process.cwd()) {
|
||||
return path.join(cwd, '.impeccable-live', 'annotations');
|
||||
}
|
||||
|
||||
function firstExisting(paths) {
|
||||
return paths.find((filePath) => fs.existsSync(filePath)) || null;
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Browser-side durable session helpers for Impeccable live mode.
|
||||
*
|
||||
* Kept separate from live-browser.js so recovery state can be tested without
|
||||
* booting the full overlay UI. Served before live-browser.js and attached to
|
||||
* window.__IMPECCABLE_LIVE_SESSION__.
|
||||
*/
|
||||
(function (root) {
|
||||
'use strict';
|
||||
|
||||
function createLiveBrowserSessionState({ prefix, storage, idFactory }) {
|
||||
if (!prefix) throw new Error('prefix required');
|
||||
const store = storage || root.localStorage;
|
||||
const makeId = idFactory || function () { return Math.random().toString(16).slice(2, 10); };
|
||||
const sessionKey = prefix + '-session';
|
||||
const handledKey = sessionKey + '-handled';
|
||||
const scrollKey = sessionKey + '-scroll';
|
||||
let checkpointRevision = 0;
|
||||
const owner = makeId();
|
||||
|
||||
function safeRead(key) {
|
||||
try { return store.getItem(key); } catch { return null; }
|
||||
}
|
||||
|
||||
function safeWrite(key, value) {
|
||||
try { store.setItem(key, value); } catch { /* quota exceeded or private mode */ }
|
||||
}
|
||||
|
||||
function safeRemove(key) {
|
||||
try { store.removeItem(key); } catch { /* unavailable storage */ }
|
||||
}
|
||||
|
||||
function loadSession() {
|
||||
try {
|
||||
const raw = safeRead(sessionKey);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Number.isInteger(parsed.checkpointRevision)) {
|
||||
checkpointRevision = Math.max(checkpointRevision, parsed.checkpointRevision);
|
||||
}
|
||||
return parsed;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
function saveSession(session) {
|
||||
if (!session || !session.id) return;
|
||||
const payload = {
|
||||
...session,
|
||||
checkpointRevision,
|
||||
};
|
||||
safeWrite(sessionKey, JSON.stringify(payload));
|
||||
}
|
||||
|
||||
function clearSession() {
|
||||
safeRemove(sessionKey);
|
||||
}
|
||||
|
||||
function nextCheckpointRevision() {
|
||||
checkpointRevision += 1;
|
||||
const existing = loadSession();
|
||||
if (existing?.id) saveSession(existing);
|
||||
return checkpointRevision;
|
||||
}
|
||||
|
||||
function seedCheckpointRevision(value) {
|
||||
if (Number.isInteger(value)) checkpointRevision = Math.max(checkpointRevision, value);
|
||||
return checkpointRevision;
|
||||
}
|
||||
|
||||
function currentCheckpointRevision() {
|
||||
return checkpointRevision;
|
||||
}
|
||||
|
||||
function markHandled(id) {
|
||||
if (!id) return;
|
||||
safeWrite(handledKey, id);
|
||||
}
|
||||
|
||||
function isHandled(id) {
|
||||
return !!id && safeRead(handledKey) === id;
|
||||
}
|
||||
|
||||
function clearHandled() {
|
||||
safeRemove(handledKey);
|
||||
}
|
||||
|
||||
function writeScrollY(y) {
|
||||
safeWrite(scrollKey, String(y));
|
||||
}
|
||||
|
||||
function readScrollY() {
|
||||
const raw = safeRead(scrollKey);
|
||||
if (raw == null) return null;
|
||||
const n = parseFloat(raw);
|
||||
return isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
function clearScrollY() {
|
||||
safeRemove(scrollKey);
|
||||
}
|
||||
|
||||
return {
|
||||
owner,
|
||||
sessionKey,
|
||||
handledKey,
|
||||
scrollKey,
|
||||
saveSession,
|
||||
loadSession,
|
||||
clearSession,
|
||||
nextCheckpointRevision,
|
||||
seedCheckpointRevision,
|
||||
currentCheckpointRevision,
|
||||
markHandled,
|
||||
isHandled,
|
||||
clearHandled,
|
||||
writeScrollY,
|
||||
readScrollY,
|
||||
clearScrollY,
|
||||
};
|
||||
}
|
||||
|
||||
root.__IMPECCABLE_LIVE_SESSION__ = { createLiveBrowserSessionState };
|
||||
})(typeof window !== 'undefined' ? window : globalThis);
|
||||
@@ -50,6 +50,16 @@
|
||||
const Z = { highlight: 100001, bar: 100005, picker: 100007, toast: 100010 };
|
||||
const EASE = 'cubic-bezier(0.22, 1, 0.36, 1)'; // ease-out-quint
|
||||
const PREFIX = 'impeccable-live';
|
||||
const sessionState = window.__IMPECCABLE_LIVE_SESSION__?.createLiveBrowserSessionState({
|
||||
prefix: PREFIX,
|
||||
storage: localStorage,
|
||||
idFactory: () => crypto.randomUUID().replace(/-/g, '').slice(0, 8),
|
||||
});
|
||||
if (!sessionState) {
|
||||
console.error('[impeccable] live-browser-session.js was not loaded. Live mode cannot start safely.');
|
||||
window.__IMPECCABLE_LIVE_INIT__ = false;
|
||||
return;
|
||||
}
|
||||
const HIGHLIGHT_TRANSITION =
|
||||
'top 140ms ' + EASE +
|
||||
', left 140ms ' + EASE +
|
||||
@@ -112,6 +122,8 @@
|
||||
let hasProjectContext = false;
|
||||
let selectedAction = 'impeccable';
|
||||
let selectedCount = 3;
|
||||
const browserOwner = sessionState.owner;
|
||||
let checkpointTimer = null;
|
||||
|
||||
// Scroll lock — holds window.scrollY at a fixed value while the session is
|
||||
// active, so HMR DOM patches and variant swaps can't drift the page. See
|
||||
@@ -126,21 +138,9 @@
|
||||
// (Previously: saveSession wrote scrollY alongside state, so every call
|
||||
// during resume overwrote the pre-reload value with whatever the browser
|
||||
// had landed on, typically 0.)
|
||||
const SCROLL_KEY_SUFFIX = '-scroll';
|
||||
function writeScrollY(y) {
|
||||
try { localStorage.setItem(LS_KEY + SCROLL_KEY_SUFFIX, String(y)); } catch {}
|
||||
}
|
||||
function readScrollY() {
|
||||
try {
|
||||
const raw = localStorage.getItem(LS_KEY + SCROLL_KEY_SUFFIX);
|
||||
if (raw == null) return null;
|
||||
const n = parseFloat(raw);
|
||||
return isFinite(n) ? n : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
function clearScrollY() {
|
||||
try { localStorage.removeItem(LS_KEY + SCROLL_KEY_SUFFIX); } catch {}
|
||||
}
|
||||
function writeScrollY(y) { sessionState.writeScrollY(y); }
|
||||
function readScrollY() { return sessionState.readScrollY(); }
|
||||
function clearScrollY() { sessionState.clearScrollY(); }
|
||||
|
||||
// Pre-empt the browser: apply manual scroll restoration and jump to the
|
||||
// saved scrollY at script-parse time. Retries on fonts.ready and load
|
||||
@@ -1585,6 +1585,7 @@
|
||||
paramsCurrentValues[p.id] = v;
|
||||
readout.textContent = formatRangeValue(input);
|
||||
applyParamValue(variantEl, p, v);
|
||||
queueCheckpoint('param_changed');
|
||||
});
|
||||
row.appendChild(input);
|
||||
} else if (p.kind === 'toggle') {
|
||||
@@ -1615,6 +1616,7 @@
|
||||
knob.style.left = next ? '18px' : '2px';
|
||||
readout.textContent = next ? 'On' : 'Off';
|
||||
applyParamValue(variantEl, p, next);
|
||||
queueCheckpoint('param_changed');
|
||||
});
|
||||
row.appendChild(track);
|
||||
} else if (p.kind === 'steps') {
|
||||
@@ -1651,6 +1653,7 @@
|
||||
btn.style.color = on ? 'oklch(98% 0 0)' : P.text;
|
||||
});
|
||||
applyParamValue(variantEl, p, o.value);
|
||||
queueCheckpoint('param_changed');
|
||||
});
|
||||
segRow.appendChild(b);
|
||||
segBtns.push({ btn: b, val: o.value });
|
||||
@@ -1872,19 +1875,26 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const previousVisibleVariant = currentSessionId === sessionId ? visibleVariant : 0;
|
||||
|
||||
// Replace the live element with the full wrapper from source
|
||||
const wrapper = srcWrapper.cloneNode(true);
|
||||
liveEl.parentElement.replaceChild(wrapper, liveEl);
|
||||
|
||||
// Update state: count variants, show the first one
|
||||
// Update state: count variants, preserving the user's current variant
|
||||
// when a late HMR/source reinjection lands after they have cycled.
|
||||
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
|
||||
arrivedVariants = variants.length;
|
||||
expectedVariants = parseInt(wrapper.dataset.impeccableVariantCount || arrivedVariants);
|
||||
visibleVariant = 1;
|
||||
showVariantInDOM(sessionId, 1);
|
||||
const saved = loadSession();
|
||||
const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0;
|
||||
visibleVariant = previousVisibleVariant > 0 && previousVisibleVariant <= arrivedVariants
|
||||
? previousVisibleVariant
|
||||
: (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1);
|
||||
showVariantInDOM(sessionId, visibleVariant);
|
||||
|
||||
// Update selectedElement to the visible variant's content
|
||||
selectedElement = pickVariantContent(wrapper, 1) || wrapper.parentElement;
|
||||
selectedElement = pickVariantContent(wrapper, visibleVariant) || wrapper.parentElement;
|
||||
|
||||
state = 'CYCLING';
|
||||
hideShaderOverlay();
|
||||
@@ -1907,6 +1917,7 @@
|
||||
updateSelectedElement();
|
||||
updateBarContent('cycling');
|
||||
saveSession();
|
||||
queueCheckpoint('variant_changed');
|
||||
}
|
||||
|
||||
function updateSelectedElement() {
|
||||
@@ -1917,6 +1928,18 @@
|
||||
if (visEl) selectedElement = visEl;
|
||||
}
|
||||
|
||||
function readVisibleVariantFromDOM(sessionId) {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return 0;
|
||||
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
|
||||
for (const variant of variants) {
|
||||
if (variant.style.display === 'none') continue;
|
||||
const idx = parseInt(variant.dataset.impeccableVariant || '0', 10);
|
||||
if (idx > 0) return idx;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Resolve the element that represents the variant's visible content.
|
||||
// Contract: each variant div should contain exactly one top-level element
|
||||
// (the full replacement). In practice a model may ship loose siblings or
|
||||
@@ -2107,8 +2130,10 @@
|
||||
updating = true;
|
||||
arrivedVariants = count;
|
||||
if (visibleVariant === 0 && arrivedVariants > 0) {
|
||||
visibleVariant = 1;
|
||||
showVariantInDOM(sessionId, 1);
|
||||
const saved = loadSession();
|
||||
const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0;
|
||||
visibleVariant = savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1;
|
||||
showVariantInDOM(sessionId, visibleVariant);
|
||||
// showVariantInDOM hid the original (display:none); if we were still
|
||||
// anchored to the original's content, its boundingRect is now zero
|
||||
// and the bar snaps to (0,0). Re-point at the visible variant instead.
|
||||
@@ -2128,6 +2153,7 @@
|
||||
updateBarContent('generating');
|
||||
}
|
||||
saveSession();
|
||||
queueCheckpoint(state === 'CYCLING' ? 'variants_ready' : 'variants_progress');
|
||||
updating = false;
|
||||
});
|
||||
|
||||
@@ -2236,6 +2262,7 @@
|
||||
|
||||
/** Server died or became unreachable. Reset UI to a clean state. */
|
||||
function handleServerLost() {
|
||||
const recoveryState = currentSessionId ? state : 'IDLE';
|
||||
if (state === 'GENERATING' || state === 'CYCLING' || state === 'SAVING') {
|
||||
showToast('Live server disconnected. Session ended.', 5000);
|
||||
}
|
||||
@@ -2246,21 +2273,61 @@
|
||||
stopScrollTracking();
|
||||
if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
|
||||
stopScrollLock();
|
||||
clearScrollY();
|
||||
clearSession();
|
||||
// Preserve local session state on server loss. The durable journal is the
|
||||
// source of truth, but localStorage plus the variant wrapper lets the UI
|
||||
// resume after a helper restart or page reload instead of treating a
|
||||
// transient disconnect as an explicit discard.
|
||||
selectedElement = null;
|
||||
currentSessionId = null;
|
||||
selectedAction = 'impeccable';
|
||||
state = 'IDLE';
|
||||
state = recoveryState;
|
||||
if (currentSessionId) saveSession();
|
||||
}
|
||||
|
||||
function sendEvent(msg) {
|
||||
function sendEvent(msg, opts) {
|
||||
msg.token = TOKEN;
|
||||
fetch('http://localhost:' + PORT + '/events', {
|
||||
function handleFailure(err) {
|
||||
console.error('[impeccable] Failed to send event:', err);
|
||||
if (opts && opts.throwOnError) throw err;
|
||||
return null;
|
||||
}
|
||||
return fetch('http://localhost:' + PORT + '/events', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(msg),
|
||||
}).catch(err => console.error('[impeccable] Failed to send event:', err));
|
||||
}).then(res => {
|
||||
if (res.ok) return res;
|
||||
return handleFailure(new Error('HTTP ' + res.status + ' ' + res.statusText));
|
||||
}).catch(handleFailure);
|
||||
}
|
||||
|
||||
function checkpointPayload(reason) {
|
||||
return {
|
||||
type: 'checkpoint',
|
||||
id: currentSessionId,
|
||||
revision: sessionState.nextCheckpointRevision(),
|
||||
owner: browserOwner,
|
||||
phase: String(state || '').toLowerCase(),
|
||||
reason,
|
||||
pageUrl: location.pathname,
|
||||
expectedVariants,
|
||||
arrivedVariants,
|
||||
visibleVariant,
|
||||
paramValues: { ...paramsCurrentValues },
|
||||
};
|
||||
}
|
||||
|
||||
function sendCheckpoint(reason) {
|
||||
if (!currentSessionId) return Promise.resolve(null);
|
||||
return sendEvent(checkpointPayload(reason)).catch(() => null);
|
||||
}
|
||||
|
||||
function queueCheckpoint(reason) {
|
||||
if (!currentSessionId) return;
|
||||
if (checkpointTimer) clearTimeout(checkpointTimer);
|
||||
checkpointTimer = setTimeout(() => {
|
||||
checkpointTimer = null;
|
||||
sendCheckpoint(reason);
|
||||
}, 120);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -2496,6 +2563,7 @@
|
||||
state = 'GENERATING';
|
||||
showBar('generating');
|
||||
saveSession();
|
||||
sendCheckpoint('generate_started');
|
||||
writeScrollY(window.scrollY);
|
||||
if (variantObserver) variantObserver.disconnect();
|
||||
variantObserver = startVariantObserver(currentSessionId);
|
||||
@@ -2920,13 +2988,12 @@ void main() {
|
||||
|
||||
function handleAccept() {
|
||||
if (!currentSessionId || arrivedVariants === 0) return;
|
||||
const domVisibleVariant = readVisibleVariantFromDOM(currentSessionId);
|
||||
if (domVisibleVariant > 0) visibleVariant = domVisibleVariant;
|
||||
const acceptPayload = { type: 'accept', id: currentSessionId, variantId: String(visibleVariant) };
|
||||
if (Object.keys(paramsCurrentValues).length > 0) {
|
||||
acceptPayload.paramValues = { ...paramsCurrentValues };
|
||||
}
|
||||
sendEvent(acceptPayload);
|
||||
markSessionHandled();
|
||||
|
||||
// The accepted variant is already the only visible child of the wrapper
|
||||
// (all other variants are display:none). HMR from the source rewrite will
|
||||
// replace the wrapper imminently. Don't eagerly replaceChild here — React
|
||||
@@ -2936,9 +3003,28 @@ void main() {
|
||||
const acceptedSessionId = currentSessionId;
|
||||
const acceptedVariant = visibleVariant;
|
||||
|
||||
state = 'CONFIRMED';
|
||||
updateBarContent('confirmed');
|
||||
setTimeout(function() {
|
||||
state = 'SAVING';
|
||||
updateBarContent('saving');
|
||||
|
||||
sendEvent(acceptPayload, { throwOnError: true })
|
||||
.then(() => {
|
||||
markSessionHandled();
|
||||
confirmAcceptAfterReceipt();
|
||||
})
|
||||
.catch(() => {
|
||||
state = 'CYCLING';
|
||||
updateBarContent('cycling');
|
||||
showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000);
|
||||
});
|
||||
|
||||
function confirmAcceptAfterReceipt() {
|
||||
state = 'CONFIRMED';
|
||||
updateBarContent('confirmed');
|
||||
scheduleAcceptCleanup();
|
||||
}
|
||||
|
||||
function scheduleAcceptCleanup() {
|
||||
setTimeout(function() {
|
||||
hideBar();
|
||||
hideHighlight();
|
||||
stopScrollTracking();
|
||||
@@ -2967,50 +3053,46 @@ void main() {
|
||||
accepted.style.display = 'contents';
|
||||
parent.replaceChild(accepted, wrapper);
|
||||
}
|
||||
}, 2000);
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDiscard() {
|
||||
if (!currentSessionId) return;
|
||||
sendEvent({ type: 'discard', id: currentSessionId });
|
||||
markSessionHandled();
|
||||
// Instant DOM restore + fire-and-forget (script handles file cleanup)
|
||||
cleanup();
|
||||
sendEvent({ type: 'discard', id: currentSessionId }, { throwOnError: true })
|
||||
.then(() => {
|
||||
markSessionHandled();
|
||||
cleanup();
|
||||
})
|
||||
.catch(() => showToast('Could not confirm discard with the live server. Session kept for recovery.', 5000));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session persistence via localStorage
|
||||
// Session persistence via live-browser-session.js
|
||||
// ---------------------------------------------------------------------------
|
||||
// Survives page reloads, browser close/reopen, HMR, and accidental refreshes.
|
||||
|
||||
const LS_KEY = PREFIX + '-session';
|
||||
|
||||
function saveSession() {
|
||||
if (!currentSessionId) return;
|
||||
// NOTE: scrollY is stored under a separate key (writeScrollY). Storing
|
||||
// it here would overwrite the Go-time value every time state changes.
|
||||
try {
|
||||
localStorage.setItem(LS_KEY, JSON.stringify({
|
||||
id: currentSessionId,
|
||||
state: state,
|
||||
action: selectedAction,
|
||||
count: selectedCount,
|
||||
expected: expectedVariants,
|
||||
arrived: arrivedVariants,
|
||||
visible: visibleVariant,
|
||||
}));
|
||||
} catch { /* quota exceeded or private mode */ }
|
||||
sessionState.saveSession({
|
||||
id: currentSessionId,
|
||||
state,
|
||||
action: selectedAction,
|
||||
count: selectedCount,
|
||||
expected: expectedVariants,
|
||||
arrived: arrivedVariants,
|
||||
visible: visibleVariant,
|
||||
});
|
||||
}
|
||||
|
||||
function loadSession() {
|
||||
try {
|
||||
const raw = localStorage.getItem(LS_KEY);
|
||||
return raw ? JSON.parse(raw) : null;
|
||||
} catch { return null; }
|
||||
return sessionState.loadSession();
|
||||
}
|
||||
|
||||
function clearSession() {
|
||||
try { localStorage.removeItem(LS_KEY); } catch {}
|
||||
sessionState.clearSession();
|
||||
}
|
||||
|
||||
/** Mark session as handled (accepted/discarded). The agent will clean up
|
||||
@@ -3018,19 +3100,15 @@ void main() {
|
||||
* prevents resumeSession from picking it up again after reload. */
|
||||
function markSessionHandled() {
|
||||
if (!currentSessionId) return;
|
||||
try {
|
||||
localStorage.setItem(LS_KEY + '-handled', currentSessionId);
|
||||
} catch {}
|
||||
sessionState.markHandled(currentSessionId);
|
||||
}
|
||||
|
||||
function isSessionHandled(id) {
|
||||
try {
|
||||
return localStorage.getItem(LS_KEY + '-handled') === id;
|
||||
} catch { return false; }
|
||||
return sessionState.isHandled(id);
|
||||
}
|
||||
|
||||
function clearHandled() {
|
||||
try { localStorage.removeItem(LS_KEY + '-handled'); } catch {}
|
||||
sessionState.clearHandled();
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
@@ -3161,6 +3239,7 @@ void main() {
|
||||
// hid. Now that state is CYCLING, re-fire.
|
||||
if (state === 'CYCLING') refreshParamsPanel();
|
||||
saveSession();
|
||||
queueCheckpoint('browser_resumed');
|
||||
|
||||
// Start observing for more variants AFTER initial setup
|
||||
if (variantObserver) variantObserver.disconnect();
|
||||
@@ -3617,7 +3696,7 @@ void main() {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Design System Panel — visualizes the project's DESIGN.json sidecar
|
||||
// Design System Panel — visualizes the project's .impeccable/design.json sidecar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DESIGN_PREFS_KEY = 'impeccable-live-design-panel';
|
||||
@@ -3629,7 +3708,7 @@ void main() {
|
||||
open: false,
|
||||
tab: 'visual', // 'visual' | 'raw'
|
||||
parsed: null, // parseDesignMd output (frontmatter + body sections)
|
||||
sidecar: null, // DESIGN.json v2 payload (extensions + components + narrative)
|
||||
sidecar: null, // .impeccable/design.json v2 payload (extensions + components + narrative)
|
||||
hasMd: false,
|
||||
hasSidecar: false,
|
||||
present: null, // true/false once fetch resolves
|
||||
@@ -4130,7 +4209,7 @@ void main() {
|
||||
box.className = 'stale';
|
||||
box.innerHTML = `
|
||||
<span class="stale-dot"></span>
|
||||
<span class="stale-text"><strong>DESIGN.md is newer than DESIGN.json.</strong> Run <code>/impeccable document</code> to refresh the sidecar.</span>
|
||||
<span class="stale-text"><strong>DESIGN.md is newer than .impeccable/design.json.</strong> Run <code>/impeccable document</code> to refresh the sidecar.</span>
|
||||
`;
|
||||
return box;
|
||||
}
|
||||
@@ -4138,7 +4217,7 @@ void main() {
|
||||
function renderParsedMdCta() {
|
||||
const box = document.createElement('div');
|
||||
box.className = 'parsed-md-cta';
|
||||
box.innerHTML = `<strong>Basic view</strong>This panel reads the tokens in your <code>DESIGN.md</code> frontmatter. Running <code>/impeccable document</code> also generates a <code>DESIGN.json</code> sidecar with your project's actual component snippets (button, input, nav) and tonal ramps, rendered live below the tokens.`;
|
||||
box.innerHTML = `<strong>Basic view</strong>This panel reads the tokens in your <code>DESIGN.md</code> frontmatter. Running <code>/impeccable document</code> also generates a <code>.impeccable/design.json</code> sidecar with your project's actual component snippets (button, input, nav) and tonal ramps, rendered live below the tokens.`;
|
||||
return box;
|
||||
}
|
||||
|
||||
@@ -4598,7 +4677,7 @@ void main() {
|
||||
|
||||
function cssSafe(v) {
|
||||
// Strip anything outside valid CSS value chars to prevent injection via
|
||||
// DESIGN.json values rendered into inline style strings.
|
||||
// .impeccable/design.json values rendered into inline style strings.
|
||||
return String(v).replace(/[<>"'`\n]/g, '');
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Canonical durable completion acknowledgement for Impeccable live sessions.
|
||||
*/
|
||||
|
||||
import { createLiveSessionStore } from './live-session-store.mjs';
|
||||
import { readLiveServerInfo } from './impeccable-paths.mjs';
|
||||
|
||||
function parseArgs(argv) {
|
||||
const out = { status: 'complete' };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
if (arg === '--id') out.id = argv[++i];
|
||||
else if (arg.startsWith('--id=')) out.id = arg.slice('--id='.length);
|
||||
else if (arg === '--discarded' || arg === '--discard') out.status = 'discarded';
|
||||
else if (arg === '--error') { out.status = 'agent_error'; out.message = argv[++i] || 'unknown error'; }
|
||||
else if (arg.startsWith('--error=')) { out.status = 'agent_error'; out.message = arg.slice('--error='.length); }
|
||||
else if (arg === '--help' || arg === '-h') out.help = true;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function completeCli() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (args.help || !args.id) {
|
||||
console.log(`Usage: node live-complete.mjs --id SESSION_ID [--discarded|--error MESSAGE]\n\nAppend the final durable session acknowledgement. Use after accept/discard cleanup is verified.`);
|
||||
process.exit(args.help ? 0 : 1);
|
||||
}
|
||||
|
||||
const serverInfo = readServerInfo();
|
||||
const serverResult = serverInfo ? await completeThroughServer(serverInfo, args) : null;
|
||||
if (serverResult?.ok) {
|
||||
const store = createLiveSessionStore({ cwd: process.cwd(), sessionId: args.id });
|
||||
const snapshot = store.getSnapshot(args.id, { includeCompleted: true });
|
||||
console.log(JSON.stringify({ ok: true, id: args.id, phase: snapshot?.phase || args.status, snapshot }, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
const store = createLiveSessionStore({ cwd: process.cwd(), sessionId: args.id });
|
||||
const event = args.status === 'discarded'
|
||||
? { type: 'discarded', id: args.id }
|
||||
: args.status === 'agent_error'
|
||||
? { type: 'agent_error', id: args.id, message: args.message || 'unknown error' }
|
||||
: { type: 'complete', id: args.id };
|
||||
const snapshot = store.appendEvent(event);
|
||||
console.log(JSON.stringify({ ok: true, id: args.id, phase: snapshot.phase, snapshot }, null, 2));
|
||||
}
|
||||
|
||||
function readServerInfo() {
|
||||
return readLiveServerInfo(process.cwd())?.info || null;
|
||||
}
|
||||
|
||||
async function completeThroughServer(info, args) {
|
||||
const type = args.status === 'discarded'
|
||||
? 'discarded'
|
||||
: args.status === 'agent_error'
|
||||
? 'error'
|
||||
: 'complete';
|
||||
try {
|
||||
const res = await fetch(`http://localhost:${info.port}/poll`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token: info.token, id: args.id, type, message: args.message }),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
return await res.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const _running = process.argv[1];
|
||||
if (_running?.endsWith('live-complete.mjs') || _running?.endsWith('live-complete.mjs/')) {
|
||||
completeCli();
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
export function completionTypeForAcceptResult(eventType, acceptResult) {
|
||||
if (eventType === 'discard') return acceptResult?.handled === true ? 'discarded' : 'error';
|
||||
if (acceptResult?.handled === true && acceptResult?.carbonize === true) return 'agent_done';
|
||||
if (acceptResult?.handled === true) return 'complete';
|
||||
if (acceptResult?.mode === 'error') return 'error';
|
||||
return 'agent_done';
|
||||
}
|
||||
|
||||
export function completionAckForAcceptResult(eventId, completionType, acceptResult) {
|
||||
const ack = { ok: true, type: completionType };
|
||||
if (acceptResult?.handled === true && acceptResult?.carbonize === true) {
|
||||
ack.final = false;
|
||||
ack.requiresComplete = true;
|
||||
ack.nextCommand = `live-complete.mjs --id ${eventId}`;
|
||||
ack.message = 'Carbonize cleanup must be verified, then the session must be completed explicitly before polling again.';
|
||||
}
|
||||
return ack;
|
||||
}
|
||||
@@ -2,23 +2,24 @@
|
||||
* CLI helper: insert/remove the live variant mode script tag in the project's
|
||||
* main HTML entry point.
|
||||
*
|
||||
* On first live run, the agent generates `config.json` in this script's
|
||||
* directory with the project's insertion target (framework-specific). On
|
||||
* On first live run, the agent generates `.impeccable/live/config.json`
|
||||
* with the project's insertion target (framework-specific). On
|
||||
* every subsequent run, this script handles insert/remove deterministically
|
||||
* with zero LLM involvement.
|
||||
*
|
||||
* Usage:
|
||||
* node live-inject.mjs --port PORT # Insert the live script tag
|
||||
* node live-inject.mjs --remove # Remove the live script tag
|
||||
* node live-inject.mjs --check # Check whether config.json exists
|
||||
* node live-inject.mjs --check # Check whether live config exists
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { resolveLiveConfigPath } from './impeccable-paths.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const CONFIG_PATH = process.env.IMPECCABLE_LIVE_CONFIG || path.join(__dirname, 'config.json');
|
||||
const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname });
|
||||
const MARKER_OPEN_TEXT = 'impeccable-live-start';
|
||||
const MARKER_CLOSE_TEXT = 'impeccable-live-end';
|
||||
|
||||
@@ -39,12 +40,12 @@ export async function injectCli() {
|
||||
console.log(`Usage: node live-inject.mjs [options]
|
||||
|
||||
Insert or remove the live mode script tag in the project's HTML entry point.
|
||||
Reads configuration from config.json (in this same directory).
|
||||
Reads configuration from .impeccable/live/config.json.
|
||||
|
||||
Modes:
|
||||
--port PORT Insert script tag pointing at http://localhost:PORT/live.js
|
||||
--remove Remove the script tag (if present)
|
||||
--check Print whether config.json exists and its content
|
||||
--check Print whether .impeccable/live/config.json exists and its content
|
||||
|
||||
Output (JSON):
|
||||
{ ok, file, inserted|removed, config? }`);
|
||||
|
||||
@@ -9,10 +9,10 @@
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { completionAckForAcceptResult, completionTypeForAcceptResult } from './live-completion.mjs';
|
||||
import { readLiveServerInfo } from './impeccable-paths.mjs';
|
||||
|
||||
// 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
|
||||
@@ -20,15 +20,29 @@ import { fileURLToPath } from 'node:url';
|
||||
// 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 {
|
||||
const record = readLiveServerInfo(process.cwd());
|
||||
if (!record) {
|
||||
console.error('No running live server found. Start one with: npx impeccable live');
|
||||
process.exit(1);
|
||||
}
|
||||
return record.info;
|
||||
}
|
||||
|
||||
export function buildPollReplyPayload(token, { id, type, message, file, data }) {
|
||||
return { token, id, type, message, file, data };
|
||||
}
|
||||
|
||||
async function postReply(base, token, reply) {
|
||||
const res = await fetch(`${base}/poll`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(buildPollReplyPayload(token, reply)),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error || res.statusText);
|
||||
}
|
||||
}
|
||||
|
||||
export async function pollCli() {
|
||||
@@ -69,23 +83,7 @@ Options:
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
await postReply(base, info.token, { id, type: status, message, file: filePath });
|
||||
|
||||
// Success — silent exit (agent doesn't need output for replies)
|
||||
} catch (err) {
|
||||
@@ -156,7 +154,23 @@ Options:
|
||||
);
|
||||
event._acceptResult = JSON.parse(out.trim());
|
||||
} catch (err) {
|
||||
event._acceptResult = { handled: false, error: err.message };
|
||||
event._acceptResult = { handled: false, mode: 'error', error: err.message };
|
||||
}
|
||||
|
||||
const completionType = completionTypeForAcceptResult(event.type, event._acceptResult);
|
||||
try {
|
||||
await postReply(base, info.token, {
|
||||
id: event.id,
|
||||
type: completionType,
|
||||
message: event._acceptResult?.error,
|
||||
file: event._acceptResult?.file,
|
||||
data: event._acceptResult?.carbonize === true ? { carbonize: true } : undefined,
|
||||
});
|
||||
} catch (err) {
|
||||
event._completionAck = { ok: false, error: err.message };
|
||||
}
|
||||
if (!event._completionAck) {
|
||||
event._completionAck = completionAckForAcceptResult(event.id, completionType, event._acceptResult);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,7 +178,7 @@ Options:
|
||||
// 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');
|
||||
process.stderr.write('\n⚠ Carbonize cleanup REQUIRED before next poll. After cleanup, run live-complete.mjs --id ' + event.id + '. See reference/live.md "Required after accept".\n\n');
|
||||
}
|
||||
|
||||
// Print the event as JSON — the agent reads this from stdout
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Recover the next agent action from the durable live-session journal.
|
||||
*/
|
||||
|
||||
import { createLiveSessionStore } from './live-session-store.mjs';
|
||||
|
||||
function parseArgs(argv) {
|
||||
const out = { id: null };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
if (arg === '--id') out.id = argv[++i];
|
||||
else if (arg.startsWith('--id=')) out.id = arg.slice('--id='.length);
|
||||
else if (arg === '--help' || arg === '-h') out.help = true;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function resumeCli() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (args.help) {
|
||||
console.log(`Usage: node live-resume.mjs [--id SESSION_ID]\n\nPrint the active durable session checkpoint and the next safe agent action.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const store = createLiveSessionStore({ cwd: process.cwd(), sessionId: args.id || undefined });
|
||||
const snapshot = args.id ? store.getSnapshot(args.id) : store.listActiveSessions()[0] || null;
|
||||
if (!snapshot) {
|
||||
console.log(JSON.stringify({ active: false, nextAction: 'No active durable live session found.' }, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
const pending = snapshot.pendingEvent || null;
|
||||
const nextAction = pending
|
||||
? `Run live-poll.mjs, handle ${pending.type} ${pending.id}, then acknowledge with live-poll.mjs --reply ${pending.id} done.`
|
||||
: snapshot.phase === 'carbonize_required'
|
||||
? `Finish carbonize cleanup${snapshot.sourceFile ? ` in ${snapshot.sourceFile}` : ''}, then run live-complete.mjs --id ${snapshot.id}.`
|
||||
: snapshot.phase === 'accept_requested'
|
||||
? `Run live-complete.mjs --id ${snapshot.id} after verifying the accepted variant is written.`
|
||||
: `Inspect ${snapshot.id}; no pending agent event is currently queued.`;
|
||||
|
||||
console.log(JSON.stringify({ active: true, snapshot, pendingEvent: pending, nextAction }, null, 2));
|
||||
}
|
||||
|
||||
const _running = process.argv[1];
|
||||
if (_running?.endsWith('live-resume.mjs') || _running?.endsWith('live-resume.mjs/')) {
|
||||
resumeCli();
|
||||
}
|
||||
@@ -22,14 +22,20 @@ import net from 'node:net';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseDesignMd } from './design-parser.mjs';
|
||||
import { resolveContextDir } from './load-context.mjs';
|
||||
import { createLiveSessionStore } from './live-session-store.mjs';
|
||||
import {
|
||||
getDesignSidecarPath,
|
||||
getLiveAnnotationsDir,
|
||||
readLiveServerInfo,
|
||||
removeLiveServerInfo,
|
||||
resolveDesignSidecarPath,
|
||||
writeLiveServerInfo,
|
||||
} from './impeccable-paths.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');
|
||||
// PRODUCT.md / DESIGN.md / DESIGN.json live wherever load-context.mjs resolves.
|
||||
// Keeps live-server in sync with the loader when users keep the docs in
|
||||
// .agents/context/, docs/, or a path set via IMPECCABLE_CONTEXT_DIR.
|
||||
// PRODUCT.md / DESIGN.md live wherever load-context.mjs resolves. The generated
|
||||
// DESIGN sidecar is project-local at .impeccable/design.json, with legacy
|
||||
// DESIGN.json fallback for existing projects.
|
||||
const CONTEXT_DIR = resolveContextDir(process.cwd());
|
||||
const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway
|
||||
const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s
|
||||
@@ -57,10 +63,12 @@ const state = {
|
||||
token: null,
|
||||
port: null,
|
||||
sseClients: new Set(), // SSE response objects (server→browser push)
|
||||
pendingEvents: [], // browser events waiting for agent poll
|
||||
pendingEvents: [], // browser events waiting for agent ack ({ event, leaseUntil })
|
||||
pendingPolls: [], // agent poll callbacks waiting for browser events
|
||||
exitTimer: null,
|
||||
sessionDir: null, // per-session tmp dir for annotation screenshots
|
||||
sessionStore: null,
|
||||
leaseTimer: null,
|
||||
};
|
||||
|
||||
// Cap per-annotation upload size. A full 1920×1080 PNG is typically <1 MB;
|
||||
@@ -68,13 +76,72 @@ const state = {
|
||||
const MAX_ANNOTATION_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
function enqueueEvent(event) {
|
||||
if (state.pendingPolls.length > 0) {
|
||||
state.pendingPolls.shift()(event);
|
||||
} else {
|
||||
state.pendingEvents.push(event);
|
||||
if (!event || (event.id && state.pendingEvents.some((entry) => entry.event?.id === event.id && entry.event?.type === event.type))) return;
|
||||
state.pendingEvents.push({ event, leaseUntil: 0 });
|
||||
flushPendingPolls();
|
||||
}
|
||||
|
||||
function restorePendingEventsFromStore() {
|
||||
if (!state.sessionStore) return;
|
||||
for (const snapshot of state.sessionStore.listActiveSessions()) {
|
||||
if (snapshot.pendingEvent) enqueueEvent(snapshot.pendingEvent);
|
||||
}
|
||||
}
|
||||
|
||||
function findAvailablePendingEvent(now = Date.now()) {
|
||||
return state.pendingEvents.find((entry) => !entry.leaseUntil || entry.leaseUntil <= now);
|
||||
}
|
||||
|
||||
function leaseEvent(entry, leaseMs) {
|
||||
if (!entry.event?.id) {
|
||||
const idx = state.pendingEvents.indexOf(entry);
|
||||
if (idx !== -1) state.pendingEvents.splice(idx, 1);
|
||||
return entry.event;
|
||||
}
|
||||
entry.leaseUntil = Date.now() + leaseMs;
|
||||
return entry.event;
|
||||
}
|
||||
|
||||
function acknowledgePendingEvent(id) {
|
||||
if (!id) return false;
|
||||
const idx = state.pendingEvents.findIndex((entry) => entry.event?.id === id);
|
||||
if (idx === -1) return false;
|
||||
state.pendingEvents.splice(idx, 1);
|
||||
scheduleLeaseFlush();
|
||||
return true;
|
||||
}
|
||||
|
||||
function scheduleLeaseFlush() {
|
||||
if (state.leaseTimer) {
|
||||
clearTimeout(state.leaseTimer);
|
||||
state.leaseTimer = null;
|
||||
}
|
||||
if (state.pendingPolls.length === 0) return;
|
||||
const now = Date.now();
|
||||
const nextLeaseUntil = state.pendingEvents
|
||||
.map((entry) => entry.leaseUntil || 0)
|
||||
.filter((leaseUntil) => leaseUntil > now)
|
||||
.sort((a, b) => a - b)[0];
|
||||
if (!nextLeaseUntil) return;
|
||||
state.leaseTimer = setTimeout(() => {
|
||||
state.leaseTimer = null;
|
||||
flushPendingPolls();
|
||||
}, Math.max(0, nextLeaseUntil - now));
|
||||
}
|
||||
|
||||
function flushPendingPolls() {
|
||||
while (state.pendingPolls.length > 0) {
|
||||
const entry = findAvailablePendingEvent();
|
||||
if (!entry) {
|
||||
scheduleLeaseFlush();
|
||||
return;
|
||||
}
|
||||
const poll = state.pendingPolls.shift();
|
||||
poll.resolve(leaseEvent(entry, poll.leaseMs));
|
||||
}
|
||||
scheduleLeaseFlush();
|
||||
}
|
||||
|
||||
/** Push a message to all connected SSE clients. */
|
||||
function broadcast(msg) {
|
||||
const data = 'data: ' + JSON.stringify(msg) + '\n\n';
|
||||
@@ -103,13 +170,16 @@ function loadBrowserScripts() {
|
||||
// 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 sessionPath = path.join(__dirname, 'live-browser-session.js');
|
||||
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);
|
||||
for (const p of [sessionPath, livePath]) {
|
||||
if (!fs.existsSync(p)) {
|
||||
process.stderr.write('Error: live browser script not found at ' + p + '\n');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
return { detectScript, livePath };
|
||||
return { detectScript, sessionPath, livePath };
|
||||
}
|
||||
|
||||
function hasProjectContext() {
|
||||
@@ -170,6 +240,13 @@ function validateEvent(msg) {
|
||||
return null;
|
||||
case 'discard':
|
||||
return isValidId(msg.id) ? null : 'discard: missing or malformed id';
|
||||
case 'checkpoint':
|
||||
if (!isValidId(msg.id)) return 'checkpoint: missing or malformed id';
|
||||
if (!Number.isInteger(msg.revision) || msg.revision < 0) return 'checkpoint: revision must be a non-negative integer';
|
||||
if (msg.paramValues !== undefined && (typeof msg.paramValues !== 'object' || msg.paramValues === null || Array.isArray(msg.paramValues))) {
|
||||
return 'checkpoint: paramValues must be an object';
|
||||
}
|
||||
return null;
|
||||
case 'exit':
|
||||
return null;
|
||||
case 'prefetch':
|
||||
@@ -184,7 +261,7 @@ function validateEvent(msg) {
|
||||
// HTTP request handler
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createRequestHandler({ detectScript, livePath }) {
|
||||
function createRequestHandler({ detectScript, sessionPath, livePath }) {
|
||||
return (req, res) => {
|
||||
const url = new URL(req.url, `http://localhost:${state.port}`);
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
@@ -200,17 +277,20 @@ function createRequestHandler({ detectScript, livePath }) {
|
||||
// the next tab reload. No-store headers prevent browser caching across
|
||||
// sessions — during iteration, a cached old script silently breaks
|
||||
// every subsequent session.
|
||||
let sessionScript;
|
||||
let liveScript;
|
||||
try {
|
||||
sessionScript = fs.readFileSync(sessionPath, 'utf-8');
|
||||
liveScript = fs.readFileSync(livePath, 'utf-8');
|
||||
} catch (err) {
|
||||
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
||||
res.end('Error reading live-browser.js: ' + err.message);
|
||||
res.end('Error reading live browser scripts: ' + err.message);
|
||||
return;
|
||||
}
|
||||
const body =
|
||||
`window.__IMPECCABLE_TOKEN__ = '${state.token}';\n` +
|
||||
`window.__IMPECCABLE_PORT__ = ${state.port};\n` +
|
||||
sessionScript + '\n' +
|
||||
liveScript;
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/javascript',
|
||||
@@ -305,6 +385,26 @@ function createRequestHandler({ detectScript, livePath }) {
|
||||
}
|
||||
|
||||
// --- Health ---
|
||||
if (p === '/status') {
|
||||
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 sessions = state.sessionStore ? state.sessionStore.listActiveSessions() : [];
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
status: 'ok',
|
||||
port: state.port,
|
||||
connectedClients: state.sseClients.size,
|
||||
pendingEvents: state.pendingEvents.map((entry) => ({
|
||||
id: entry.event?.id,
|
||||
type: entry.event?.type,
|
||||
leased: !!(entry.leaseUntil && entry.leaseUntil > Date.now()),
|
||||
leaseUntil: entry.leaseUntil || null,
|
||||
})),
|
||||
activeSessions: sessions,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (p === '/health') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
@@ -316,13 +416,13 @@ function createRequestHandler({ detectScript, livePath }) {
|
||||
}
|
||||
|
||||
// --- Design system (unified v2 response) + raw ---
|
||||
// /design-system.json returns both parsed DESIGN.md and DESIGN.json
|
||||
// /design-system.json returns both parsed DESIGN.md and .impeccable/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.
|
||||
// - sidecar: .impeccable/design.json contents when present.
|
||||
// Expected shape: schemaVersion 2, carrying
|
||||
// extensions + components + narrative.
|
||||
// /design-system/raw returns DESIGN.md markdown verbatim
|
||||
@@ -331,7 +431,7 @@ function createRequestHandler({ detectScript, livePath }) {
|
||||
if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
|
||||
|
||||
const mdPath = path.join(CONTEXT_DIR, 'DESIGN.md');
|
||||
const jsonPath = path.join(CONTEXT_DIR, 'DESIGN.json');
|
||||
const jsonPath = resolveDesignSidecarPath(process.cwd(), CONTEXT_DIR) || getDesignSidecarPath(process.cwd());
|
||||
const mdStat = statOrNull(mdPath);
|
||||
const jsonStat = statOrNull(jsonPath);
|
||||
|
||||
@@ -367,7 +467,7 @@ function createRequestHandler({ detectScript, livePath }) {
|
||||
try {
|
||||
response.sidecar = JSON.parse(fs.readFileSync(jsonPath, 'utf-8'));
|
||||
} catch (err) {
|
||||
response.sidecarError = 'Failed to parse DESIGN.json: ' + err.message;
|
||||
response.sidecarError = 'Failed to parse .impeccable/design.json: ' + err.message;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -449,7 +549,16 @@ function createRequestHandler({ detectScript, livePath }) {
|
||||
res.end(JSON.stringify({ error }));
|
||||
return;
|
||||
}
|
||||
enqueueEvent(msg);
|
||||
if (state.sessionStore && msg.id) {
|
||||
try {
|
||||
state.sessionStore.appendEvent(msg);
|
||||
} catch (err) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'session_store_append_failed', message: err.message }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (msg.type !== 'checkpoint') enqueueEvent(msg);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ ok: true }));
|
||||
});
|
||||
@@ -492,13 +601,16 @@ function handlePollGet(req, res, url) {
|
||||
return;
|
||||
}
|
||||
const timeout = parseInt(url.searchParams.get('timeout') || DEFAULT_POLL_TIMEOUT, 10);
|
||||
if (state.pendingEvents.length > 0) {
|
||||
const leaseMs = parseInt(url.searchParams.get('leaseMs') || '30000', 10);
|
||||
const available = findAvailablePendingEvent();
|
||||
if (available) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(state.pendingEvents.shift()));
|
||||
res.end(JSON.stringify(leaseEvent(available, leaseMs)));
|
||||
return;
|
||||
}
|
||||
const poll = { resolve, leaseMs };
|
||||
const timer = setTimeout(() => {
|
||||
const idx = state.pendingPolls.indexOf(resolve);
|
||||
const idx = state.pendingPolls.indexOf(poll);
|
||||
if (idx !== -1) state.pendingPolls.splice(idx, 1);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ type: 'timeout' }));
|
||||
@@ -508,10 +620,11 @@ function handlePollGet(req, res, url) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(event));
|
||||
}
|
||||
state.pendingPolls.push(resolve);
|
||||
state.pendingPolls.push(poll);
|
||||
scheduleLeaseFlush();
|
||||
req.on('close', () => {
|
||||
clearTimeout(timer);
|
||||
const idx = state.pendingPolls.indexOf(resolve);
|
||||
const idx = state.pendingPolls.indexOf(poll);
|
||||
if (idx !== -1) state.pendingPolls.splice(idx, 1);
|
||||
});
|
||||
}
|
||||
@@ -531,6 +644,26 @@ function handlePollPost(req, res) {
|
||||
res.end(JSON.stringify({ error: 'Unauthorized' }));
|
||||
return;
|
||||
}
|
||||
acknowledgePendingEvent(msg.id);
|
||||
if (state.sessionStore && msg.id) {
|
||||
try {
|
||||
const eventType = msg.type === 'discard' || msg.type === 'discarded'
|
||||
? 'discarded'
|
||||
: msg.type === 'complete'
|
||||
? 'complete'
|
||||
: msg.type === 'error'
|
||||
? 'agent_error'
|
||||
: 'agent_done';
|
||||
state.sessionStore.appendEvent({
|
||||
type: eventType,
|
||||
id: msg.id,
|
||||
file: msg.file,
|
||||
message: msg.message,
|
||||
carbonize: msg.data?.carbonize === true,
|
||||
});
|
||||
} catch { /* keep reply path best-effort; browser still needs SSE */ }
|
||||
}
|
||||
flushPendingPolls();
|
||||
// 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' });
|
||||
@@ -545,13 +678,15 @@ function handlePollPost(req, res) {
|
||||
let httpServer = null;
|
||||
|
||||
function shutdown() {
|
||||
try { fs.unlinkSync(LIVE_PID_FILE); } catch {}
|
||||
removeLiveServerInfo(process.cwd());
|
||||
if (state.leaseTimer) clearTimeout(state.leaseTimer);
|
||||
state.leaseTimer = null;
|
||||
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' });
|
||||
for (const poll of state.pendingPolls) poll.resolve({ type: 'exit' });
|
||||
state.pendingPolls.length = 0;
|
||||
if (httpServer) httpServer.close();
|
||||
process.exit(0);
|
||||
@@ -587,6 +722,7 @@ Endpoints:
|
||||
/events SSE stream (server→browser) + POST (browser→server)
|
||||
/poll Long-poll for agent CLI
|
||||
/source Raw source file reader (no-HMR fallback)
|
||||
/status Durable recovery status (token-protected)
|
||||
/health Health check`);
|
||||
process.exit(0);
|
||||
}
|
||||
@@ -594,7 +730,7 @@ Endpoints:
|
||||
if (args.includes('stop')) {
|
||||
const keepInject = args.includes('--keep-inject');
|
||||
try {
|
||||
const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
|
||||
const { info } = readLiveServerInfo(process.cwd()) || {};
|
||||
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 {
|
||||
@@ -645,7 +781,7 @@ if (args.includes('--background')) {
|
||||
const deadline = Date.now() + 10_000;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
|
||||
const { info } = readLiveServerInfo(process.cwd()) || {};
|
||||
if (info.pid !== process.pid) {
|
||||
// Output JSON so the agent can read port + token from stdout.
|
||||
console.log(JSON.stringify(info));
|
||||
@@ -659,30 +795,36 @@ if (args.includes('--background')) {
|
||||
}
|
||||
|
||||
// Check for existing session
|
||||
try {
|
||||
const existing = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
|
||||
try { process.kill(existing.pid, 0);
|
||||
const existingRecord = readLiveServerInfo(process.cwd());
|
||||
if (existingRecord?.info) {
|
||||
const existing = existingRecord.info;
|
||||
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 {}
|
||||
} catch {
|
||||
try { fs.unlinkSync(existingRecord.path); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
state.token = randomUUID();
|
||||
state.sessionStore = createLiveSessionStore({ cwd: process.cwd() });
|
||||
restorePendingEventsFromStore();
|
||||
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');
|
||||
const annotRoot = getLiveAnnotationsDir(process.cwd());
|
||||
fs.mkdirSync(annotRoot, { recursive: true });
|
||||
state.sessionDir = fs.mkdtempSync(path.join(annotRoot, 'session-'));
|
||||
|
||||
const { detectScript, livePath } = loadBrowserScripts();
|
||||
httpServer = http.createServer(createRequestHandler({ detectScript, livePath }));
|
||||
const { detectScript, sessionPath, livePath } = loadBrowserScripts();
|
||||
httpServer = http.createServer(createRequestHandler({ detectScript, sessionPath, 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 }));
|
||||
writeLiveServerInfo(process.cwd(), { 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`);
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { getLegacyLiveSessionsDir, getLiveSessionsDir } from './impeccable-paths.mjs';
|
||||
|
||||
const COMPLETED_PHASES = new Set(['completed', 'discarded']);
|
||||
|
||||
export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) {
|
||||
const rootDir = getLiveSessionsDir(cwd);
|
||||
const legacyRootDir = getLegacyLiveSessionsDir(cwd);
|
||||
fs.mkdirSync(rootDir, { recursive: true });
|
||||
const snapshotCache = new Map();
|
||||
|
||||
function loadCachedOrRebuild(id) {
|
||||
const cached = snapshotCache.get(id);
|
||||
if (cached) return cached;
|
||||
const journalPath = getReadableJournalPath(id);
|
||||
const rebuilt = rebuildSnapshotFromJournal(journalPath, id);
|
||||
snapshotCache.set(id, rebuilt);
|
||||
return rebuilt;
|
||||
}
|
||||
|
||||
function getReadableJournalPath(id) {
|
||||
const primary = getJournalPath(rootDir, id);
|
||||
if (fs.existsSync(primary)) return primary;
|
||||
const legacy = getJournalPath(legacyRootDir, id);
|
||||
if (fs.existsSync(legacy)) return legacy;
|
||||
return primary;
|
||||
}
|
||||
|
||||
return {
|
||||
rootDir,
|
||||
legacyRootDir,
|
||||
appendEvent(event) {
|
||||
const normalized = normalizeEvent(event, sessionId);
|
||||
const journalPath = getJournalPath(rootDir, normalized.id);
|
||||
const snapshotPath = getSnapshotPath(rootDir, normalized.id);
|
||||
const legacyJournalPath = getJournalPath(legacyRootDir, normalized.id);
|
||||
if (!fs.existsSync(journalPath) && fs.existsSync(legacyJournalPath)) {
|
||||
fs.copyFileSync(legacyJournalPath, journalPath);
|
||||
}
|
||||
const prior = loadCachedOrRebuild(normalized.id);
|
||||
const seq = prior.nextSeq;
|
||||
const entry = {
|
||||
seq,
|
||||
id: normalized.id,
|
||||
type: normalized.type,
|
||||
ts: new Date().toISOString(),
|
||||
event: normalized,
|
||||
};
|
||||
fs.appendFileSync(journalPath, JSON.stringify(entry) + '\n');
|
||||
const next = applyEvent(prior.snapshot, entry, prior.diagnostics);
|
||||
snapshotCache.set(normalized.id, { snapshot: next, diagnostics: next.diagnostics || [], nextSeq: seq + 1 });
|
||||
writeSnapshot(snapshotPath, next);
|
||||
return next;
|
||||
},
|
||||
getSnapshot(id = sessionId, opts = {}) {
|
||||
if (!id) throw new Error('session id required');
|
||||
const journalPath = getReadableJournalPath(id);
|
||||
const snapshotPath = getSnapshotPath(rootDir, id);
|
||||
const rebuilt = rebuildSnapshotFromJournal(journalPath, id);
|
||||
snapshotCache.set(id, rebuilt);
|
||||
writeSnapshot(snapshotPath, rebuilt.snapshot);
|
||||
if (!opts.includeCompleted && COMPLETED_PHASES.has(rebuilt.snapshot.phase)) return null;
|
||||
return rebuilt.snapshot;
|
||||
},
|
||||
listActiveSessions() {
|
||||
const ids = new Set();
|
||||
for (const dir of [legacyRootDir, rootDir]) {
|
||||
if (!fs.existsSync(dir)) continue;
|
||||
for (const name of fs.readdirSync(dir)) {
|
||||
if (name.endsWith('.jsonl')) ids.add(name.slice(0, -'.jsonl'.length));
|
||||
}
|
||||
}
|
||||
return [...ids]
|
||||
.sort()
|
||||
.map((id) => this.getSnapshot(id))
|
||||
.filter(Boolean);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeEvent(event, fallbackId) {
|
||||
if (!event || typeof event !== 'object') throw new Error('event object required');
|
||||
const id = event.id || fallbackId;
|
||||
if (!id || typeof id !== 'string') throw new Error('event id required');
|
||||
if (!event.type || typeof event.type !== 'string') throw new Error('event type required');
|
||||
return { ...event, id };
|
||||
}
|
||||
|
||||
function getJournalPath(rootDir, id) {
|
||||
return path.join(rootDir, safeSessionId(id) + '.jsonl');
|
||||
}
|
||||
|
||||
function getSnapshotPath(rootDir, id) {
|
||||
return path.join(rootDir, safeSessionId(id) + '.snapshot.json');
|
||||
}
|
||||
|
||||
function safeSessionId(id) {
|
||||
if (!/^[A-Za-z0-9_-]{1,128}$/.test(id)) throw new Error('invalid session id: ' + id);
|
||||
return id;
|
||||
}
|
||||
|
||||
function baseSnapshot(id) {
|
||||
return {
|
||||
id,
|
||||
phase: 'new',
|
||||
pageUrl: null,
|
||||
sourceFile: null,
|
||||
expectedVariants: 0,
|
||||
arrivedVariants: 0,
|
||||
visibleVariant: null,
|
||||
paramValues: {},
|
||||
pendingEventSeq: null,
|
||||
pendingEvent: null,
|
||||
deliveryLease: null,
|
||||
checkpointRevision: 0,
|
||||
activeOwner: null,
|
||||
sourceMarkers: {},
|
||||
fallbackMode: null,
|
||||
annotationArtifacts: [],
|
||||
diagnostics: [],
|
||||
updatedAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
function rebuildSnapshotFromJournal(journalPath, id) {
|
||||
let snapshot = baseSnapshot(id);
|
||||
const diagnostics = [];
|
||||
let nextSeq = 1;
|
||||
if (!fs.existsSync(journalPath)) return { snapshot, diagnostics, nextSeq };
|
||||
|
||||
const lines = fs.readFileSync(journalPath, 'utf-8').split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const entry = JSON.parse(line);
|
||||
if (!entry || typeof entry !== 'object') throw new Error('entry is not object');
|
||||
if (Number.isInteger(entry.seq)) nextSeq = Math.max(nextSeq, entry.seq + 1);
|
||||
snapshot = applyEvent(snapshot, entry);
|
||||
} catch (err) {
|
||||
diagnostics.push({
|
||||
error: 'journal_parse_failed',
|
||||
line: i + 1,
|
||||
message: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
snapshot.diagnostics = [...snapshot.diagnostics, ...diagnostics];
|
||||
return { snapshot, diagnostics, nextSeq };
|
||||
}
|
||||
|
||||
function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
|
||||
const event = entry.event || entry;
|
||||
const next = {
|
||||
...snapshot,
|
||||
paramValues: { ...(snapshot.paramValues || {}) },
|
||||
sourceMarkers: { ...(snapshot.sourceMarkers || {}) },
|
||||
annotationArtifacts: [...(snapshot.annotationArtifacts || [])],
|
||||
diagnostics: [...(snapshot.diagnostics || [])],
|
||||
updatedAt: entry.ts || new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (inheritedDiagnostics.length && next.diagnostics.length === 0) {
|
||||
next.diagnostics = [...inheritedDiagnostics];
|
||||
}
|
||||
|
||||
switch (event.type) {
|
||||
case 'generate':
|
||||
next.phase = 'generate_requested';
|
||||
next.pageUrl = event.pageUrl ?? next.pageUrl;
|
||||
next.expectedVariants = event.count ?? next.expectedVariants;
|
||||
next.pendingEventSeq = entry.seq ?? next.pendingEventSeq;
|
||||
next.pendingEvent = toPendingEvent(event);
|
||||
if (event.screenshotPath) upsertArtifact(next.annotationArtifacts, { type: 'screenshot', path: event.screenshotPath });
|
||||
break;
|
||||
case 'variants_ready':
|
||||
case 'agent_done':
|
||||
next.phase = event.carbonize === true ? 'carbonize_required' : 'variants_ready';
|
||||
next.sourceFile = event.file ?? next.sourceFile;
|
||||
next.arrivedVariants = event.arrivedVariants ?? (next.arrivedVariants ?? next.expectedVariants);
|
||||
next.pendingEventSeq = null;
|
||||
next.pendingEvent = null;
|
||||
if (event.carbonize === true) {
|
||||
next.diagnostics.push({
|
||||
error: 'carbonize_cleanup_required',
|
||||
file: event.file || null,
|
||||
message: 'Accepted variant still has carbonize markers that must be folded into source CSS.',
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'checkpoint':
|
||||
if ((event.revision ?? 0) >= (next.checkpointRevision ?? 0)) {
|
||||
next.phase = event.phase ?? next.phase;
|
||||
next.checkpointRevision = event.revision ?? next.checkpointRevision;
|
||||
next.activeOwner = event.owner ?? next.activeOwner;
|
||||
next.arrivedVariants = event.arrivedVariants ?? next.arrivedVariants;
|
||||
next.visibleVariant = event.visibleVariant ?? next.visibleVariant;
|
||||
if (event.paramValues) next.paramValues = { ...event.paramValues };
|
||||
} else {
|
||||
next.diagnostics.push({ error: 'stale_checkpoint_ignored', revision: event.revision });
|
||||
}
|
||||
break;
|
||||
case 'accept':
|
||||
case 'accept_intent':
|
||||
next.phase = 'accept_requested';
|
||||
next.visibleVariant = Number(event.variantId ?? next.visibleVariant);
|
||||
if (event.paramValues) next.paramValues = { ...event.paramValues };
|
||||
next.pendingEventSeq = entry.seq ?? next.pendingEventSeq;
|
||||
next.pendingEvent = toPendingEvent(event);
|
||||
break;
|
||||
case 'discard':
|
||||
next.phase = 'discard_requested';
|
||||
next.pendingEventSeq = entry.seq ?? next.pendingEventSeq;
|
||||
next.pendingEvent = toPendingEvent(event);
|
||||
break;
|
||||
case 'discarded':
|
||||
next.phase = 'discarded';
|
||||
next.pendingEventSeq = null;
|
||||
next.pendingEvent = null;
|
||||
break;
|
||||
case 'complete':
|
||||
next.phase = 'completed';
|
||||
next.pendingEventSeq = null;
|
||||
next.pendingEvent = null;
|
||||
break;
|
||||
case 'agent_error':
|
||||
next.phase = 'agent_error';
|
||||
next.pendingEventSeq = null;
|
||||
next.pendingEvent = null;
|
||||
next.diagnostics.push({ error: 'agent_error', message: event.message || 'unknown agent error' });
|
||||
break;
|
||||
default:
|
||||
next.diagnostics.push({ error: 'unknown_event_type', type: event.type });
|
||||
break;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function toPendingEvent(event) {
|
||||
const pending = { ...event };
|
||||
delete pending.token;
|
||||
return pending;
|
||||
}
|
||||
|
||||
function upsertArtifact(artifacts, artifact) {
|
||||
if (!artifacts.some((existing) => existing.path === artifact.path && existing.type === artifact.type)) {
|
||||
artifacts.push(artifact);
|
||||
}
|
||||
}
|
||||
|
||||
function writeSnapshot(snapshotPath, snapshot) {
|
||||
fs.writeFileSync(snapshotPath, JSON.stringify(snapshot, null, 2) + '\n');
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Print durable recovery status for Impeccable live sessions.
|
||||
*/
|
||||
|
||||
import { createLiveSessionStore } from './live-session-store.mjs';
|
||||
import { readLiveServerInfo } from './impeccable-paths.mjs';
|
||||
|
||||
function readServerInfo() {
|
||||
return readLiveServerInfo(process.cwd())?.info || null;
|
||||
}
|
||||
|
||||
async function fetchServerStatus(info) {
|
||||
if (!info) return null;
|
||||
try {
|
||||
const res = await fetch(`http://localhost:${info.port}/status?token=${info.token}`);
|
||||
if (!res.ok) return null;
|
||||
return await res.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function statusCli() {
|
||||
const info = readServerInfo();
|
||||
const server = await fetchServerStatus(info);
|
||||
const store = createLiveSessionStore({ cwd: process.cwd() });
|
||||
const activeSessions = store.listActiveSessions();
|
||||
const payload = {
|
||||
liveServer: server ? {
|
||||
status: server.status,
|
||||
port: server.port,
|
||||
connectedClients: server.connectedClients,
|
||||
pendingEvents: server.pendingEvents,
|
||||
} : null,
|
||||
activeSessions: server?.activeSessions || activeSessions,
|
||||
recoveryHint: server
|
||||
? 'Run live-poll.mjs to continue pending work, or live-complete.mjs --id <session> after manual cleanup.'
|
||||
: 'Start live-server.mjs to requeue pending durable events, then run live-poll.mjs.',
|
||||
};
|
||||
console.log(JSON.stringify(payload, null, 2));
|
||||
}
|
||||
|
||||
const _running = process.argv[1];
|
||||
if (_running?.endsWith('live-status.mjs') || _running?.endsWith('live-status.mjs/')) {
|
||||
statusCli();
|
||||
}
|
||||
@@ -185,6 +185,7 @@ The agent should insert variant HTML at insertLine.`);
|
||||
|
||||
const { startLine, endLine } = match;
|
||||
const commentSyntax = detectCommentSyntax(targetFile);
|
||||
const styleMode = detectStyleMode(targetFile);
|
||||
const isJsx = commentSyntax.open === '{/*';
|
||||
const indent = lines[startLine].match(/^(\s*)/)[1];
|
||||
|
||||
@@ -269,6 +270,10 @@ The agent should insert variant HTML at insertLine.`);
|
||||
endLine: startLine + wrapperLines.length + (originalLines.length - 1), // 1-indexed
|
||||
insertLine: insertLine + 1, // 1-indexed: where variants go
|
||||
commentSyntax: commentSyntax,
|
||||
styleMode: styleMode.mode,
|
||||
styleTag: styleMode.styleTag,
|
||||
cssSelectorPrefixExamples: buildCssSelectorPrefixExamples(styleMode.mode, count),
|
||||
cssAuthoring: buildCssAuthoring(styleMode, count),
|
||||
originalLineCount: originalLines.length,
|
||||
}));
|
||||
}
|
||||
@@ -335,6 +340,62 @@ function detectCommentSyntax(filePath) {
|
||||
return { open: '<!--', close: '-->' };
|
||||
}
|
||||
|
||||
function detectStyleMode(filePath) {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
if (ext === '.astro') {
|
||||
return {
|
||||
mode: 'astro-global-prefixed',
|
||||
styleTag: '<style is:inline data-impeccable-css="SESSION_ID">',
|
||||
};
|
||||
}
|
||||
return {
|
||||
mode: 'scoped',
|
||||
styleTag: '<style data-impeccable-css="SESSION_ID">',
|
||||
};
|
||||
}
|
||||
|
||||
function buildCssSelectorPrefixExamples(styleMode, count) {
|
||||
if (styleMode !== 'astro-global-prefixed') return [];
|
||||
return Array.from({ length: count }, (_, i) => `[data-impeccable-variant="${i + 1}"]`);
|
||||
}
|
||||
|
||||
function buildCssAuthoring(styleMode, count) {
|
||||
const variantNumbers = Array.from({ length: count }, (_, i) => i + 1);
|
||||
if (styleMode.mode === 'astro-global-prefixed') {
|
||||
return {
|
||||
mode: styleMode.mode,
|
||||
styleTag: styleMode.styleTag,
|
||||
strategy: 'global-prefixed',
|
||||
rulePattern: '[data-impeccable-variant="N"] > .variant-class { ... }',
|
||||
selectorExamples: variantNumbers.map((n) => `[data-impeccable-variant="${n}"] > .variant-class`),
|
||||
requirements: [
|
||||
'Use the styleTag exactly; the is:inline attribute is required for this file.',
|
||||
'Prefix every preview selector with the matching [data-impeccable-variant="N"] selector.',
|
||||
'Keep selectors anchored to the generated variant wrapper; do not rely on component CSS scoping for preview rules.',
|
||||
],
|
||||
forbidden: [
|
||||
'Do not use @scope for this styleMode.',
|
||||
],
|
||||
};
|
||||
}
|
||||
return {
|
||||
mode: styleMode.mode,
|
||||
styleTag: styleMode.styleTag,
|
||||
strategy: 'scope-rule',
|
||||
rulePattern: '@scope ([data-impeccable-variant="N"]) { :scope > .variant-class { ... } }',
|
||||
selectorExamples: variantNumbers.map((n) => `@scope ([data-impeccable-variant="${n}"]) { :scope > .variant-class { ... } }`),
|
||||
requirements: [
|
||||
'Use @scope blocks keyed to each [data-impeccable-variant="N"] wrapper.',
|
||||
'Inside each @scope block, make :scope rules step into the replacement element with a descendant combinator.',
|
||||
'Use the styleTag exactly; do not add framework-specific style attributes unless this object says to.',
|
||||
],
|
||||
forbidden: [
|
||||
'Do not use global [data-impeccable-variant="N"] selector prefixes for this styleMode.',
|
||||
'Do not add is:inline to the style tag for this styleMode.',
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Search project files for the query string (class name, ID, etc.)
|
||||
* Returns the first matching file path, or null.
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
* CLI entry point: prepare everything needed to enter the live variant poll loop.
|
||||
*
|
||||
* Does (all in one command):
|
||||
* 1. Check config.json (returns config_missing if first-ever run)
|
||||
* 1. Check .impeccable/live/config.json (returns config_missing if first-ever run)
|
||||
* 2. Start the live server in the background (or reuse a running one)
|
||||
* 3. Inject the browser script tag into the project's entry file
|
||||
* 4. Read .impeccable.md for design context (if present)
|
||||
* 4. Read PRODUCT.md / DESIGN.md for project context
|
||||
* 5. Print a single JSON blob with everything the agent needs
|
||||
*
|
||||
* After this, the agent's only remaining steps are:
|
||||
@@ -23,9 +23,9 @@ import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { loadContext } from './load-context.mjs';
|
||||
import { resolveFiles } from './live-inject.mjs';
|
||||
import { readLiveServerInfo } from './impeccable-paths.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PID_FILE = path.join(process.cwd(), '.impeccable-live.json');
|
||||
|
||||
async function liveCli() {
|
||||
const args = process.argv.slice(2);
|
||||
@@ -34,10 +34,10 @@ async function liveCli() {
|
||||
console.log(`Usage: node live.mjs
|
||||
|
||||
Prepare everything for live variant mode in a single command:
|
||||
- Checks scripts/config.json (required, created once per project)
|
||||
- Checks .impeccable/live/config.json (required, created once per project)
|
||||
- Starts (or reuses) the live server in the background
|
||||
- Injects the browser script tag
|
||||
- Reads .impeccable.md for design context
|
||||
- Reads PRODUCT.md / DESIGN.md for project context
|
||||
|
||||
On success, prints a JSON blob with:
|
||||
{ ok, serverPort, serverToken, pageFile, hasContext, context }
|
||||
@@ -223,7 +223,7 @@ function safeParse(out) {
|
||||
function ensureServerRunning() {
|
||||
// Try to reuse an existing server
|
||||
try {
|
||||
const existing = JSON.parse(fs.readFileSync(PID_FILE, 'utf-8'));
|
||||
const existing = readLiveServerInfo(process.cwd())?.info;
|
||||
if (existing && existing.pid) {
|
||||
try {
|
||||
process.kill(existing.pid, 0); // throws if dead
|
||||
|
||||
@@ -39,7 +39,7 @@ const LEGACY_NAMES = ['.impeccable.md'];
|
||||
const FALLBACK_DIRS = ['.agents/context', 'docs'];
|
||||
|
||||
/**
|
||||
* Resolve the directory that holds PRODUCT.md / DESIGN.md / DESIGN.json for
|
||||
* Resolve the directory that holds PRODUCT.md / DESIGN.md for
|
||||
* this project. Exported so other scripts (e.g. live-server.mjs) can read the
|
||||
* design files from the same location the loader uses.
|
||||
*/
|
||||
|
||||
@@ -237,11 +237,11 @@ Concrete, forceful guardrails. Lead each with "Do" or "Don't". Be specific: incl
|
||||
- **Don't** [...]
|
||||
```
|
||||
|
||||
### Step 4b: Write DESIGN.json sidecar (extensions only)
|
||||
### Step 4b: Write .impeccable/design.json sidecar (extensions only)
|
||||
|
||||
The frontmatter owns token primitives (colors, typography, rounded, spacing, components). The sidecar at `DESIGN.json` carries **what Stitch's schema can't hold**: tonal ramps per color, shadow/elevation tokens, motion tokens, breakpoints, full component HTML/CSS snippets (the panel renders these into a shadow DOM), and narrative (north star, rules, do's/don'ts). It extends the frontmatter, it doesn't duplicate it.
|
||||
The frontmatter owns token primitives (colors, typography, rounded, spacing, components). The sidecar at `.impeccable/design.json` carries **what Stitch's schema can't hold**: tonal ramps per color, shadow/elevation tokens, motion tokens, breakpoints, full component HTML/CSS snippets (the panel renders these into a shadow DOM), and narrative (north star, rules, do's/don'ts). It extends the frontmatter, it doesn't duplicate it.
|
||||
|
||||
Regenerate the sidecar whenever you regenerate DESIGN.md. If the user only asks to refresh the sidecar (e.g., from the live panel's stale-hint), preserve DESIGN.md and write only DESIGN.json.
|
||||
Regenerate the sidecar whenever you regenerate root `DESIGN.md`. If the user only asks to refresh the sidecar (e.g., from the live panel's stale-hint), preserve `DESIGN.md` and write only `.impeccable/design.json`.
|
||||
|
||||
#### Schema
|
||||
|
||||
@@ -310,7 +310,7 @@ Aim for a tight set of **5-10 components** that best represent the visual system
|
||||
- **Signature components (include if distinctive):** hero CTA, featured card, filter pill, any custom pattern the user mentioned as important in PRODUCT.md.
|
||||
- **Skip the rest.** Utility components, form building blocks, wrapper layouts: not worth documenting unless visually distinctive.
|
||||
|
||||
If the project has **no component library yet** (bare landing page, new project), synthesize canonical primitives from the tokens using best-practice defaults consistent with the DESIGN.md's rules. Every DESIGN.json has *something* to render, even on day zero.
|
||||
If the project has **no component library yet** (bare landing page, new project), synthesize canonical primitives from the tokens using best-practice defaults consistent with the DESIGN.md's rules. Every `.impeccable/design.json` has *something* to render, even on day zero.
|
||||
|
||||
#### Tonal ramps
|
||||
|
||||
@@ -331,7 +331,7 @@ Do not reword. The panel shows these as secondary collapsible context; the same
|
||||
### Step 5: Confirm, refine, and refresh session cache
|
||||
|
||||
1. Show the user the full DESIGN.md you wrote. Briefly highlight the non-obvious creative choices (descriptive color names, atmosphere language, named rules).
|
||||
2. Mention that `DESIGN.json` was also written alongside; the live panel will now render this project's actual button/input/nav primitives instead of generic approximations.
|
||||
2. Mention that `.impeccable/design.json` was also written alongside; the live panel will now render this project's actual button/input/nav primitives instead of generic approximations.
|
||||
3. Offer to refine any section: "Want me to revise a section, add component patterns I missed, or adjust the atmosphere language?"
|
||||
4. **Refresh the session cache.** Run `node .claude/skills/impeccable/scripts/load-context.mjs` one final time so the newly-written DESIGN.md lands in conversation. Subsequent commands in this session will use the fresh version automatically without re-reading.
|
||||
|
||||
@@ -392,7 +392,7 @@ Per-section guidance in seed mode:
|
||||
- **Components**: omit entirely; no components exist yet.
|
||||
- **Do's and Don'ts**: carry PRODUCT.md's anti-references directly plus the anti-reference named in Q5.
|
||||
|
||||
Seed mode writes a minimal frontmatter with `name` and `description` only; no colors, typography, rounded, spacing, or components yet. Real tokens land on the next Scan-mode run. Skip the `DESIGN.json` sidecar in seed mode for the same reason: nothing to render.
|
||||
Seed mode writes a minimal frontmatter with `name` and `description` only; no colors, typography, rounded, spacing, or components yet. Real tokens land on the next Scan-mode run. Skip the `.impeccable/design.json` sidecar in seed mode for the same reason: nothing to render.
|
||||
|
||||
### Step 4: Confirm and refresh session cache
|
||||
|
||||
|
||||
@@ -12,8 +12,9 @@ Execute in order. No step skipped, no step reordered.
|
||||
2. Navigate to the URL that serves `pageFile` (infer from `package.json`, docs, terminal output, or an open tab). If you can't infer it confidently, tell the user once to open their dev/preview URL. Never use `serverPort` as that URL; it's the helper, not the app.
|
||||
3. Poll loop with the default long timeout (600000 ms). After every event or `--reply`, run `live-poll.mjs` again immediately. Never pass a short `--timeout=`.
|
||||
4. On `generate`: read screenshot if present; load the action's reference; plan three distinct directions; write all variants in one edit; `--reply done`; poll again.
|
||||
5. On `accept` / `discard`: the poll script already cleaned up; just poll again.
|
||||
6. On `exit`: run the cleanup at the bottom.
|
||||
5. On `accept` / `discard`: the poll script runs `live-accept.mjs`, acknowledges the delivered event, and prints `_completionAck`. Plain accepts/discards are terminal immediately; carbonize accepts remain recoverable until you finish cleanup, run `live-complete.mjs --id EVENT_ID`, and only then poll again.
|
||||
6. If interrupted, run `live-status.mjs` or `live-resume.mjs` before guessing. The durable journal replays unacknowledged work after helper restart.
|
||||
7. On `exit`: run the cleanup at the bottom.
|
||||
|
||||
Harness policy:
|
||||
- **Claude Code**: run the poll as a **background task** (no short timeout). The harness notifies you when it completes, so the main conversation stays free. Do not block the shell.
|
||||
@@ -43,13 +44,31 @@ LOOP:
|
||||
Read JSON; dispatch on "type"
|
||||
|
||||
"generate" → Handle Generate; reply done; LOOP
|
||||
"accept" → Handle Accept; LOOP
|
||||
"accept" → Handle Accept; complete carbonize cleanup if required; LOOP
|
||||
"discard" → Handle Discard; LOOP
|
||||
"prefetch" → Handle Prefetch; LOOP
|
||||
"timeout" → LOOP
|
||||
"exit" → break → Cleanup
|
||||
```
|
||||
|
||||
## Recovery commands
|
||||
|
||||
The live helper persists an append-only journal under `.impeccable/live/sessions/`. Browser checkpoints are advisory but durable; the journal is canonical. This is local durable recovery state, not project source.
|
||||
|
||||
Use these commands when the chat was interrupted, polling was missed, the helper restarted, or the browser reloaded:
|
||||
|
||||
```bash
|
||||
node .claude/skills/impeccable/scripts/live-status.mjs
|
||||
node .claude/skills/impeccable/scripts/live-resume.mjs --id SESSION_ID
|
||||
node .claude/skills/impeccable/scripts/live-complete.mjs --id SESSION_ID
|
||||
```
|
||||
|
||||
- `live-status.mjs` prints connected helper state, active durable sessions, and queued pending events. It works even when the helper is down by reading the journal directly.
|
||||
- `live-resume.mjs` prints the active snapshot, pending event, checkpoint phase, visible variant, parameter values, and the next safe agent action.
|
||||
- `live-complete.mjs` is the canonical manual final acknowledgement. Use it after carbonize/manual cleanup is verified and no further poll acknowledgement will happen automatically.
|
||||
|
||||
Server restart rule: start `live-server.mjs` again, then poll. Startup requeues unacknowledged pending events from the journal, so do not ask the user to click Go again unless `live-resume.mjs` says no active session exists.
|
||||
|
||||
## Handle `generate`
|
||||
|
||||
Event: `{id, action, freeformPrompt?, count, pageUrl, element, screenshotPath?, comments?, strokes?}`.
|
||||
@@ -88,7 +107,14 @@ The helper searches ID first, then classes, then tag + class combo. If `event.pa
|
||||
|
||||
If `--text` matches multiple candidates equally well, wrap exits with `{ error: "element_ambiguous", candidates: [...] }` and `fallback: "agent-driven"`: read the candidate line ranges, decide which one matches the picked element from page context, and write the wrapper manually per the fallback flow.
|
||||
|
||||
Output on success: `{ file, insertLine, commentSyntax }`.
|
||||
Output on success: `{ file, insertLine, commentSyntax, styleMode, styleTag, cssSelectorPrefixExamples, cssAuthoring }`.
|
||||
|
||||
`styleMode` controls how preview CSS must be authored. Treat it as a detected capability mode, not a framework guess:
|
||||
|
||||
- `scoped`: use `@scope ([data-impeccable-variant="N"])` rules.
|
||||
- `astro-global-prefixed`: use explicit `[data-impeccable-variant="N"]` selector prefixes and the exact `styleTag` returned by the tool.
|
||||
|
||||
Use `cssAuthoring` as the source of truth for the current file. It includes the exact `styleTag`, selector strategy, selector examples, requirements, and forbidden patterns. Do not apply a framework-specific exception unless the returned `styleMode` / `cssAuthoring.mode` says to.
|
||||
|
||||
**Fallback errors.** Wrap only writes into files it judges to be source (tracked by git, not marked GENERATED, not listed in config's `generatedFiles`). If it can't land on a source file, it errors without writing; accepting a variant into a generated file is silent data loss. Three shapes:
|
||||
|
||||
@@ -208,13 +234,14 @@ When the prompt and PRODUCT.md anti-references conflict (the prompt asks for X,
|
||||
|
||||
Complete HTML replacement of the original element for each variant, not a CSS-only patch. Consider the element's context (computed styles, parent structure, CSS variables from `event.element`).
|
||||
|
||||
Write CSS + all variants in ONE edit at the `insertLine` reported by `wrap`. Colocate scoped CSS as a `<style>` tag inside the variant wrapper; `<style>` works anywhere in modern browsers and this ensures CSS and HTML arrive atomically (no FOUC).
|
||||
Write CSS + all variants in ONE edit at the `insertLine` reported by `wrap`. Colocate CSS as a `<style>` tag inside the variant wrapper; `<style>` works anywhere in modern browsers and this ensures CSS and HTML arrive atomically (no FOUC).
|
||||
|
||||
Use the `cssAuthoring` object returned by `live-wrap.mjs` to author the temporary preview CSS. The style opening tag shown below is the common case; replace it with `cssAuthoring.styleTag` when the tool returns a different one. The variant markup shape is otherwise stable:
|
||||
|
||||
```html
|
||||
<!-- Variants: insert below this line -->
|
||||
<style data-impeccable-css="SESSION_ID">
|
||||
@scope ([data-impeccable-variant="1"]) { ... }
|
||||
@scope ([data-impeccable-variant="2"]) { ... }
|
||||
/* rules matching cssAuthoring.rulePattern */
|
||||
</style>
|
||||
<div data-impeccable-variant="1">
|
||||
<!-- variant 1: full element replacement (single top-level element) -->
|
||||
@@ -229,11 +256,11 @@ Write CSS + all variants in ONE edit at the `insertLine` reported by `wrap`. Col
|
||||
|
||||
**Each variant div contains exactly one top-level element: the full replacement for the original.** Use the same tag as the original (e.g. `<section>` if the user picked a `<section>`). 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 `<style>` tag entirely. Use `@scope` for CSS isolation (Chrome 118+ / Firefox 128+ / Safari 17.4+).
|
||||
The first variant has no `display: none` (visible by default). All others do. If variants use only inline styles and no preview CSS, omit the `<style>` tag entirely.
|
||||
|
||||
One edit, all variants; the browser's MutationObserver picks everything up in one pass.
|
||||
|
||||
**Author every `:scope` rule with a descendant combinator.** The `@scope` boundary is the **variant wrapper `<div data-impeccable-variant="N">`**, not the element you're designing. A bare `:scope { background: cream; }` styles the wrapper, not the inner replacement, so the cream lands on a `display: contents` shell while the actual element keeps page defaults. Always step in: `:scope > .card`, `:scope > section`, `:scope .hero-title`, etc. The fake test agent's CSS in `tests/live-e2e/agent.mjs` is a faithful template; every rule starts `:scope > ...`.
|
||||
For `styleMode: "scoped"`, author every `:scope` rule with a descendant combinator. The `@scope` boundary is the **variant wrapper `<div data-impeccable-variant="N">`**, not the element you're designing. A bare `:scope { background: cream; }` styles the wrapper, not the inner replacement, so the cream lands on a `display: contents` shell while the actual element keeps page defaults. Always step in: `:scope > .card`, `:scope > section`, `:scope .hero-title`, etc. The fake test agent's CSS in `tests/live-e2e/agent.mjs` is a faithful template; every scoped rule starts `:scope > ...`.
|
||||
|
||||
**JSX / TSX target files.** Wrap `<style>` content in a template literal so the CSS `{` / `}` aren't parsed as JSX expressions, and use `className=` / `style={{…}}` on every variant element. Keep `data-impeccable-*` attributes as-is; they're plain strings:
|
||||
|
||||
@@ -369,10 +396,11 @@ Remove the wrapper you inserted in Step 2. Nothing else to do.
|
||||
|
||||
## Handle `accept`
|
||||
|
||||
Event: `{id, variantId, _acceptResult}`. The poll script already ran `live-accept.mjs` to handle the file operation deterministically; the browser DOM is already updated.
|
||||
Event: `{id, variantId, _acceptResult, _completionAck}`. The poll script already ran `live-accept.mjs` to handle the file operation deterministically, then acknowledged event delivery to the helper. The browser DOM is already updated.
|
||||
|
||||
- `_completionAck.ok !== true`: do not poll yet. Run `live-status.mjs` / `live-resume.mjs`, complete the cleanup manually if needed, then run `live-complete.mjs --id EVENT_ID`.
|
||||
- `_acceptResult.handled: true` and `carbonize: false`: nothing to do. Poll again.
|
||||
- `_acceptResult.handled: true` and `carbonize: true`: **post-accept cleanup is required before the next poll.** See the "Required after accept (carbonize)" section below. The `event._acceptResult.todo` field and a stderr banner both list the steps explicitly; neither is decorative.
|
||||
- `_acceptResult.handled: true` and `carbonize: true`: **post-accept cleanup is required before the next poll.** See the "Required after accept (carbonize)" section below. The `event._acceptResult.todo` field, `_completionAck.requiresComplete`, and a stderr banner all point at this required follow-up; none are decorative. After cleanup, run `live-complete.mjs --id EVENT_ID`, then poll again.
|
||||
- `_acceptResult.handled: false, mode: "fallback"`: the session lived in a generated file and the script refused to persist there. You've already written the accepted variant into true source during Handle fallback Step 3; just clean up the temporary wrapper in the served file if any, and poll again.
|
||||
- `_acceptResult.handled: false` without `mode`: manual cleanup: read file, find markers, edit.
|
||||
|
||||
@@ -388,13 +416,13 @@ Do these five steps in the current thread, synchronously, before the next poll.
|
||||
4. **Unwrap the accepted content.** Delete the `<div data-impeccable-variant="N" style="display: contents">` that wraps it. Drop `data-impeccable-params` and any `data-p-*` attributes from it; those are live-mode plumbing, not source.
|
||||
5. **Delete the inline `<style>` block, the `<!-- impeccable-param-values -->` comment if present, and both `<!-- impeccable-carbonize-start/end -->` markers.** Also drop any `@scope` rules for variants other than the accepted one; those are dead code now.
|
||||
|
||||
Then poll again.
|
||||
After the file is clean, run `live-complete.mjs --id SESSION_ID`, verify it reports `phase: "completed"`, then poll again.
|
||||
|
||||
A background agent may be used for the rewrite, but the current thread is responsible for verifying the five steps are complete before issuing the next poll. In practice, inline is usually faster and less error-prone.
|
||||
|
||||
## Handle `discard`
|
||||
|
||||
Event: `{id, _acceptResult}`. The poll script already restored the original and removed all variant markers. Nothing to do. Poll again.
|
||||
Event: `{id, _acceptResult, _completionAck}`. The poll script already restored the original, removed all variant markers, and acknowledged `discarded` durable completion. Nothing to do unless `_completionAck.ok !== true`; in that case run `live-complete.mjs --id EVENT_ID --discarded`, then poll again.
|
||||
|
||||
## Handle `prefetch`
|
||||
|
||||
@@ -424,7 +452,7 @@ When the poll returns `exit`, proceed to cleanup. If the poll is still running a
|
||||
node .claude/skills/impeccable/scripts/live-server.mjs stop
|
||||
```
|
||||
|
||||
Stops the HTTP server and runs `live-inject.mjs --remove` to strip `localhost:…/live.js` from the HTML entry. To stop the server but keep the inject tag (for a quick restart), use `stop --keep-inject`. `config.json` persists for future sessions.
|
||||
Stops the HTTP server and runs `live-inject.mjs --remove` to strip `localhost:…/live.js` from the HTML entry. To stop the server but keep the inject tag (for a quick restart), use `stop --keep-inject`. `.impeccable/live/config.json` persists as project config for future sessions.
|
||||
|
||||
Then:
|
||||
- Remove any leftover variant wrappers (search for `impeccable-variants-start` markers).
|
||||
@@ -432,7 +460,7 @@ Then:
|
||||
|
||||
## First-time setup (config missing or invalid)
|
||||
|
||||
If `live.mjs` outputs `{ ok: false, error: "config_missing" | "config_invalid", path }`, write `config.json` at the reported path.
|
||||
If `live.mjs` outputs `{ ok: false, error: "config_missing" | "config_invalid", path }`, write the live config at the reported path. By default this is `.impeccable/live/config.json`.
|
||||
|
||||
Schema:
|
||||
|
||||
@@ -512,7 +540,7 @@ node .claude/skills/impeccable/scripts/detect-csp.mjs
|
||||
|
||||
Output: `{ shape, signals }` where `shape` is one of `append-arrays`, `append-string`, `middleware`, `meta-tag`, or `null`. The shape is named by *patch mechanism*, so one template covers many frameworks.
|
||||
|
||||
- **`null`**: no CSP; skip to writing `config.json` with `cspChecked: true`.
|
||||
- **`null`**: no CSP; skip to writing `.impeccable/live/config.json` with `cspChecked: true`.
|
||||
- **`append-arrays`**: CSP defined as structured directive arrays. Auto-patchable. See *append-arrays* below. Covers:
|
||||
- Monorepo helpers with `additionalScriptSrc` / `additionalConnectSrc` options (Next.js + shared config package)
|
||||
- SvelteKit `kit.csp.directives`
|
||||
@@ -589,6 +617,6 @@ Reference outputs:
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
If a user says "no" to the CSP patch at setup time and later complains that live doesn't work: their dev CSP blocks `http://localhost:8400`. Fix: delete `cspChecked` from `config.json` and re-run `live.mjs`: setup will ask again.
|
||||
If a user says "no" to the CSP patch at setup time and later complains that live doesn't work: their dev CSP blocks `http://localhost:8400`. Fix: delete `cspChecked` from `.impeccable/live/config.json` and re-run `live.mjs`: setup will ask again.
|
||||
|
||||
Then re-run `live.mjs`.
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
Gathers design context for a project and writes two complementary files at the project root:
|
||||
|
||||
- **PRODUCT.md** (strategic): register, target users, product purpose, brand personality, anti-references, strategic design principles. Answers "who/what/why".
|
||||
- **DESIGN.md** (visual): visual theme, color palette, typography, components, layout. Follows the [Google Stitch DESIGN.md format](https://stitch.withgoogle.com/docs/design-md/format/). Answers "how it looks".
|
||||
- **PRODUCT.md** (strategic): root project file for register, target users, product purpose, brand personality, anti-references, strategic design principles. Answers "who/what/why".
|
||||
- **DESIGN.md** (visual): root project file for visual theme, color palette, typography, components, layout. Follows the [Google Stitch DESIGN.md format](https://stitch.withgoogle.com/docs/design-md/format/). Answers "how it looks".
|
||||
|
||||
Every other impeccable command reads these files before doing any work.
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
export const IMPECCABLE_DIR = '.impeccable';
|
||||
export const LIVE_DIR = 'live';
|
||||
|
||||
export function getImpeccableDir(cwd = process.cwd()) {
|
||||
return path.join(cwd, IMPECCABLE_DIR);
|
||||
}
|
||||
|
||||
export function getDesignSidecarPath(cwd = process.cwd()) {
|
||||
return path.join(getImpeccableDir(cwd), 'design.json');
|
||||
}
|
||||
|
||||
export function getDesignSidecarCandidates(cwd = process.cwd(), contextDir = cwd) {
|
||||
const candidates = [
|
||||
getDesignSidecarPath(cwd),
|
||||
path.join(cwd, 'DESIGN.json'),
|
||||
];
|
||||
const contextLegacy = path.join(contextDir, 'DESIGN.json');
|
||||
if (!candidates.includes(contextLegacy)) candidates.push(contextLegacy);
|
||||
return candidates;
|
||||
}
|
||||
|
||||
export function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd) {
|
||||
return firstExisting(getDesignSidecarCandidates(cwd, contextDir));
|
||||
}
|
||||
|
||||
export function getLiveDir(cwd = process.cwd()) {
|
||||
return path.join(getImpeccableDir(cwd), LIVE_DIR);
|
||||
}
|
||||
|
||||
export function getLiveConfigPath(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'config.json');
|
||||
}
|
||||
|
||||
export function getLegacyLiveConfigPath(scriptsDir) {
|
||||
return path.join(scriptsDir, 'config.json');
|
||||
}
|
||||
|
||||
export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = process.env } = {}) {
|
||||
if (env.IMPECCABLE_LIVE_CONFIG && env.IMPECCABLE_LIVE_CONFIG.trim()) {
|
||||
const configured = env.IMPECCABLE_LIVE_CONFIG.trim();
|
||||
return path.isAbsolute(configured) ? configured : path.resolve(cwd, configured);
|
||||
}
|
||||
const primary = getLiveConfigPath(cwd);
|
||||
if (fs.existsSync(primary)) return primary;
|
||||
if (scriptsDir) {
|
||||
const legacy = getLegacyLiveConfigPath(scriptsDir);
|
||||
if (fs.existsSync(legacy)) return legacy;
|
||||
}
|
||||
return primary;
|
||||
}
|
||||
|
||||
export function getLiveServerPath(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'server.json');
|
||||
}
|
||||
|
||||
export function getLegacyLiveServerPath(cwd = process.cwd()) {
|
||||
return path.join(cwd, '.impeccable-live.json');
|
||||
}
|
||||
|
||||
export function readLiveServerInfo(cwd = process.cwd()) {
|
||||
for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) {
|
||||
try {
|
||||
return { info: JSON.parse(fs.readFileSync(filePath, 'utf-8')), path: filePath };
|
||||
} catch {
|
||||
/* try next */
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function writeLiveServerInfo(cwd = process.cwd(), info) {
|
||||
const filePath = getLiveServerPath(cwd);
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, JSON.stringify(info));
|
||||
return filePath;
|
||||
}
|
||||
|
||||
export function removeLiveServerInfo(cwd = process.cwd()) {
|
||||
for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) {
|
||||
try { fs.unlinkSync(filePath); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
export function getLiveSessionsDir(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'sessions');
|
||||
}
|
||||
|
||||
export function getLegacyLiveSessionsDir(cwd = process.cwd()) {
|
||||
return path.join(cwd, '.impeccable-live', 'sessions');
|
||||
}
|
||||
|
||||
export function getLiveAnnotationsDir(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'annotations');
|
||||
}
|
||||
|
||||
export function getLegacyLiveAnnotationsDir(cwd = process.cwd()) {
|
||||
return path.join(cwd, '.impeccable-live', 'annotations');
|
||||
}
|
||||
|
||||
function firstExisting(paths) {
|
||||
return paths.find((filePath) => fs.existsSync(filePath)) || null;
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Browser-side durable session helpers for Impeccable live mode.
|
||||
*
|
||||
* Kept separate from live-browser.js so recovery state can be tested without
|
||||
* booting the full overlay UI. Served before live-browser.js and attached to
|
||||
* window.__IMPECCABLE_LIVE_SESSION__.
|
||||
*/
|
||||
(function (root) {
|
||||
'use strict';
|
||||
|
||||
function createLiveBrowserSessionState({ prefix, storage, idFactory }) {
|
||||
if (!prefix) throw new Error('prefix required');
|
||||
const store = storage || root.localStorage;
|
||||
const makeId = idFactory || function () { return Math.random().toString(16).slice(2, 10); };
|
||||
const sessionKey = prefix + '-session';
|
||||
const handledKey = sessionKey + '-handled';
|
||||
const scrollKey = sessionKey + '-scroll';
|
||||
let checkpointRevision = 0;
|
||||
const owner = makeId();
|
||||
|
||||
function safeRead(key) {
|
||||
try { return store.getItem(key); } catch { return null; }
|
||||
}
|
||||
|
||||
function safeWrite(key, value) {
|
||||
try { store.setItem(key, value); } catch { /* quota exceeded or private mode */ }
|
||||
}
|
||||
|
||||
function safeRemove(key) {
|
||||
try { store.removeItem(key); } catch { /* unavailable storage */ }
|
||||
}
|
||||
|
||||
function loadSession() {
|
||||
try {
|
||||
const raw = safeRead(sessionKey);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Number.isInteger(parsed.checkpointRevision)) {
|
||||
checkpointRevision = Math.max(checkpointRevision, parsed.checkpointRevision);
|
||||
}
|
||||
return parsed;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
function saveSession(session) {
|
||||
if (!session || !session.id) return;
|
||||
const payload = {
|
||||
...session,
|
||||
checkpointRevision,
|
||||
};
|
||||
safeWrite(sessionKey, JSON.stringify(payload));
|
||||
}
|
||||
|
||||
function clearSession() {
|
||||
safeRemove(sessionKey);
|
||||
}
|
||||
|
||||
function nextCheckpointRevision() {
|
||||
checkpointRevision += 1;
|
||||
const existing = loadSession();
|
||||
if (existing?.id) saveSession(existing);
|
||||
return checkpointRevision;
|
||||
}
|
||||
|
||||
function seedCheckpointRevision(value) {
|
||||
if (Number.isInteger(value)) checkpointRevision = Math.max(checkpointRevision, value);
|
||||
return checkpointRevision;
|
||||
}
|
||||
|
||||
function currentCheckpointRevision() {
|
||||
return checkpointRevision;
|
||||
}
|
||||
|
||||
function markHandled(id) {
|
||||
if (!id) return;
|
||||
safeWrite(handledKey, id);
|
||||
}
|
||||
|
||||
function isHandled(id) {
|
||||
return !!id && safeRead(handledKey) === id;
|
||||
}
|
||||
|
||||
function clearHandled() {
|
||||
safeRemove(handledKey);
|
||||
}
|
||||
|
||||
function writeScrollY(y) {
|
||||
safeWrite(scrollKey, String(y));
|
||||
}
|
||||
|
||||
function readScrollY() {
|
||||
const raw = safeRead(scrollKey);
|
||||
if (raw == null) return null;
|
||||
const n = parseFloat(raw);
|
||||
return isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
function clearScrollY() {
|
||||
safeRemove(scrollKey);
|
||||
}
|
||||
|
||||
return {
|
||||
owner,
|
||||
sessionKey,
|
||||
handledKey,
|
||||
scrollKey,
|
||||
saveSession,
|
||||
loadSession,
|
||||
clearSession,
|
||||
nextCheckpointRevision,
|
||||
seedCheckpointRevision,
|
||||
currentCheckpointRevision,
|
||||
markHandled,
|
||||
isHandled,
|
||||
clearHandled,
|
||||
writeScrollY,
|
||||
readScrollY,
|
||||
clearScrollY,
|
||||
};
|
||||
}
|
||||
|
||||
root.__IMPECCABLE_LIVE_SESSION__ = { createLiveBrowserSessionState };
|
||||
})(typeof window !== 'undefined' ? window : globalThis);
|
||||
@@ -50,6 +50,16 @@
|
||||
const Z = { highlight: 100001, bar: 100005, picker: 100007, toast: 100010 };
|
||||
const EASE = 'cubic-bezier(0.22, 1, 0.36, 1)'; // ease-out-quint
|
||||
const PREFIX = 'impeccable-live';
|
||||
const sessionState = window.__IMPECCABLE_LIVE_SESSION__?.createLiveBrowserSessionState({
|
||||
prefix: PREFIX,
|
||||
storage: localStorage,
|
||||
idFactory: () => crypto.randomUUID().replace(/-/g, '').slice(0, 8),
|
||||
});
|
||||
if (!sessionState) {
|
||||
console.error('[impeccable] live-browser-session.js was not loaded. Live mode cannot start safely.');
|
||||
window.__IMPECCABLE_LIVE_INIT__ = false;
|
||||
return;
|
||||
}
|
||||
const HIGHLIGHT_TRANSITION =
|
||||
'top 140ms ' + EASE +
|
||||
', left 140ms ' + EASE +
|
||||
@@ -112,6 +122,8 @@
|
||||
let hasProjectContext = false;
|
||||
let selectedAction = 'impeccable';
|
||||
let selectedCount = 3;
|
||||
const browserOwner = sessionState.owner;
|
||||
let checkpointTimer = null;
|
||||
|
||||
// Scroll lock — holds window.scrollY at a fixed value while the session is
|
||||
// active, so HMR DOM patches and variant swaps can't drift the page. See
|
||||
@@ -126,21 +138,9 @@
|
||||
// (Previously: saveSession wrote scrollY alongside state, so every call
|
||||
// during resume overwrote the pre-reload value with whatever the browser
|
||||
// had landed on, typically 0.)
|
||||
const SCROLL_KEY_SUFFIX = '-scroll';
|
||||
function writeScrollY(y) {
|
||||
try { localStorage.setItem(LS_KEY + SCROLL_KEY_SUFFIX, String(y)); } catch {}
|
||||
}
|
||||
function readScrollY() {
|
||||
try {
|
||||
const raw = localStorage.getItem(LS_KEY + SCROLL_KEY_SUFFIX);
|
||||
if (raw == null) return null;
|
||||
const n = parseFloat(raw);
|
||||
return isFinite(n) ? n : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
function clearScrollY() {
|
||||
try { localStorage.removeItem(LS_KEY + SCROLL_KEY_SUFFIX); } catch {}
|
||||
}
|
||||
function writeScrollY(y) { sessionState.writeScrollY(y); }
|
||||
function readScrollY() { return sessionState.readScrollY(); }
|
||||
function clearScrollY() { sessionState.clearScrollY(); }
|
||||
|
||||
// Pre-empt the browser: apply manual scroll restoration and jump to the
|
||||
// saved scrollY at script-parse time. Retries on fonts.ready and load
|
||||
@@ -1585,6 +1585,7 @@
|
||||
paramsCurrentValues[p.id] = v;
|
||||
readout.textContent = formatRangeValue(input);
|
||||
applyParamValue(variantEl, p, v);
|
||||
queueCheckpoint('param_changed');
|
||||
});
|
||||
row.appendChild(input);
|
||||
} else if (p.kind === 'toggle') {
|
||||
@@ -1615,6 +1616,7 @@
|
||||
knob.style.left = next ? '18px' : '2px';
|
||||
readout.textContent = next ? 'On' : 'Off';
|
||||
applyParamValue(variantEl, p, next);
|
||||
queueCheckpoint('param_changed');
|
||||
});
|
||||
row.appendChild(track);
|
||||
} else if (p.kind === 'steps') {
|
||||
@@ -1651,6 +1653,7 @@
|
||||
btn.style.color = on ? 'oklch(98% 0 0)' : P.text;
|
||||
});
|
||||
applyParamValue(variantEl, p, o.value);
|
||||
queueCheckpoint('param_changed');
|
||||
});
|
||||
segRow.appendChild(b);
|
||||
segBtns.push({ btn: b, val: o.value });
|
||||
@@ -1872,19 +1875,26 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const previousVisibleVariant = currentSessionId === sessionId ? visibleVariant : 0;
|
||||
|
||||
// Replace the live element with the full wrapper from source
|
||||
const wrapper = srcWrapper.cloneNode(true);
|
||||
liveEl.parentElement.replaceChild(wrapper, liveEl);
|
||||
|
||||
// Update state: count variants, show the first one
|
||||
// Update state: count variants, preserving the user's current variant
|
||||
// when a late HMR/source reinjection lands after they have cycled.
|
||||
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
|
||||
arrivedVariants = variants.length;
|
||||
expectedVariants = parseInt(wrapper.dataset.impeccableVariantCount || arrivedVariants);
|
||||
visibleVariant = 1;
|
||||
showVariantInDOM(sessionId, 1);
|
||||
const saved = loadSession();
|
||||
const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0;
|
||||
visibleVariant = previousVisibleVariant > 0 && previousVisibleVariant <= arrivedVariants
|
||||
? previousVisibleVariant
|
||||
: (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1);
|
||||
showVariantInDOM(sessionId, visibleVariant);
|
||||
|
||||
// Update selectedElement to the visible variant's content
|
||||
selectedElement = pickVariantContent(wrapper, 1) || wrapper.parentElement;
|
||||
selectedElement = pickVariantContent(wrapper, visibleVariant) || wrapper.parentElement;
|
||||
|
||||
state = 'CYCLING';
|
||||
hideShaderOverlay();
|
||||
@@ -1907,6 +1917,7 @@
|
||||
updateSelectedElement();
|
||||
updateBarContent('cycling');
|
||||
saveSession();
|
||||
queueCheckpoint('variant_changed');
|
||||
}
|
||||
|
||||
function updateSelectedElement() {
|
||||
@@ -1917,6 +1928,18 @@
|
||||
if (visEl) selectedElement = visEl;
|
||||
}
|
||||
|
||||
function readVisibleVariantFromDOM(sessionId) {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return 0;
|
||||
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
|
||||
for (const variant of variants) {
|
||||
if (variant.style.display === 'none') continue;
|
||||
const idx = parseInt(variant.dataset.impeccableVariant || '0', 10);
|
||||
if (idx > 0) return idx;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Resolve the element that represents the variant's visible content.
|
||||
// Contract: each variant div should contain exactly one top-level element
|
||||
// (the full replacement). In practice a model may ship loose siblings or
|
||||
@@ -2107,8 +2130,10 @@
|
||||
updating = true;
|
||||
arrivedVariants = count;
|
||||
if (visibleVariant === 0 && arrivedVariants > 0) {
|
||||
visibleVariant = 1;
|
||||
showVariantInDOM(sessionId, 1);
|
||||
const saved = loadSession();
|
||||
const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0;
|
||||
visibleVariant = savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1;
|
||||
showVariantInDOM(sessionId, visibleVariant);
|
||||
// showVariantInDOM hid the original (display:none); if we were still
|
||||
// anchored to the original's content, its boundingRect is now zero
|
||||
// and the bar snaps to (0,0). Re-point at the visible variant instead.
|
||||
@@ -2128,6 +2153,7 @@
|
||||
updateBarContent('generating');
|
||||
}
|
||||
saveSession();
|
||||
queueCheckpoint(state === 'CYCLING' ? 'variants_ready' : 'variants_progress');
|
||||
updating = false;
|
||||
});
|
||||
|
||||
@@ -2236,6 +2262,7 @@
|
||||
|
||||
/** Server died or became unreachable. Reset UI to a clean state. */
|
||||
function handleServerLost() {
|
||||
const recoveryState = currentSessionId ? state : 'IDLE';
|
||||
if (state === 'GENERATING' || state === 'CYCLING' || state === 'SAVING') {
|
||||
showToast('Live server disconnected. Session ended.', 5000);
|
||||
}
|
||||
@@ -2246,21 +2273,61 @@
|
||||
stopScrollTracking();
|
||||
if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
|
||||
stopScrollLock();
|
||||
clearScrollY();
|
||||
clearSession();
|
||||
// Preserve local session state on server loss. The durable journal is the
|
||||
// source of truth, but localStorage plus the variant wrapper lets the UI
|
||||
// resume after a helper restart or page reload instead of treating a
|
||||
// transient disconnect as an explicit discard.
|
||||
selectedElement = null;
|
||||
currentSessionId = null;
|
||||
selectedAction = 'impeccable';
|
||||
state = 'IDLE';
|
||||
state = recoveryState;
|
||||
if (currentSessionId) saveSession();
|
||||
}
|
||||
|
||||
function sendEvent(msg) {
|
||||
function sendEvent(msg, opts) {
|
||||
msg.token = TOKEN;
|
||||
fetch('http://localhost:' + PORT + '/events', {
|
||||
function handleFailure(err) {
|
||||
console.error('[impeccable] Failed to send event:', err);
|
||||
if (opts && opts.throwOnError) throw err;
|
||||
return null;
|
||||
}
|
||||
return fetch('http://localhost:' + PORT + '/events', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(msg),
|
||||
}).catch(err => console.error('[impeccable] Failed to send event:', err));
|
||||
}).then(res => {
|
||||
if (res.ok) return res;
|
||||
return handleFailure(new Error('HTTP ' + res.status + ' ' + res.statusText));
|
||||
}).catch(handleFailure);
|
||||
}
|
||||
|
||||
function checkpointPayload(reason) {
|
||||
return {
|
||||
type: 'checkpoint',
|
||||
id: currentSessionId,
|
||||
revision: sessionState.nextCheckpointRevision(),
|
||||
owner: browserOwner,
|
||||
phase: String(state || '').toLowerCase(),
|
||||
reason,
|
||||
pageUrl: location.pathname,
|
||||
expectedVariants,
|
||||
arrivedVariants,
|
||||
visibleVariant,
|
||||
paramValues: { ...paramsCurrentValues },
|
||||
};
|
||||
}
|
||||
|
||||
function sendCheckpoint(reason) {
|
||||
if (!currentSessionId) return Promise.resolve(null);
|
||||
return sendEvent(checkpointPayload(reason)).catch(() => null);
|
||||
}
|
||||
|
||||
function queueCheckpoint(reason) {
|
||||
if (!currentSessionId) return;
|
||||
if (checkpointTimer) clearTimeout(checkpointTimer);
|
||||
checkpointTimer = setTimeout(() => {
|
||||
checkpointTimer = null;
|
||||
sendCheckpoint(reason);
|
||||
}, 120);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -2496,6 +2563,7 @@
|
||||
state = 'GENERATING';
|
||||
showBar('generating');
|
||||
saveSession();
|
||||
sendCheckpoint('generate_started');
|
||||
writeScrollY(window.scrollY);
|
||||
if (variantObserver) variantObserver.disconnect();
|
||||
variantObserver = startVariantObserver(currentSessionId);
|
||||
@@ -2920,13 +2988,12 @@ void main() {
|
||||
|
||||
function handleAccept() {
|
||||
if (!currentSessionId || arrivedVariants === 0) return;
|
||||
const domVisibleVariant = readVisibleVariantFromDOM(currentSessionId);
|
||||
if (domVisibleVariant > 0) visibleVariant = domVisibleVariant;
|
||||
const acceptPayload = { type: 'accept', id: currentSessionId, variantId: String(visibleVariant) };
|
||||
if (Object.keys(paramsCurrentValues).length > 0) {
|
||||
acceptPayload.paramValues = { ...paramsCurrentValues };
|
||||
}
|
||||
sendEvent(acceptPayload);
|
||||
markSessionHandled();
|
||||
|
||||
// The accepted variant is already the only visible child of the wrapper
|
||||
// (all other variants are display:none). HMR from the source rewrite will
|
||||
// replace the wrapper imminently. Don't eagerly replaceChild here — React
|
||||
@@ -2936,9 +3003,28 @@ void main() {
|
||||
const acceptedSessionId = currentSessionId;
|
||||
const acceptedVariant = visibleVariant;
|
||||
|
||||
state = 'CONFIRMED';
|
||||
updateBarContent('confirmed');
|
||||
setTimeout(function() {
|
||||
state = 'SAVING';
|
||||
updateBarContent('saving');
|
||||
|
||||
sendEvent(acceptPayload, { throwOnError: true })
|
||||
.then(() => {
|
||||
markSessionHandled();
|
||||
confirmAcceptAfterReceipt();
|
||||
})
|
||||
.catch(() => {
|
||||
state = 'CYCLING';
|
||||
updateBarContent('cycling');
|
||||
showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000);
|
||||
});
|
||||
|
||||
function confirmAcceptAfterReceipt() {
|
||||
state = 'CONFIRMED';
|
||||
updateBarContent('confirmed');
|
||||
scheduleAcceptCleanup();
|
||||
}
|
||||
|
||||
function scheduleAcceptCleanup() {
|
||||
setTimeout(function() {
|
||||
hideBar();
|
||||
hideHighlight();
|
||||
stopScrollTracking();
|
||||
@@ -2967,50 +3053,46 @@ void main() {
|
||||
accepted.style.display = 'contents';
|
||||
parent.replaceChild(accepted, wrapper);
|
||||
}
|
||||
}, 2000);
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDiscard() {
|
||||
if (!currentSessionId) return;
|
||||
sendEvent({ type: 'discard', id: currentSessionId });
|
||||
markSessionHandled();
|
||||
// Instant DOM restore + fire-and-forget (script handles file cleanup)
|
||||
cleanup();
|
||||
sendEvent({ type: 'discard', id: currentSessionId }, { throwOnError: true })
|
||||
.then(() => {
|
||||
markSessionHandled();
|
||||
cleanup();
|
||||
})
|
||||
.catch(() => showToast('Could not confirm discard with the live server. Session kept for recovery.', 5000));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session persistence via localStorage
|
||||
// Session persistence via live-browser-session.js
|
||||
// ---------------------------------------------------------------------------
|
||||
// Survives page reloads, browser close/reopen, HMR, and accidental refreshes.
|
||||
|
||||
const LS_KEY = PREFIX + '-session';
|
||||
|
||||
function saveSession() {
|
||||
if (!currentSessionId) return;
|
||||
// NOTE: scrollY is stored under a separate key (writeScrollY). Storing
|
||||
// it here would overwrite the Go-time value every time state changes.
|
||||
try {
|
||||
localStorage.setItem(LS_KEY, JSON.stringify({
|
||||
id: currentSessionId,
|
||||
state: state,
|
||||
action: selectedAction,
|
||||
count: selectedCount,
|
||||
expected: expectedVariants,
|
||||
arrived: arrivedVariants,
|
||||
visible: visibleVariant,
|
||||
}));
|
||||
} catch { /* quota exceeded or private mode */ }
|
||||
sessionState.saveSession({
|
||||
id: currentSessionId,
|
||||
state,
|
||||
action: selectedAction,
|
||||
count: selectedCount,
|
||||
expected: expectedVariants,
|
||||
arrived: arrivedVariants,
|
||||
visible: visibleVariant,
|
||||
});
|
||||
}
|
||||
|
||||
function loadSession() {
|
||||
try {
|
||||
const raw = localStorage.getItem(LS_KEY);
|
||||
return raw ? JSON.parse(raw) : null;
|
||||
} catch { return null; }
|
||||
return sessionState.loadSession();
|
||||
}
|
||||
|
||||
function clearSession() {
|
||||
try { localStorage.removeItem(LS_KEY); } catch {}
|
||||
sessionState.clearSession();
|
||||
}
|
||||
|
||||
/** Mark session as handled (accepted/discarded). The agent will clean up
|
||||
@@ -3018,19 +3100,15 @@ void main() {
|
||||
* prevents resumeSession from picking it up again after reload. */
|
||||
function markSessionHandled() {
|
||||
if (!currentSessionId) return;
|
||||
try {
|
||||
localStorage.setItem(LS_KEY + '-handled', currentSessionId);
|
||||
} catch {}
|
||||
sessionState.markHandled(currentSessionId);
|
||||
}
|
||||
|
||||
function isSessionHandled(id) {
|
||||
try {
|
||||
return localStorage.getItem(LS_KEY + '-handled') === id;
|
||||
} catch { return false; }
|
||||
return sessionState.isHandled(id);
|
||||
}
|
||||
|
||||
function clearHandled() {
|
||||
try { localStorage.removeItem(LS_KEY + '-handled'); } catch {}
|
||||
sessionState.clearHandled();
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
@@ -3161,6 +3239,7 @@ void main() {
|
||||
// hid. Now that state is CYCLING, re-fire.
|
||||
if (state === 'CYCLING') refreshParamsPanel();
|
||||
saveSession();
|
||||
queueCheckpoint('browser_resumed');
|
||||
|
||||
// Start observing for more variants AFTER initial setup
|
||||
if (variantObserver) variantObserver.disconnect();
|
||||
@@ -3617,7 +3696,7 @@ void main() {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Design System Panel — visualizes the project's DESIGN.json sidecar
|
||||
// Design System Panel — visualizes the project's .impeccable/design.json sidecar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DESIGN_PREFS_KEY = 'impeccable-live-design-panel';
|
||||
@@ -3629,7 +3708,7 @@ void main() {
|
||||
open: false,
|
||||
tab: 'visual', // 'visual' | 'raw'
|
||||
parsed: null, // parseDesignMd output (frontmatter + body sections)
|
||||
sidecar: null, // DESIGN.json v2 payload (extensions + components + narrative)
|
||||
sidecar: null, // .impeccable/design.json v2 payload (extensions + components + narrative)
|
||||
hasMd: false,
|
||||
hasSidecar: false,
|
||||
present: null, // true/false once fetch resolves
|
||||
@@ -4130,7 +4209,7 @@ void main() {
|
||||
box.className = 'stale';
|
||||
box.innerHTML = `
|
||||
<span class="stale-dot"></span>
|
||||
<span class="stale-text"><strong>DESIGN.md is newer than DESIGN.json.</strong> Run <code>/impeccable document</code> to refresh the sidecar.</span>
|
||||
<span class="stale-text"><strong>DESIGN.md is newer than .impeccable/design.json.</strong> Run <code>/impeccable document</code> to refresh the sidecar.</span>
|
||||
`;
|
||||
return box;
|
||||
}
|
||||
@@ -4138,7 +4217,7 @@ void main() {
|
||||
function renderParsedMdCta() {
|
||||
const box = document.createElement('div');
|
||||
box.className = 'parsed-md-cta';
|
||||
box.innerHTML = `<strong>Basic view</strong>This panel reads the tokens in your <code>DESIGN.md</code> frontmatter. Running <code>/impeccable document</code> also generates a <code>DESIGN.json</code> sidecar with your project's actual component snippets (button, input, nav) and tonal ramps, rendered live below the tokens.`;
|
||||
box.innerHTML = `<strong>Basic view</strong>This panel reads the tokens in your <code>DESIGN.md</code> frontmatter. Running <code>/impeccable document</code> also generates a <code>.impeccable/design.json</code> sidecar with your project's actual component snippets (button, input, nav) and tonal ramps, rendered live below the tokens.`;
|
||||
return box;
|
||||
}
|
||||
|
||||
@@ -4598,7 +4677,7 @@ void main() {
|
||||
|
||||
function cssSafe(v) {
|
||||
// Strip anything outside valid CSS value chars to prevent injection via
|
||||
// DESIGN.json values rendered into inline style strings.
|
||||
// .impeccable/design.json values rendered into inline style strings.
|
||||
return String(v).replace(/[<>"'`\n]/g, '');
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Canonical durable completion acknowledgement for Impeccable live sessions.
|
||||
*/
|
||||
|
||||
import { createLiveSessionStore } from './live-session-store.mjs';
|
||||
import { readLiveServerInfo } from './impeccable-paths.mjs';
|
||||
|
||||
function parseArgs(argv) {
|
||||
const out = { status: 'complete' };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
if (arg === '--id') out.id = argv[++i];
|
||||
else if (arg.startsWith('--id=')) out.id = arg.slice('--id='.length);
|
||||
else if (arg === '--discarded' || arg === '--discard') out.status = 'discarded';
|
||||
else if (arg === '--error') { out.status = 'agent_error'; out.message = argv[++i] || 'unknown error'; }
|
||||
else if (arg.startsWith('--error=')) { out.status = 'agent_error'; out.message = arg.slice('--error='.length); }
|
||||
else if (arg === '--help' || arg === '-h') out.help = true;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function completeCli() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (args.help || !args.id) {
|
||||
console.log(`Usage: node live-complete.mjs --id SESSION_ID [--discarded|--error MESSAGE]\n\nAppend the final durable session acknowledgement. Use after accept/discard cleanup is verified.`);
|
||||
process.exit(args.help ? 0 : 1);
|
||||
}
|
||||
|
||||
const serverInfo = readServerInfo();
|
||||
const serverResult = serverInfo ? await completeThroughServer(serverInfo, args) : null;
|
||||
if (serverResult?.ok) {
|
||||
const store = createLiveSessionStore({ cwd: process.cwd(), sessionId: args.id });
|
||||
const snapshot = store.getSnapshot(args.id, { includeCompleted: true });
|
||||
console.log(JSON.stringify({ ok: true, id: args.id, phase: snapshot?.phase || args.status, snapshot }, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
const store = createLiveSessionStore({ cwd: process.cwd(), sessionId: args.id });
|
||||
const event = args.status === 'discarded'
|
||||
? { type: 'discarded', id: args.id }
|
||||
: args.status === 'agent_error'
|
||||
? { type: 'agent_error', id: args.id, message: args.message || 'unknown error' }
|
||||
: { type: 'complete', id: args.id };
|
||||
const snapshot = store.appendEvent(event);
|
||||
console.log(JSON.stringify({ ok: true, id: args.id, phase: snapshot.phase, snapshot }, null, 2));
|
||||
}
|
||||
|
||||
function readServerInfo() {
|
||||
return readLiveServerInfo(process.cwd())?.info || null;
|
||||
}
|
||||
|
||||
async function completeThroughServer(info, args) {
|
||||
const type = args.status === 'discarded'
|
||||
? 'discarded'
|
||||
: args.status === 'agent_error'
|
||||
? 'error'
|
||||
: 'complete';
|
||||
try {
|
||||
const res = await fetch(`http://localhost:${info.port}/poll`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token: info.token, id: args.id, type, message: args.message }),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
return await res.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const _running = process.argv[1];
|
||||
if (_running?.endsWith('live-complete.mjs') || _running?.endsWith('live-complete.mjs/')) {
|
||||
completeCli();
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
export function completionTypeForAcceptResult(eventType, acceptResult) {
|
||||
if (eventType === 'discard') return acceptResult?.handled === true ? 'discarded' : 'error';
|
||||
if (acceptResult?.handled === true && acceptResult?.carbonize === true) return 'agent_done';
|
||||
if (acceptResult?.handled === true) return 'complete';
|
||||
if (acceptResult?.mode === 'error') return 'error';
|
||||
return 'agent_done';
|
||||
}
|
||||
|
||||
export function completionAckForAcceptResult(eventId, completionType, acceptResult) {
|
||||
const ack = { ok: true, type: completionType };
|
||||
if (acceptResult?.handled === true && acceptResult?.carbonize === true) {
|
||||
ack.final = false;
|
||||
ack.requiresComplete = true;
|
||||
ack.nextCommand = `live-complete.mjs --id ${eventId}`;
|
||||
ack.message = 'Carbonize cleanup must be verified, then the session must be completed explicitly before polling again.';
|
||||
}
|
||||
return ack;
|
||||
}
|
||||
@@ -2,23 +2,24 @@
|
||||
* CLI helper: insert/remove the live variant mode script tag in the project's
|
||||
* main HTML entry point.
|
||||
*
|
||||
* On first live run, the agent generates `config.json` in this script's
|
||||
* directory with the project's insertion target (framework-specific). On
|
||||
* On first live run, the agent generates `.impeccable/live/config.json`
|
||||
* with the project's insertion target (framework-specific). On
|
||||
* every subsequent run, this script handles insert/remove deterministically
|
||||
* with zero LLM involvement.
|
||||
*
|
||||
* Usage:
|
||||
* node live-inject.mjs --port PORT # Insert the live script tag
|
||||
* node live-inject.mjs --remove # Remove the live script tag
|
||||
* node live-inject.mjs --check # Check whether config.json exists
|
||||
* node live-inject.mjs --check # Check whether live config exists
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { resolveLiveConfigPath } from './impeccable-paths.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const CONFIG_PATH = process.env.IMPECCABLE_LIVE_CONFIG || path.join(__dirname, 'config.json');
|
||||
const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname });
|
||||
const MARKER_OPEN_TEXT = 'impeccable-live-start';
|
||||
const MARKER_CLOSE_TEXT = 'impeccable-live-end';
|
||||
|
||||
@@ -39,12 +40,12 @@ export async function injectCli() {
|
||||
console.log(`Usage: node live-inject.mjs [options]
|
||||
|
||||
Insert or remove the live mode script tag in the project's HTML entry point.
|
||||
Reads configuration from config.json (in this same directory).
|
||||
Reads configuration from .impeccable/live/config.json.
|
||||
|
||||
Modes:
|
||||
--port PORT Insert script tag pointing at http://localhost:PORT/live.js
|
||||
--remove Remove the script tag (if present)
|
||||
--check Print whether config.json exists and its content
|
||||
--check Print whether .impeccable/live/config.json exists and its content
|
||||
|
||||
Output (JSON):
|
||||
{ ok, file, inserted|removed, config? }`);
|
||||
|
||||
@@ -9,10 +9,10 @@
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { completionAckForAcceptResult, completionTypeForAcceptResult } from './live-completion.mjs';
|
||||
import { readLiveServerInfo } from './impeccable-paths.mjs';
|
||||
|
||||
// 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
|
||||
@@ -20,15 +20,29 @@ import { fileURLToPath } from 'node:url';
|
||||
// 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 {
|
||||
const record = readLiveServerInfo(process.cwd());
|
||||
if (!record) {
|
||||
console.error('No running live server found. Start one with: npx impeccable live');
|
||||
process.exit(1);
|
||||
}
|
||||
return record.info;
|
||||
}
|
||||
|
||||
export function buildPollReplyPayload(token, { id, type, message, file, data }) {
|
||||
return { token, id, type, message, file, data };
|
||||
}
|
||||
|
||||
async function postReply(base, token, reply) {
|
||||
const res = await fetch(`${base}/poll`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(buildPollReplyPayload(token, reply)),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error || res.statusText);
|
||||
}
|
||||
}
|
||||
|
||||
export async function pollCli() {
|
||||
@@ -69,23 +83,7 @@ Options:
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
await postReply(base, info.token, { id, type: status, message, file: filePath });
|
||||
|
||||
// Success — silent exit (agent doesn't need output for replies)
|
||||
} catch (err) {
|
||||
@@ -156,7 +154,23 @@ Options:
|
||||
);
|
||||
event._acceptResult = JSON.parse(out.trim());
|
||||
} catch (err) {
|
||||
event._acceptResult = { handled: false, error: err.message };
|
||||
event._acceptResult = { handled: false, mode: 'error', error: err.message };
|
||||
}
|
||||
|
||||
const completionType = completionTypeForAcceptResult(event.type, event._acceptResult);
|
||||
try {
|
||||
await postReply(base, info.token, {
|
||||
id: event.id,
|
||||
type: completionType,
|
||||
message: event._acceptResult?.error,
|
||||
file: event._acceptResult?.file,
|
||||
data: event._acceptResult?.carbonize === true ? { carbonize: true } : undefined,
|
||||
});
|
||||
} catch (err) {
|
||||
event._completionAck = { ok: false, error: err.message };
|
||||
}
|
||||
if (!event._completionAck) {
|
||||
event._completionAck = completionAckForAcceptResult(event.id, completionType, event._acceptResult);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,7 +178,7 @@ Options:
|
||||
// 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');
|
||||
process.stderr.write('\n⚠ Carbonize cleanup REQUIRED before next poll. After cleanup, run live-complete.mjs --id ' + event.id + '. See reference/live.md "Required after accept".\n\n');
|
||||
}
|
||||
|
||||
// Print the event as JSON — the agent reads this from stdout
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Recover the next agent action from the durable live-session journal.
|
||||
*/
|
||||
|
||||
import { createLiveSessionStore } from './live-session-store.mjs';
|
||||
|
||||
function parseArgs(argv) {
|
||||
const out = { id: null };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
if (arg === '--id') out.id = argv[++i];
|
||||
else if (arg.startsWith('--id=')) out.id = arg.slice('--id='.length);
|
||||
else if (arg === '--help' || arg === '-h') out.help = true;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function resumeCli() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (args.help) {
|
||||
console.log(`Usage: node live-resume.mjs [--id SESSION_ID]\n\nPrint the active durable session checkpoint and the next safe agent action.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const store = createLiveSessionStore({ cwd: process.cwd(), sessionId: args.id || undefined });
|
||||
const snapshot = args.id ? store.getSnapshot(args.id) : store.listActiveSessions()[0] || null;
|
||||
if (!snapshot) {
|
||||
console.log(JSON.stringify({ active: false, nextAction: 'No active durable live session found.' }, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
const pending = snapshot.pendingEvent || null;
|
||||
const nextAction = pending
|
||||
? `Run live-poll.mjs, handle ${pending.type} ${pending.id}, then acknowledge with live-poll.mjs --reply ${pending.id} done.`
|
||||
: snapshot.phase === 'carbonize_required'
|
||||
? `Finish carbonize cleanup${snapshot.sourceFile ? ` in ${snapshot.sourceFile}` : ''}, then run live-complete.mjs --id ${snapshot.id}.`
|
||||
: snapshot.phase === 'accept_requested'
|
||||
? `Run live-complete.mjs --id ${snapshot.id} after verifying the accepted variant is written.`
|
||||
: `Inspect ${snapshot.id}; no pending agent event is currently queued.`;
|
||||
|
||||
console.log(JSON.stringify({ active: true, snapshot, pendingEvent: pending, nextAction }, null, 2));
|
||||
}
|
||||
|
||||
const _running = process.argv[1];
|
||||
if (_running?.endsWith('live-resume.mjs') || _running?.endsWith('live-resume.mjs/')) {
|
||||
resumeCli();
|
||||
}
|
||||
@@ -22,14 +22,20 @@ import net from 'node:net';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseDesignMd } from './design-parser.mjs';
|
||||
import { resolveContextDir } from './load-context.mjs';
|
||||
import { createLiveSessionStore } from './live-session-store.mjs';
|
||||
import {
|
||||
getDesignSidecarPath,
|
||||
getLiveAnnotationsDir,
|
||||
readLiveServerInfo,
|
||||
removeLiveServerInfo,
|
||||
resolveDesignSidecarPath,
|
||||
writeLiveServerInfo,
|
||||
} from './impeccable-paths.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');
|
||||
// PRODUCT.md / DESIGN.md / DESIGN.json live wherever load-context.mjs resolves.
|
||||
// Keeps live-server in sync with the loader when users keep the docs in
|
||||
// .agents/context/, docs/, or a path set via IMPECCABLE_CONTEXT_DIR.
|
||||
// PRODUCT.md / DESIGN.md live wherever load-context.mjs resolves. The generated
|
||||
// DESIGN sidecar is project-local at .impeccable/design.json, with legacy
|
||||
// DESIGN.json fallback for existing projects.
|
||||
const CONTEXT_DIR = resolveContextDir(process.cwd());
|
||||
const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway
|
||||
const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s
|
||||
@@ -57,10 +63,12 @@ const state = {
|
||||
token: null,
|
||||
port: null,
|
||||
sseClients: new Set(), // SSE response objects (server→browser push)
|
||||
pendingEvents: [], // browser events waiting for agent poll
|
||||
pendingEvents: [], // browser events waiting for agent ack ({ event, leaseUntil })
|
||||
pendingPolls: [], // agent poll callbacks waiting for browser events
|
||||
exitTimer: null,
|
||||
sessionDir: null, // per-session tmp dir for annotation screenshots
|
||||
sessionStore: null,
|
||||
leaseTimer: null,
|
||||
};
|
||||
|
||||
// Cap per-annotation upload size. A full 1920×1080 PNG is typically <1 MB;
|
||||
@@ -68,13 +76,72 @@ const state = {
|
||||
const MAX_ANNOTATION_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
function enqueueEvent(event) {
|
||||
if (state.pendingPolls.length > 0) {
|
||||
state.pendingPolls.shift()(event);
|
||||
} else {
|
||||
state.pendingEvents.push(event);
|
||||
if (!event || (event.id && state.pendingEvents.some((entry) => entry.event?.id === event.id && entry.event?.type === event.type))) return;
|
||||
state.pendingEvents.push({ event, leaseUntil: 0 });
|
||||
flushPendingPolls();
|
||||
}
|
||||
|
||||
function restorePendingEventsFromStore() {
|
||||
if (!state.sessionStore) return;
|
||||
for (const snapshot of state.sessionStore.listActiveSessions()) {
|
||||
if (snapshot.pendingEvent) enqueueEvent(snapshot.pendingEvent);
|
||||
}
|
||||
}
|
||||
|
||||
function findAvailablePendingEvent(now = Date.now()) {
|
||||
return state.pendingEvents.find((entry) => !entry.leaseUntil || entry.leaseUntil <= now);
|
||||
}
|
||||
|
||||
function leaseEvent(entry, leaseMs) {
|
||||
if (!entry.event?.id) {
|
||||
const idx = state.pendingEvents.indexOf(entry);
|
||||
if (idx !== -1) state.pendingEvents.splice(idx, 1);
|
||||
return entry.event;
|
||||
}
|
||||
entry.leaseUntil = Date.now() + leaseMs;
|
||||
return entry.event;
|
||||
}
|
||||
|
||||
function acknowledgePendingEvent(id) {
|
||||
if (!id) return false;
|
||||
const idx = state.pendingEvents.findIndex((entry) => entry.event?.id === id);
|
||||
if (idx === -1) return false;
|
||||
state.pendingEvents.splice(idx, 1);
|
||||
scheduleLeaseFlush();
|
||||
return true;
|
||||
}
|
||||
|
||||
function scheduleLeaseFlush() {
|
||||
if (state.leaseTimer) {
|
||||
clearTimeout(state.leaseTimer);
|
||||
state.leaseTimer = null;
|
||||
}
|
||||
if (state.pendingPolls.length === 0) return;
|
||||
const now = Date.now();
|
||||
const nextLeaseUntil = state.pendingEvents
|
||||
.map((entry) => entry.leaseUntil || 0)
|
||||
.filter((leaseUntil) => leaseUntil > now)
|
||||
.sort((a, b) => a - b)[0];
|
||||
if (!nextLeaseUntil) return;
|
||||
state.leaseTimer = setTimeout(() => {
|
||||
state.leaseTimer = null;
|
||||
flushPendingPolls();
|
||||
}, Math.max(0, nextLeaseUntil - now));
|
||||
}
|
||||
|
||||
function flushPendingPolls() {
|
||||
while (state.pendingPolls.length > 0) {
|
||||
const entry = findAvailablePendingEvent();
|
||||
if (!entry) {
|
||||
scheduleLeaseFlush();
|
||||
return;
|
||||
}
|
||||
const poll = state.pendingPolls.shift();
|
||||
poll.resolve(leaseEvent(entry, poll.leaseMs));
|
||||
}
|
||||
scheduleLeaseFlush();
|
||||
}
|
||||
|
||||
/** Push a message to all connected SSE clients. */
|
||||
function broadcast(msg) {
|
||||
const data = 'data: ' + JSON.stringify(msg) + '\n\n';
|
||||
@@ -103,13 +170,16 @@ function loadBrowserScripts() {
|
||||
// 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 sessionPath = path.join(__dirname, 'live-browser-session.js');
|
||||
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);
|
||||
for (const p of [sessionPath, livePath]) {
|
||||
if (!fs.existsSync(p)) {
|
||||
process.stderr.write('Error: live browser script not found at ' + p + '\n');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
return { detectScript, livePath };
|
||||
return { detectScript, sessionPath, livePath };
|
||||
}
|
||||
|
||||
function hasProjectContext() {
|
||||
@@ -170,6 +240,13 @@ function validateEvent(msg) {
|
||||
return null;
|
||||
case 'discard':
|
||||
return isValidId(msg.id) ? null : 'discard: missing or malformed id';
|
||||
case 'checkpoint':
|
||||
if (!isValidId(msg.id)) return 'checkpoint: missing or malformed id';
|
||||
if (!Number.isInteger(msg.revision) || msg.revision < 0) return 'checkpoint: revision must be a non-negative integer';
|
||||
if (msg.paramValues !== undefined && (typeof msg.paramValues !== 'object' || msg.paramValues === null || Array.isArray(msg.paramValues))) {
|
||||
return 'checkpoint: paramValues must be an object';
|
||||
}
|
||||
return null;
|
||||
case 'exit':
|
||||
return null;
|
||||
case 'prefetch':
|
||||
@@ -184,7 +261,7 @@ function validateEvent(msg) {
|
||||
// HTTP request handler
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createRequestHandler({ detectScript, livePath }) {
|
||||
function createRequestHandler({ detectScript, sessionPath, livePath }) {
|
||||
return (req, res) => {
|
||||
const url = new URL(req.url, `http://localhost:${state.port}`);
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
@@ -200,17 +277,20 @@ function createRequestHandler({ detectScript, livePath }) {
|
||||
// the next tab reload. No-store headers prevent browser caching across
|
||||
// sessions — during iteration, a cached old script silently breaks
|
||||
// every subsequent session.
|
||||
let sessionScript;
|
||||
let liveScript;
|
||||
try {
|
||||
sessionScript = fs.readFileSync(sessionPath, 'utf-8');
|
||||
liveScript = fs.readFileSync(livePath, 'utf-8');
|
||||
} catch (err) {
|
||||
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
||||
res.end('Error reading live-browser.js: ' + err.message);
|
||||
res.end('Error reading live browser scripts: ' + err.message);
|
||||
return;
|
||||
}
|
||||
const body =
|
||||
`window.__IMPECCABLE_TOKEN__ = '${state.token}';\n` +
|
||||
`window.__IMPECCABLE_PORT__ = ${state.port};\n` +
|
||||
sessionScript + '\n' +
|
||||
liveScript;
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/javascript',
|
||||
@@ -305,6 +385,26 @@ function createRequestHandler({ detectScript, livePath }) {
|
||||
}
|
||||
|
||||
// --- Health ---
|
||||
if (p === '/status') {
|
||||
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 sessions = state.sessionStore ? state.sessionStore.listActiveSessions() : [];
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
status: 'ok',
|
||||
port: state.port,
|
||||
connectedClients: state.sseClients.size,
|
||||
pendingEvents: state.pendingEvents.map((entry) => ({
|
||||
id: entry.event?.id,
|
||||
type: entry.event?.type,
|
||||
leased: !!(entry.leaseUntil && entry.leaseUntil > Date.now()),
|
||||
leaseUntil: entry.leaseUntil || null,
|
||||
})),
|
||||
activeSessions: sessions,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (p === '/health') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
@@ -316,13 +416,13 @@ function createRequestHandler({ detectScript, livePath }) {
|
||||
}
|
||||
|
||||
// --- Design system (unified v2 response) + raw ---
|
||||
// /design-system.json returns both parsed DESIGN.md and DESIGN.json
|
||||
// /design-system.json returns both parsed DESIGN.md and .impeccable/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.
|
||||
// - sidecar: .impeccable/design.json contents when present.
|
||||
// Expected shape: schemaVersion 2, carrying
|
||||
// extensions + components + narrative.
|
||||
// /design-system/raw returns DESIGN.md markdown verbatim
|
||||
@@ -331,7 +431,7 @@ function createRequestHandler({ detectScript, livePath }) {
|
||||
if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
|
||||
|
||||
const mdPath = path.join(CONTEXT_DIR, 'DESIGN.md');
|
||||
const jsonPath = path.join(CONTEXT_DIR, 'DESIGN.json');
|
||||
const jsonPath = resolveDesignSidecarPath(process.cwd(), CONTEXT_DIR) || getDesignSidecarPath(process.cwd());
|
||||
const mdStat = statOrNull(mdPath);
|
||||
const jsonStat = statOrNull(jsonPath);
|
||||
|
||||
@@ -367,7 +467,7 @@ function createRequestHandler({ detectScript, livePath }) {
|
||||
try {
|
||||
response.sidecar = JSON.parse(fs.readFileSync(jsonPath, 'utf-8'));
|
||||
} catch (err) {
|
||||
response.sidecarError = 'Failed to parse DESIGN.json: ' + err.message;
|
||||
response.sidecarError = 'Failed to parse .impeccable/design.json: ' + err.message;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -449,7 +549,16 @@ function createRequestHandler({ detectScript, livePath }) {
|
||||
res.end(JSON.stringify({ error }));
|
||||
return;
|
||||
}
|
||||
enqueueEvent(msg);
|
||||
if (state.sessionStore && msg.id) {
|
||||
try {
|
||||
state.sessionStore.appendEvent(msg);
|
||||
} catch (err) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'session_store_append_failed', message: err.message }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (msg.type !== 'checkpoint') enqueueEvent(msg);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ ok: true }));
|
||||
});
|
||||
@@ -492,13 +601,16 @@ function handlePollGet(req, res, url) {
|
||||
return;
|
||||
}
|
||||
const timeout = parseInt(url.searchParams.get('timeout') || DEFAULT_POLL_TIMEOUT, 10);
|
||||
if (state.pendingEvents.length > 0) {
|
||||
const leaseMs = parseInt(url.searchParams.get('leaseMs') || '30000', 10);
|
||||
const available = findAvailablePendingEvent();
|
||||
if (available) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(state.pendingEvents.shift()));
|
||||
res.end(JSON.stringify(leaseEvent(available, leaseMs)));
|
||||
return;
|
||||
}
|
||||
const poll = { resolve, leaseMs };
|
||||
const timer = setTimeout(() => {
|
||||
const idx = state.pendingPolls.indexOf(resolve);
|
||||
const idx = state.pendingPolls.indexOf(poll);
|
||||
if (idx !== -1) state.pendingPolls.splice(idx, 1);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ type: 'timeout' }));
|
||||
@@ -508,10 +620,11 @@ function handlePollGet(req, res, url) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(event));
|
||||
}
|
||||
state.pendingPolls.push(resolve);
|
||||
state.pendingPolls.push(poll);
|
||||
scheduleLeaseFlush();
|
||||
req.on('close', () => {
|
||||
clearTimeout(timer);
|
||||
const idx = state.pendingPolls.indexOf(resolve);
|
||||
const idx = state.pendingPolls.indexOf(poll);
|
||||
if (idx !== -1) state.pendingPolls.splice(idx, 1);
|
||||
});
|
||||
}
|
||||
@@ -531,6 +644,26 @@ function handlePollPost(req, res) {
|
||||
res.end(JSON.stringify({ error: 'Unauthorized' }));
|
||||
return;
|
||||
}
|
||||
acknowledgePendingEvent(msg.id);
|
||||
if (state.sessionStore && msg.id) {
|
||||
try {
|
||||
const eventType = msg.type === 'discard' || msg.type === 'discarded'
|
||||
? 'discarded'
|
||||
: msg.type === 'complete'
|
||||
? 'complete'
|
||||
: msg.type === 'error'
|
||||
? 'agent_error'
|
||||
: 'agent_done';
|
||||
state.sessionStore.appendEvent({
|
||||
type: eventType,
|
||||
id: msg.id,
|
||||
file: msg.file,
|
||||
message: msg.message,
|
||||
carbonize: msg.data?.carbonize === true,
|
||||
});
|
||||
} catch { /* keep reply path best-effort; browser still needs SSE */ }
|
||||
}
|
||||
flushPendingPolls();
|
||||
// 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' });
|
||||
@@ -545,13 +678,15 @@ function handlePollPost(req, res) {
|
||||
let httpServer = null;
|
||||
|
||||
function shutdown() {
|
||||
try { fs.unlinkSync(LIVE_PID_FILE); } catch {}
|
||||
removeLiveServerInfo(process.cwd());
|
||||
if (state.leaseTimer) clearTimeout(state.leaseTimer);
|
||||
state.leaseTimer = null;
|
||||
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' });
|
||||
for (const poll of state.pendingPolls) poll.resolve({ type: 'exit' });
|
||||
state.pendingPolls.length = 0;
|
||||
if (httpServer) httpServer.close();
|
||||
process.exit(0);
|
||||
@@ -587,6 +722,7 @@ Endpoints:
|
||||
/events SSE stream (server→browser) + POST (browser→server)
|
||||
/poll Long-poll for agent CLI
|
||||
/source Raw source file reader (no-HMR fallback)
|
||||
/status Durable recovery status (token-protected)
|
||||
/health Health check`);
|
||||
process.exit(0);
|
||||
}
|
||||
@@ -594,7 +730,7 @@ Endpoints:
|
||||
if (args.includes('stop')) {
|
||||
const keepInject = args.includes('--keep-inject');
|
||||
try {
|
||||
const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
|
||||
const { info } = readLiveServerInfo(process.cwd()) || {};
|
||||
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 {
|
||||
@@ -645,7 +781,7 @@ if (args.includes('--background')) {
|
||||
const deadline = Date.now() + 10_000;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
|
||||
const { info } = readLiveServerInfo(process.cwd()) || {};
|
||||
if (info.pid !== process.pid) {
|
||||
// Output JSON so the agent can read port + token from stdout.
|
||||
console.log(JSON.stringify(info));
|
||||
@@ -659,30 +795,36 @@ if (args.includes('--background')) {
|
||||
}
|
||||
|
||||
// Check for existing session
|
||||
try {
|
||||
const existing = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
|
||||
try { process.kill(existing.pid, 0);
|
||||
const existingRecord = readLiveServerInfo(process.cwd());
|
||||
if (existingRecord?.info) {
|
||||
const existing = existingRecord.info;
|
||||
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 {}
|
||||
} catch {
|
||||
try { fs.unlinkSync(existingRecord.path); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
state.token = randomUUID();
|
||||
state.sessionStore = createLiveSessionStore({ cwd: process.cwd() });
|
||||
restorePendingEventsFromStore();
|
||||
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');
|
||||
const annotRoot = getLiveAnnotationsDir(process.cwd());
|
||||
fs.mkdirSync(annotRoot, { recursive: true });
|
||||
state.sessionDir = fs.mkdtempSync(path.join(annotRoot, 'session-'));
|
||||
|
||||
const { detectScript, livePath } = loadBrowserScripts();
|
||||
httpServer = http.createServer(createRequestHandler({ detectScript, livePath }));
|
||||
const { detectScript, sessionPath, livePath } = loadBrowserScripts();
|
||||
httpServer = http.createServer(createRequestHandler({ detectScript, sessionPath, 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 }));
|
||||
writeLiveServerInfo(process.cwd(), { 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`);
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { getLegacyLiveSessionsDir, getLiveSessionsDir } from './impeccable-paths.mjs';
|
||||
|
||||
const COMPLETED_PHASES = new Set(['completed', 'discarded']);
|
||||
|
||||
export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) {
|
||||
const rootDir = getLiveSessionsDir(cwd);
|
||||
const legacyRootDir = getLegacyLiveSessionsDir(cwd);
|
||||
fs.mkdirSync(rootDir, { recursive: true });
|
||||
const snapshotCache = new Map();
|
||||
|
||||
function loadCachedOrRebuild(id) {
|
||||
const cached = snapshotCache.get(id);
|
||||
if (cached) return cached;
|
||||
const journalPath = getReadableJournalPath(id);
|
||||
const rebuilt = rebuildSnapshotFromJournal(journalPath, id);
|
||||
snapshotCache.set(id, rebuilt);
|
||||
return rebuilt;
|
||||
}
|
||||
|
||||
function getReadableJournalPath(id) {
|
||||
const primary = getJournalPath(rootDir, id);
|
||||
if (fs.existsSync(primary)) return primary;
|
||||
const legacy = getJournalPath(legacyRootDir, id);
|
||||
if (fs.existsSync(legacy)) return legacy;
|
||||
return primary;
|
||||
}
|
||||
|
||||
return {
|
||||
rootDir,
|
||||
legacyRootDir,
|
||||
appendEvent(event) {
|
||||
const normalized = normalizeEvent(event, sessionId);
|
||||
const journalPath = getJournalPath(rootDir, normalized.id);
|
||||
const snapshotPath = getSnapshotPath(rootDir, normalized.id);
|
||||
const legacyJournalPath = getJournalPath(legacyRootDir, normalized.id);
|
||||
if (!fs.existsSync(journalPath) && fs.existsSync(legacyJournalPath)) {
|
||||
fs.copyFileSync(legacyJournalPath, journalPath);
|
||||
}
|
||||
const prior = loadCachedOrRebuild(normalized.id);
|
||||
const seq = prior.nextSeq;
|
||||
const entry = {
|
||||
seq,
|
||||
id: normalized.id,
|
||||
type: normalized.type,
|
||||
ts: new Date().toISOString(),
|
||||
event: normalized,
|
||||
};
|
||||
fs.appendFileSync(journalPath, JSON.stringify(entry) + '\n');
|
||||
const next = applyEvent(prior.snapshot, entry, prior.diagnostics);
|
||||
snapshotCache.set(normalized.id, { snapshot: next, diagnostics: next.diagnostics || [], nextSeq: seq + 1 });
|
||||
writeSnapshot(snapshotPath, next);
|
||||
return next;
|
||||
},
|
||||
getSnapshot(id = sessionId, opts = {}) {
|
||||
if (!id) throw new Error('session id required');
|
||||
const journalPath = getReadableJournalPath(id);
|
||||
const snapshotPath = getSnapshotPath(rootDir, id);
|
||||
const rebuilt = rebuildSnapshotFromJournal(journalPath, id);
|
||||
snapshotCache.set(id, rebuilt);
|
||||
writeSnapshot(snapshotPath, rebuilt.snapshot);
|
||||
if (!opts.includeCompleted && COMPLETED_PHASES.has(rebuilt.snapshot.phase)) return null;
|
||||
return rebuilt.snapshot;
|
||||
},
|
||||
listActiveSessions() {
|
||||
const ids = new Set();
|
||||
for (const dir of [legacyRootDir, rootDir]) {
|
||||
if (!fs.existsSync(dir)) continue;
|
||||
for (const name of fs.readdirSync(dir)) {
|
||||
if (name.endsWith('.jsonl')) ids.add(name.slice(0, -'.jsonl'.length));
|
||||
}
|
||||
}
|
||||
return [...ids]
|
||||
.sort()
|
||||
.map((id) => this.getSnapshot(id))
|
||||
.filter(Boolean);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeEvent(event, fallbackId) {
|
||||
if (!event || typeof event !== 'object') throw new Error('event object required');
|
||||
const id = event.id || fallbackId;
|
||||
if (!id || typeof id !== 'string') throw new Error('event id required');
|
||||
if (!event.type || typeof event.type !== 'string') throw new Error('event type required');
|
||||
return { ...event, id };
|
||||
}
|
||||
|
||||
function getJournalPath(rootDir, id) {
|
||||
return path.join(rootDir, safeSessionId(id) + '.jsonl');
|
||||
}
|
||||
|
||||
function getSnapshotPath(rootDir, id) {
|
||||
return path.join(rootDir, safeSessionId(id) + '.snapshot.json');
|
||||
}
|
||||
|
||||
function safeSessionId(id) {
|
||||
if (!/^[A-Za-z0-9_-]{1,128}$/.test(id)) throw new Error('invalid session id: ' + id);
|
||||
return id;
|
||||
}
|
||||
|
||||
function baseSnapshot(id) {
|
||||
return {
|
||||
id,
|
||||
phase: 'new',
|
||||
pageUrl: null,
|
||||
sourceFile: null,
|
||||
expectedVariants: 0,
|
||||
arrivedVariants: 0,
|
||||
visibleVariant: null,
|
||||
paramValues: {},
|
||||
pendingEventSeq: null,
|
||||
pendingEvent: null,
|
||||
deliveryLease: null,
|
||||
checkpointRevision: 0,
|
||||
activeOwner: null,
|
||||
sourceMarkers: {},
|
||||
fallbackMode: null,
|
||||
annotationArtifacts: [],
|
||||
diagnostics: [],
|
||||
updatedAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
function rebuildSnapshotFromJournal(journalPath, id) {
|
||||
let snapshot = baseSnapshot(id);
|
||||
const diagnostics = [];
|
||||
let nextSeq = 1;
|
||||
if (!fs.existsSync(journalPath)) return { snapshot, diagnostics, nextSeq };
|
||||
|
||||
const lines = fs.readFileSync(journalPath, 'utf-8').split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const entry = JSON.parse(line);
|
||||
if (!entry || typeof entry !== 'object') throw new Error('entry is not object');
|
||||
if (Number.isInteger(entry.seq)) nextSeq = Math.max(nextSeq, entry.seq + 1);
|
||||
snapshot = applyEvent(snapshot, entry);
|
||||
} catch (err) {
|
||||
diagnostics.push({
|
||||
error: 'journal_parse_failed',
|
||||
line: i + 1,
|
||||
message: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
snapshot.diagnostics = [...snapshot.diagnostics, ...diagnostics];
|
||||
return { snapshot, diagnostics, nextSeq };
|
||||
}
|
||||
|
||||
function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
|
||||
const event = entry.event || entry;
|
||||
const next = {
|
||||
...snapshot,
|
||||
paramValues: { ...(snapshot.paramValues || {}) },
|
||||
sourceMarkers: { ...(snapshot.sourceMarkers || {}) },
|
||||
annotationArtifacts: [...(snapshot.annotationArtifacts || [])],
|
||||
diagnostics: [...(snapshot.diagnostics || [])],
|
||||
updatedAt: entry.ts || new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (inheritedDiagnostics.length && next.diagnostics.length === 0) {
|
||||
next.diagnostics = [...inheritedDiagnostics];
|
||||
}
|
||||
|
||||
switch (event.type) {
|
||||
case 'generate':
|
||||
next.phase = 'generate_requested';
|
||||
next.pageUrl = event.pageUrl ?? next.pageUrl;
|
||||
next.expectedVariants = event.count ?? next.expectedVariants;
|
||||
next.pendingEventSeq = entry.seq ?? next.pendingEventSeq;
|
||||
next.pendingEvent = toPendingEvent(event);
|
||||
if (event.screenshotPath) upsertArtifact(next.annotationArtifacts, { type: 'screenshot', path: event.screenshotPath });
|
||||
break;
|
||||
case 'variants_ready':
|
||||
case 'agent_done':
|
||||
next.phase = event.carbonize === true ? 'carbonize_required' : 'variants_ready';
|
||||
next.sourceFile = event.file ?? next.sourceFile;
|
||||
next.arrivedVariants = event.arrivedVariants ?? (next.arrivedVariants ?? next.expectedVariants);
|
||||
next.pendingEventSeq = null;
|
||||
next.pendingEvent = null;
|
||||
if (event.carbonize === true) {
|
||||
next.diagnostics.push({
|
||||
error: 'carbonize_cleanup_required',
|
||||
file: event.file || null,
|
||||
message: 'Accepted variant still has carbonize markers that must be folded into source CSS.',
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'checkpoint':
|
||||
if ((event.revision ?? 0) >= (next.checkpointRevision ?? 0)) {
|
||||
next.phase = event.phase ?? next.phase;
|
||||
next.checkpointRevision = event.revision ?? next.checkpointRevision;
|
||||
next.activeOwner = event.owner ?? next.activeOwner;
|
||||
next.arrivedVariants = event.arrivedVariants ?? next.arrivedVariants;
|
||||
next.visibleVariant = event.visibleVariant ?? next.visibleVariant;
|
||||
if (event.paramValues) next.paramValues = { ...event.paramValues };
|
||||
} else {
|
||||
next.diagnostics.push({ error: 'stale_checkpoint_ignored', revision: event.revision });
|
||||
}
|
||||
break;
|
||||
case 'accept':
|
||||
case 'accept_intent':
|
||||
next.phase = 'accept_requested';
|
||||
next.visibleVariant = Number(event.variantId ?? next.visibleVariant);
|
||||
if (event.paramValues) next.paramValues = { ...event.paramValues };
|
||||
next.pendingEventSeq = entry.seq ?? next.pendingEventSeq;
|
||||
next.pendingEvent = toPendingEvent(event);
|
||||
break;
|
||||
case 'discard':
|
||||
next.phase = 'discard_requested';
|
||||
next.pendingEventSeq = entry.seq ?? next.pendingEventSeq;
|
||||
next.pendingEvent = toPendingEvent(event);
|
||||
break;
|
||||
case 'discarded':
|
||||
next.phase = 'discarded';
|
||||
next.pendingEventSeq = null;
|
||||
next.pendingEvent = null;
|
||||
break;
|
||||
case 'complete':
|
||||
next.phase = 'completed';
|
||||
next.pendingEventSeq = null;
|
||||
next.pendingEvent = null;
|
||||
break;
|
||||
case 'agent_error':
|
||||
next.phase = 'agent_error';
|
||||
next.pendingEventSeq = null;
|
||||
next.pendingEvent = null;
|
||||
next.diagnostics.push({ error: 'agent_error', message: event.message || 'unknown agent error' });
|
||||
break;
|
||||
default:
|
||||
next.diagnostics.push({ error: 'unknown_event_type', type: event.type });
|
||||
break;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function toPendingEvent(event) {
|
||||
const pending = { ...event };
|
||||
delete pending.token;
|
||||
return pending;
|
||||
}
|
||||
|
||||
function upsertArtifact(artifacts, artifact) {
|
||||
if (!artifacts.some((existing) => existing.path === artifact.path && existing.type === artifact.type)) {
|
||||
artifacts.push(artifact);
|
||||
}
|
||||
}
|
||||
|
||||
function writeSnapshot(snapshotPath, snapshot) {
|
||||
fs.writeFileSync(snapshotPath, JSON.stringify(snapshot, null, 2) + '\n');
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Print durable recovery status for Impeccable live sessions.
|
||||
*/
|
||||
|
||||
import { createLiveSessionStore } from './live-session-store.mjs';
|
||||
import { readLiveServerInfo } from './impeccable-paths.mjs';
|
||||
|
||||
function readServerInfo() {
|
||||
return readLiveServerInfo(process.cwd())?.info || null;
|
||||
}
|
||||
|
||||
async function fetchServerStatus(info) {
|
||||
if (!info) return null;
|
||||
try {
|
||||
const res = await fetch(`http://localhost:${info.port}/status?token=${info.token}`);
|
||||
if (!res.ok) return null;
|
||||
return await res.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function statusCli() {
|
||||
const info = readServerInfo();
|
||||
const server = await fetchServerStatus(info);
|
||||
const store = createLiveSessionStore({ cwd: process.cwd() });
|
||||
const activeSessions = store.listActiveSessions();
|
||||
const payload = {
|
||||
liveServer: server ? {
|
||||
status: server.status,
|
||||
port: server.port,
|
||||
connectedClients: server.connectedClients,
|
||||
pendingEvents: server.pendingEvents,
|
||||
} : null,
|
||||
activeSessions: server?.activeSessions || activeSessions,
|
||||
recoveryHint: server
|
||||
? 'Run live-poll.mjs to continue pending work, or live-complete.mjs --id <session> after manual cleanup.'
|
||||
: 'Start live-server.mjs to requeue pending durable events, then run live-poll.mjs.',
|
||||
};
|
||||
console.log(JSON.stringify(payload, null, 2));
|
||||
}
|
||||
|
||||
const _running = process.argv[1];
|
||||
if (_running?.endsWith('live-status.mjs') || _running?.endsWith('live-status.mjs/')) {
|
||||
statusCli();
|
||||
}
|
||||
@@ -185,6 +185,7 @@ The agent should insert variant HTML at insertLine.`);
|
||||
|
||||
const { startLine, endLine } = match;
|
||||
const commentSyntax = detectCommentSyntax(targetFile);
|
||||
const styleMode = detectStyleMode(targetFile);
|
||||
const isJsx = commentSyntax.open === '{/*';
|
||||
const indent = lines[startLine].match(/^(\s*)/)[1];
|
||||
|
||||
@@ -269,6 +270,10 @@ The agent should insert variant HTML at insertLine.`);
|
||||
endLine: startLine + wrapperLines.length + (originalLines.length - 1), // 1-indexed
|
||||
insertLine: insertLine + 1, // 1-indexed: where variants go
|
||||
commentSyntax: commentSyntax,
|
||||
styleMode: styleMode.mode,
|
||||
styleTag: styleMode.styleTag,
|
||||
cssSelectorPrefixExamples: buildCssSelectorPrefixExamples(styleMode.mode, count),
|
||||
cssAuthoring: buildCssAuthoring(styleMode, count),
|
||||
originalLineCount: originalLines.length,
|
||||
}));
|
||||
}
|
||||
@@ -335,6 +340,62 @@ function detectCommentSyntax(filePath) {
|
||||
return { open: '<!--', close: '-->' };
|
||||
}
|
||||
|
||||
function detectStyleMode(filePath) {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
if (ext === '.astro') {
|
||||
return {
|
||||
mode: 'astro-global-prefixed',
|
||||
styleTag: '<style is:inline data-impeccable-css="SESSION_ID">',
|
||||
};
|
||||
}
|
||||
return {
|
||||
mode: 'scoped',
|
||||
styleTag: '<style data-impeccable-css="SESSION_ID">',
|
||||
};
|
||||
}
|
||||
|
||||
function buildCssSelectorPrefixExamples(styleMode, count) {
|
||||
if (styleMode !== 'astro-global-prefixed') return [];
|
||||
return Array.from({ length: count }, (_, i) => `[data-impeccable-variant="${i + 1}"]`);
|
||||
}
|
||||
|
||||
function buildCssAuthoring(styleMode, count) {
|
||||
const variantNumbers = Array.from({ length: count }, (_, i) => i + 1);
|
||||
if (styleMode.mode === 'astro-global-prefixed') {
|
||||
return {
|
||||
mode: styleMode.mode,
|
||||
styleTag: styleMode.styleTag,
|
||||
strategy: 'global-prefixed',
|
||||
rulePattern: '[data-impeccable-variant="N"] > .variant-class { ... }',
|
||||
selectorExamples: variantNumbers.map((n) => `[data-impeccable-variant="${n}"] > .variant-class`),
|
||||
requirements: [
|
||||
'Use the styleTag exactly; the is:inline attribute is required for this file.',
|
||||
'Prefix every preview selector with the matching [data-impeccable-variant="N"] selector.',
|
||||
'Keep selectors anchored to the generated variant wrapper; do not rely on component CSS scoping for preview rules.',
|
||||
],
|
||||
forbidden: [
|
||||
'Do not use @scope for this styleMode.',
|
||||
],
|
||||
};
|
||||
}
|
||||
return {
|
||||
mode: styleMode.mode,
|
||||
styleTag: styleMode.styleTag,
|
||||
strategy: 'scope-rule',
|
||||
rulePattern: '@scope ([data-impeccable-variant="N"]) { :scope > .variant-class { ... } }',
|
||||
selectorExamples: variantNumbers.map((n) => `@scope ([data-impeccable-variant="${n}"]) { :scope > .variant-class { ... } }`),
|
||||
requirements: [
|
||||
'Use @scope blocks keyed to each [data-impeccable-variant="N"] wrapper.',
|
||||
'Inside each @scope block, make :scope rules step into the replacement element with a descendant combinator.',
|
||||
'Use the styleTag exactly; do not add framework-specific style attributes unless this object says to.',
|
||||
],
|
||||
forbidden: [
|
||||
'Do not use global [data-impeccable-variant="N"] selector prefixes for this styleMode.',
|
||||
'Do not add is:inline to the style tag for this styleMode.',
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Search project files for the query string (class name, ID, etc.)
|
||||
* Returns the first matching file path, or null.
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
* CLI entry point: prepare everything needed to enter the live variant poll loop.
|
||||
*
|
||||
* Does (all in one command):
|
||||
* 1. Check config.json (returns config_missing if first-ever run)
|
||||
* 1. Check .impeccable/live/config.json (returns config_missing if first-ever run)
|
||||
* 2. Start the live server in the background (or reuse a running one)
|
||||
* 3. Inject the browser script tag into the project's entry file
|
||||
* 4. Read .impeccable.md for design context (if present)
|
||||
* 4. Read PRODUCT.md / DESIGN.md for project context
|
||||
* 5. Print a single JSON blob with everything the agent needs
|
||||
*
|
||||
* After this, the agent's only remaining steps are:
|
||||
@@ -23,9 +23,9 @@ import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { loadContext } from './load-context.mjs';
|
||||
import { resolveFiles } from './live-inject.mjs';
|
||||
import { readLiveServerInfo } from './impeccable-paths.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PID_FILE = path.join(process.cwd(), '.impeccable-live.json');
|
||||
|
||||
async function liveCli() {
|
||||
const args = process.argv.slice(2);
|
||||
@@ -34,10 +34,10 @@ async function liveCli() {
|
||||
console.log(`Usage: node live.mjs
|
||||
|
||||
Prepare everything for live variant mode in a single command:
|
||||
- Checks scripts/config.json (required, created once per project)
|
||||
- Checks .impeccable/live/config.json (required, created once per project)
|
||||
- Starts (or reuses) the live server in the background
|
||||
- Injects the browser script tag
|
||||
- Reads .impeccable.md for design context
|
||||
- Reads PRODUCT.md / DESIGN.md for project context
|
||||
|
||||
On success, prints a JSON blob with:
|
||||
{ ok, serverPort, serverToken, pageFile, hasContext, context }
|
||||
@@ -223,7 +223,7 @@ function safeParse(out) {
|
||||
function ensureServerRunning() {
|
||||
// Try to reuse an existing server
|
||||
try {
|
||||
const existing = JSON.parse(fs.readFileSync(PID_FILE, 'utf-8'));
|
||||
const existing = readLiveServerInfo(process.cwd())?.info;
|
||||
if (existing && existing.pid) {
|
||||
try {
|
||||
process.kill(existing.pid, 0); // throws if dead
|
||||
|
||||
@@ -39,7 +39,7 @@ const LEGACY_NAMES = ['.impeccable.md'];
|
||||
const FALLBACK_DIRS = ['.agents/context', 'docs'];
|
||||
|
||||
/**
|
||||
* Resolve the directory that holds PRODUCT.md / DESIGN.md / DESIGN.json for
|
||||
* Resolve the directory that holds PRODUCT.md / DESIGN.md for
|
||||
* this project. Exported so other scripts (e.g. live-server.mjs) can read the
|
||||
* design files from the same location the loader uses.
|
||||
*/
|
||||
|
||||
@@ -237,11 +237,11 @@ Concrete, forceful guardrails. Lead each with "Do" or "Don't". Be specific: incl
|
||||
- **Don't** [...]
|
||||
```
|
||||
|
||||
### Step 4b: Write DESIGN.json sidecar (extensions only)
|
||||
### Step 4b: Write .impeccable/design.json sidecar (extensions only)
|
||||
|
||||
The frontmatter owns token primitives (colors, typography, rounded, spacing, components). The sidecar at `DESIGN.json` carries **what Stitch's schema can't hold**: tonal ramps per color, shadow/elevation tokens, motion tokens, breakpoints, full component HTML/CSS snippets (the panel renders these into a shadow DOM), and narrative (north star, rules, do's/don'ts). It extends the frontmatter, it doesn't duplicate it.
|
||||
The frontmatter owns token primitives (colors, typography, rounded, spacing, components). The sidecar at `.impeccable/design.json` carries **what Stitch's schema can't hold**: tonal ramps per color, shadow/elevation tokens, motion tokens, breakpoints, full component HTML/CSS snippets (the panel renders these into a shadow DOM), and narrative (north star, rules, do's/don'ts). It extends the frontmatter, it doesn't duplicate it.
|
||||
|
||||
Regenerate the sidecar whenever you regenerate DESIGN.md. If the user only asks to refresh the sidecar (e.g., from the live panel's stale-hint), preserve DESIGN.md and write only DESIGN.json.
|
||||
Regenerate the sidecar whenever you regenerate root `DESIGN.md`. If the user only asks to refresh the sidecar (e.g., from the live panel's stale-hint), preserve `DESIGN.md` and write only `.impeccable/design.json`.
|
||||
|
||||
#### Schema
|
||||
|
||||
@@ -310,7 +310,7 @@ Aim for a tight set of **5-10 components** that best represent the visual system
|
||||
- **Signature components (include if distinctive):** hero CTA, featured card, filter pill, any custom pattern the user mentioned as important in PRODUCT.md.
|
||||
- **Skip the rest.** Utility components, form building blocks, wrapper layouts: not worth documenting unless visually distinctive.
|
||||
|
||||
If the project has **no component library yet** (bare landing page, new project), synthesize canonical primitives from the tokens using best-practice defaults consistent with the DESIGN.md's rules. Every DESIGN.json has *something* to render, even on day zero.
|
||||
If the project has **no component library yet** (bare landing page, new project), synthesize canonical primitives from the tokens using best-practice defaults consistent with the DESIGN.md's rules. Every `.impeccable/design.json` has *something* to render, even on day zero.
|
||||
|
||||
#### Tonal ramps
|
||||
|
||||
@@ -331,7 +331,7 @@ Do not reword. The panel shows these as secondary collapsible context; the same
|
||||
### Step 5: Confirm, refine, and refresh session cache
|
||||
|
||||
1. Show the user the full DESIGN.md you wrote. Briefly highlight the non-obvious creative choices (descriptive color names, atmosphere language, named rules).
|
||||
2. Mention that `DESIGN.json` was also written alongside; the live panel will now render this project's actual button/input/nav primitives instead of generic approximations.
|
||||
2. Mention that `.impeccable/design.json` was also written alongside; the live panel will now render this project's actual button/input/nav primitives instead of generic approximations.
|
||||
3. Offer to refine any section: "Want me to revise a section, add component patterns I missed, or adjust the atmosphere language?"
|
||||
4. **Refresh the session cache.** Run `node .cursor/skills/impeccable/scripts/load-context.mjs` one final time so the newly-written DESIGN.md lands in conversation. Subsequent commands in this session will use the fresh version automatically without re-reading.
|
||||
|
||||
@@ -392,7 +392,7 @@ Per-section guidance in seed mode:
|
||||
- **Components**: omit entirely; no components exist yet.
|
||||
- **Do's and Don'ts**: carry PRODUCT.md's anti-references directly plus the anti-reference named in Q5.
|
||||
|
||||
Seed mode writes a minimal frontmatter with `name` and `description` only; no colors, typography, rounded, spacing, or components yet. Real tokens land on the next Scan-mode run. Skip the `DESIGN.json` sidecar in seed mode for the same reason: nothing to render.
|
||||
Seed mode writes a minimal frontmatter with `name` and `description` only; no colors, typography, rounded, spacing, or components yet. Real tokens land on the next Scan-mode run. Skip the `.impeccable/design.json` sidecar in seed mode for the same reason: nothing to render.
|
||||
|
||||
### Step 4: Confirm and refresh session cache
|
||||
|
||||
|
||||
@@ -12,8 +12,9 @@ Execute in order. No step skipped, no step reordered.
|
||||
2. Navigate to the URL that serves `pageFile` (infer from `package.json`, docs, terminal output, or an open tab). If you can't infer it confidently, tell the user once to open their dev/preview URL. Never use `serverPort` as that URL; it's the helper, not the app.
|
||||
3. Poll loop with the default long timeout (600000 ms). After every event or `--reply`, run `live-poll.mjs` again immediately. Never pass a short `--timeout=`.
|
||||
4. On `generate`: read screenshot if present; load the action's reference; plan three distinct directions; write all variants in one edit; `--reply done`; poll again.
|
||||
5. On `accept` / `discard`: the poll script already cleaned up; just poll again.
|
||||
6. On `exit`: run the cleanup at the bottom.
|
||||
5. On `accept` / `discard`: the poll script runs `live-accept.mjs`, acknowledges the delivered event, and prints `_completionAck`. Plain accepts/discards are terminal immediately; carbonize accepts remain recoverable until you finish cleanup, run `live-complete.mjs --id EVENT_ID`, and only then poll again.
|
||||
6. If interrupted, run `live-status.mjs` or `live-resume.mjs` before guessing. The durable journal replays unacknowledged work after helper restart.
|
||||
7. On `exit`: run the cleanup at the bottom.
|
||||
|
||||
Harness policy:
|
||||
- **Claude Code**: run the poll as a **background task** (no short timeout). The harness notifies you when it completes, so the main conversation stays free. Do not block the shell.
|
||||
@@ -43,13 +44,31 @@ LOOP:
|
||||
Read JSON; dispatch on "type"
|
||||
|
||||
"generate" → Handle Generate; reply done; LOOP
|
||||
"accept" → Handle Accept; LOOP
|
||||
"accept" → Handle Accept; complete carbonize cleanup if required; LOOP
|
||||
"discard" → Handle Discard; LOOP
|
||||
"prefetch" → Handle Prefetch; LOOP
|
||||
"timeout" → LOOP
|
||||
"exit" → break → Cleanup
|
||||
```
|
||||
|
||||
## Recovery commands
|
||||
|
||||
The live helper persists an append-only journal under `.impeccable/live/sessions/`. Browser checkpoints are advisory but durable; the journal is canonical. This is local durable recovery state, not project source.
|
||||
|
||||
Use these commands when the chat was interrupted, polling was missed, the helper restarted, or the browser reloaded:
|
||||
|
||||
```bash
|
||||
node .cursor/skills/impeccable/scripts/live-status.mjs
|
||||
node .cursor/skills/impeccable/scripts/live-resume.mjs --id SESSION_ID
|
||||
node .cursor/skills/impeccable/scripts/live-complete.mjs --id SESSION_ID
|
||||
```
|
||||
|
||||
- `live-status.mjs` prints connected helper state, active durable sessions, and queued pending events. It works even when the helper is down by reading the journal directly.
|
||||
- `live-resume.mjs` prints the active snapshot, pending event, checkpoint phase, visible variant, parameter values, and the next safe agent action.
|
||||
- `live-complete.mjs` is the canonical manual final acknowledgement. Use it after carbonize/manual cleanup is verified and no further poll acknowledgement will happen automatically.
|
||||
|
||||
Server restart rule: start `live-server.mjs` again, then poll. Startup requeues unacknowledged pending events from the journal, so do not ask the user to click Go again unless `live-resume.mjs` says no active session exists.
|
||||
|
||||
## Handle `generate`
|
||||
|
||||
Event: `{id, action, freeformPrompt?, count, pageUrl, element, screenshotPath?, comments?, strokes?}`.
|
||||
@@ -88,7 +107,14 @@ The helper searches ID first, then classes, then tag + class combo. If `event.pa
|
||||
|
||||
If `--text` matches multiple candidates equally well, wrap exits with `{ error: "element_ambiguous", candidates: [...] }` and `fallback: "agent-driven"`: read the candidate line ranges, decide which one matches the picked element from page context, and write the wrapper manually per the fallback flow.
|
||||
|
||||
Output on success: `{ file, insertLine, commentSyntax }`.
|
||||
Output on success: `{ file, insertLine, commentSyntax, styleMode, styleTag, cssSelectorPrefixExamples, cssAuthoring }`.
|
||||
|
||||
`styleMode` controls how preview CSS must be authored. Treat it as a detected capability mode, not a framework guess:
|
||||
|
||||
- `scoped`: use `@scope ([data-impeccable-variant="N"])` rules.
|
||||
- `astro-global-prefixed`: use explicit `[data-impeccable-variant="N"]` selector prefixes and the exact `styleTag` returned by the tool.
|
||||
|
||||
Use `cssAuthoring` as the source of truth for the current file. It includes the exact `styleTag`, selector strategy, selector examples, requirements, and forbidden patterns. Do not apply a framework-specific exception unless the returned `styleMode` / `cssAuthoring.mode` says to.
|
||||
|
||||
**Fallback errors.** Wrap only writes into files it judges to be source (tracked by git, not marked GENERATED, not listed in config's `generatedFiles`). If it can't land on a source file, it errors without writing; accepting a variant into a generated file is silent data loss. Three shapes:
|
||||
|
||||
@@ -208,13 +234,14 @@ When the prompt and PRODUCT.md anti-references conflict (the prompt asks for X,
|
||||
|
||||
Complete HTML replacement of the original element for each variant, not a CSS-only patch. Consider the element's context (computed styles, parent structure, CSS variables from `event.element`).
|
||||
|
||||
Write CSS + all variants in ONE edit at the `insertLine` reported by `wrap`. Colocate scoped CSS as a `<style>` tag inside the variant wrapper; `<style>` works anywhere in modern browsers and this ensures CSS and HTML arrive atomically (no FOUC).
|
||||
Write CSS + all variants in ONE edit at the `insertLine` reported by `wrap`. Colocate CSS as a `<style>` tag inside the variant wrapper; `<style>` works anywhere in modern browsers and this ensures CSS and HTML arrive atomically (no FOUC).
|
||||
|
||||
Use the `cssAuthoring` object returned by `live-wrap.mjs` to author the temporary preview CSS. The style opening tag shown below is the common case; replace it with `cssAuthoring.styleTag` when the tool returns a different one. The variant markup shape is otherwise stable:
|
||||
|
||||
```html
|
||||
<!-- Variants: insert below this line -->
|
||||
<style data-impeccable-css="SESSION_ID">
|
||||
@scope ([data-impeccable-variant="1"]) { ... }
|
||||
@scope ([data-impeccable-variant="2"]) { ... }
|
||||
/* rules matching cssAuthoring.rulePattern */
|
||||
</style>
|
||||
<div data-impeccable-variant="1">
|
||||
<!-- variant 1: full element replacement (single top-level element) -->
|
||||
@@ -229,11 +256,11 @@ Write CSS + all variants in ONE edit at the `insertLine` reported by `wrap`. Col
|
||||
|
||||
**Each variant div contains exactly one top-level element: the full replacement for the original.** Use the same tag as the original (e.g. `<section>` if the user picked a `<section>`). 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 `<style>` tag entirely. Use `@scope` for CSS isolation (Chrome 118+ / Firefox 128+ / Safari 17.4+).
|
||||
The first variant has no `display: none` (visible by default). All others do. If variants use only inline styles and no preview CSS, omit the `<style>` tag entirely.
|
||||
|
||||
One edit, all variants; the browser's MutationObserver picks everything up in one pass.
|
||||
|
||||
**Author every `:scope` rule with a descendant combinator.** The `@scope` boundary is the **variant wrapper `<div data-impeccable-variant="N">`**, not the element you're designing. A bare `:scope { background: cream; }` styles the wrapper, not the inner replacement, so the cream lands on a `display: contents` shell while the actual element keeps page defaults. Always step in: `:scope > .card`, `:scope > section`, `:scope .hero-title`, etc. The fake test agent's CSS in `tests/live-e2e/agent.mjs` is a faithful template; every rule starts `:scope > ...`.
|
||||
For `styleMode: "scoped"`, author every `:scope` rule with a descendant combinator. The `@scope` boundary is the **variant wrapper `<div data-impeccable-variant="N">`**, not the element you're designing. A bare `:scope { background: cream; }` styles the wrapper, not the inner replacement, so the cream lands on a `display: contents` shell while the actual element keeps page defaults. Always step in: `:scope > .card`, `:scope > section`, `:scope .hero-title`, etc. The fake test agent's CSS in `tests/live-e2e/agent.mjs` is a faithful template; every scoped rule starts `:scope > ...`.
|
||||
|
||||
**JSX / TSX target files.** Wrap `<style>` content in a template literal so the CSS `{` / `}` aren't parsed as JSX expressions, and use `className=` / `style={{…}}` on every variant element. Keep `data-impeccable-*` attributes as-is; they're plain strings:
|
||||
|
||||
@@ -369,10 +396,11 @@ Remove the wrapper you inserted in Step 2. Nothing else to do.
|
||||
|
||||
## Handle `accept`
|
||||
|
||||
Event: `{id, variantId, _acceptResult}`. The poll script already ran `live-accept.mjs` to handle the file operation deterministically; the browser DOM is already updated.
|
||||
Event: `{id, variantId, _acceptResult, _completionAck}`. The poll script already ran `live-accept.mjs` to handle the file operation deterministically, then acknowledged event delivery to the helper. The browser DOM is already updated.
|
||||
|
||||
- `_completionAck.ok !== true`: do not poll yet. Run `live-status.mjs` / `live-resume.mjs`, complete the cleanup manually if needed, then run `live-complete.mjs --id EVENT_ID`.
|
||||
- `_acceptResult.handled: true` and `carbonize: false`: nothing to do. Poll again.
|
||||
- `_acceptResult.handled: true` and `carbonize: true`: **post-accept cleanup is required before the next poll.** See the "Required after accept (carbonize)" section below. The `event._acceptResult.todo` field and a stderr banner both list the steps explicitly; neither is decorative.
|
||||
- `_acceptResult.handled: true` and `carbonize: true`: **post-accept cleanup is required before the next poll.** See the "Required after accept (carbonize)" section below. The `event._acceptResult.todo` field, `_completionAck.requiresComplete`, and a stderr banner all point at this required follow-up; none are decorative. After cleanup, run `live-complete.mjs --id EVENT_ID`, then poll again.
|
||||
- `_acceptResult.handled: false, mode: "fallback"`: the session lived in a generated file and the script refused to persist there. You've already written the accepted variant into true source during Handle fallback Step 3; just clean up the temporary wrapper in the served file if any, and poll again.
|
||||
- `_acceptResult.handled: false` without `mode`: manual cleanup: read file, find markers, edit.
|
||||
|
||||
@@ -388,13 +416,13 @@ Do these five steps in the current thread, synchronously, before the next poll.
|
||||
4. **Unwrap the accepted content.** Delete the `<div data-impeccable-variant="N" style="display: contents">` that wraps it. Drop `data-impeccable-params` and any `data-p-*` attributes from it; those are live-mode plumbing, not source.
|
||||
5. **Delete the inline `<style>` block, the `<!-- impeccable-param-values -->` comment if present, and both `<!-- impeccable-carbonize-start/end -->` markers.** Also drop any `@scope` rules for variants other than the accepted one; those are dead code now.
|
||||
|
||||
Then poll again.
|
||||
After the file is clean, run `live-complete.mjs --id SESSION_ID`, verify it reports `phase: "completed"`, then poll again.
|
||||
|
||||
A background agent may be used for the rewrite, but the current thread is responsible for verifying the five steps are complete before issuing the next poll. In practice, inline is usually faster and less error-prone.
|
||||
|
||||
## Handle `discard`
|
||||
|
||||
Event: `{id, _acceptResult}`. The poll script already restored the original and removed all variant markers. Nothing to do. Poll again.
|
||||
Event: `{id, _acceptResult, _completionAck}`. The poll script already restored the original, removed all variant markers, and acknowledged `discarded` durable completion. Nothing to do unless `_completionAck.ok !== true`; in that case run `live-complete.mjs --id EVENT_ID --discarded`, then poll again.
|
||||
|
||||
## Handle `prefetch`
|
||||
|
||||
@@ -424,7 +452,7 @@ When the poll returns `exit`, proceed to cleanup. If the poll is still running a
|
||||
node .cursor/skills/impeccable/scripts/live-server.mjs stop
|
||||
```
|
||||
|
||||
Stops the HTTP server and runs `live-inject.mjs --remove` to strip `localhost:…/live.js` from the HTML entry. To stop the server but keep the inject tag (for a quick restart), use `stop --keep-inject`. `config.json` persists for future sessions.
|
||||
Stops the HTTP server and runs `live-inject.mjs --remove` to strip `localhost:…/live.js` from the HTML entry. To stop the server but keep the inject tag (for a quick restart), use `stop --keep-inject`. `.impeccable/live/config.json` persists as project config for future sessions.
|
||||
|
||||
Then:
|
||||
- Remove any leftover variant wrappers (search for `impeccable-variants-start` markers).
|
||||
@@ -432,7 +460,7 @@ Then:
|
||||
|
||||
## First-time setup (config missing or invalid)
|
||||
|
||||
If `live.mjs` outputs `{ ok: false, error: "config_missing" | "config_invalid", path }`, write `config.json` at the reported path.
|
||||
If `live.mjs` outputs `{ ok: false, error: "config_missing" | "config_invalid", path }`, write the live config at the reported path. By default this is `.impeccable/live/config.json`.
|
||||
|
||||
Schema:
|
||||
|
||||
@@ -512,7 +540,7 @@ node .cursor/skills/impeccable/scripts/detect-csp.mjs
|
||||
|
||||
Output: `{ shape, signals }` where `shape` is one of `append-arrays`, `append-string`, `middleware`, `meta-tag`, or `null`. The shape is named by *patch mechanism*, so one template covers many frameworks.
|
||||
|
||||
- **`null`**: no CSP; skip to writing `config.json` with `cspChecked: true`.
|
||||
- **`null`**: no CSP; skip to writing `.impeccable/live/config.json` with `cspChecked: true`.
|
||||
- **`append-arrays`**: CSP defined as structured directive arrays. Auto-patchable. See *append-arrays* below. Covers:
|
||||
- Monorepo helpers with `additionalScriptSrc` / `additionalConnectSrc` options (Next.js + shared config package)
|
||||
- SvelteKit `kit.csp.directives`
|
||||
@@ -589,6 +617,6 @@ Reference outputs:
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
If a user says "no" to the CSP patch at setup time and later complains that live doesn't work: their dev CSP blocks `http://localhost:8400`. Fix: delete `cspChecked` from `config.json` and re-run `live.mjs`: setup will ask again.
|
||||
If a user says "no" to the CSP patch at setup time and later complains that live doesn't work: their dev CSP blocks `http://localhost:8400`. Fix: delete `cspChecked` from `.impeccable/live/config.json` and re-run `live.mjs`: setup will ask again.
|
||||
|
||||
Then re-run `live.mjs`.
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
Gathers design context for a project and writes two complementary files at the project root:
|
||||
|
||||
- **PRODUCT.md** (strategic): register, target users, product purpose, brand personality, anti-references, strategic design principles. Answers "who/what/why".
|
||||
- **DESIGN.md** (visual): visual theme, color palette, typography, components, layout. Follows the [Google Stitch DESIGN.md format](https://stitch.withgoogle.com/docs/design-md/format/). Answers "how it looks".
|
||||
- **PRODUCT.md** (strategic): root project file for register, target users, product purpose, brand personality, anti-references, strategic design principles. Answers "who/what/why".
|
||||
- **DESIGN.md** (visual): root project file for visual theme, color palette, typography, components, layout. Follows the [Google Stitch DESIGN.md format](https://stitch.withgoogle.com/docs/design-md/format/). Answers "how it looks".
|
||||
|
||||
Every other impeccable command reads these files before doing any work.
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
export const IMPECCABLE_DIR = '.impeccable';
|
||||
export const LIVE_DIR = 'live';
|
||||
|
||||
export function getImpeccableDir(cwd = process.cwd()) {
|
||||
return path.join(cwd, IMPECCABLE_DIR);
|
||||
}
|
||||
|
||||
export function getDesignSidecarPath(cwd = process.cwd()) {
|
||||
return path.join(getImpeccableDir(cwd), 'design.json');
|
||||
}
|
||||
|
||||
export function getDesignSidecarCandidates(cwd = process.cwd(), contextDir = cwd) {
|
||||
const candidates = [
|
||||
getDesignSidecarPath(cwd),
|
||||
path.join(cwd, 'DESIGN.json'),
|
||||
];
|
||||
const contextLegacy = path.join(contextDir, 'DESIGN.json');
|
||||
if (!candidates.includes(contextLegacy)) candidates.push(contextLegacy);
|
||||
return candidates;
|
||||
}
|
||||
|
||||
export function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd) {
|
||||
return firstExisting(getDesignSidecarCandidates(cwd, contextDir));
|
||||
}
|
||||
|
||||
export function getLiveDir(cwd = process.cwd()) {
|
||||
return path.join(getImpeccableDir(cwd), LIVE_DIR);
|
||||
}
|
||||
|
||||
export function getLiveConfigPath(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'config.json');
|
||||
}
|
||||
|
||||
export function getLegacyLiveConfigPath(scriptsDir) {
|
||||
return path.join(scriptsDir, 'config.json');
|
||||
}
|
||||
|
||||
export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = process.env } = {}) {
|
||||
if (env.IMPECCABLE_LIVE_CONFIG && env.IMPECCABLE_LIVE_CONFIG.trim()) {
|
||||
const configured = env.IMPECCABLE_LIVE_CONFIG.trim();
|
||||
return path.isAbsolute(configured) ? configured : path.resolve(cwd, configured);
|
||||
}
|
||||
const primary = getLiveConfigPath(cwd);
|
||||
if (fs.existsSync(primary)) return primary;
|
||||
if (scriptsDir) {
|
||||
const legacy = getLegacyLiveConfigPath(scriptsDir);
|
||||
if (fs.existsSync(legacy)) return legacy;
|
||||
}
|
||||
return primary;
|
||||
}
|
||||
|
||||
export function getLiveServerPath(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'server.json');
|
||||
}
|
||||
|
||||
export function getLegacyLiveServerPath(cwd = process.cwd()) {
|
||||
return path.join(cwd, '.impeccable-live.json');
|
||||
}
|
||||
|
||||
export function readLiveServerInfo(cwd = process.cwd()) {
|
||||
for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) {
|
||||
try {
|
||||
return { info: JSON.parse(fs.readFileSync(filePath, 'utf-8')), path: filePath };
|
||||
} catch {
|
||||
/* try next */
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function writeLiveServerInfo(cwd = process.cwd(), info) {
|
||||
const filePath = getLiveServerPath(cwd);
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, JSON.stringify(info));
|
||||
return filePath;
|
||||
}
|
||||
|
||||
export function removeLiveServerInfo(cwd = process.cwd()) {
|
||||
for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) {
|
||||
try { fs.unlinkSync(filePath); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
export function getLiveSessionsDir(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'sessions');
|
||||
}
|
||||
|
||||
export function getLegacyLiveSessionsDir(cwd = process.cwd()) {
|
||||
return path.join(cwd, '.impeccable-live', 'sessions');
|
||||
}
|
||||
|
||||
export function getLiveAnnotationsDir(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'annotations');
|
||||
}
|
||||
|
||||
export function getLegacyLiveAnnotationsDir(cwd = process.cwd()) {
|
||||
return path.join(cwd, '.impeccable-live', 'annotations');
|
||||
}
|
||||
|
||||
function firstExisting(paths) {
|
||||
return paths.find((filePath) => fs.existsSync(filePath)) || null;
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Browser-side durable session helpers for Impeccable live mode.
|
||||
*
|
||||
* Kept separate from live-browser.js so recovery state can be tested without
|
||||
* booting the full overlay UI. Served before live-browser.js and attached to
|
||||
* window.__IMPECCABLE_LIVE_SESSION__.
|
||||
*/
|
||||
(function (root) {
|
||||
'use strict';
|
||||
|
||||
function createLiveBrowserSessionState({ prefix, storage, idFactory }) {
|
||||
if (!prefix) throw new Error('prefix required');
|
||||
const store = storage || root.localStorage;
|
||||
const makeId = idFactory || function () { return Math.random().toString(16).slice(2, 10); };
|
||||
const sessionKey = prefix + '-session';
|
||||
const handledKey = sessionKey + '-handled';
|
||||
const scrollKey = sessionKey + '-scroll';
|
||||
let checkpointRevision = 0;
|
||||
const owner = makeId();
|
||||
|
||||
function safeRead(key) {
|
||||
try { return store.getItem(key); } catch { return null; }
|
||||
}
|
||||
|
||||
function safeWrite(key, value) {
|
||||
try { store.setItem(key, value); } catch { /* quota exceeded or private mode */ }
|
||||
}
|
||||
|
||||
function safeRemove(key) {
|
||||
try { store.removeItem(key); } catch { /* unavailable storage */ }
|
||||
}
|
||||
|
||||
function loadSession() {
|
||||
try {
|
||||
const raw = safeRead(sessionKey);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Number.isInteger(parsed.checkpointRevision)) {
|
||||
checkpointRevision = Math.max(checkpointRevision, parsed.checkpointRevision);
|
||||
}
|
||||
return parsed;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
function saveSession(session) {
|
||||
if (!session || !session.id) return;
|
||||
const payload = {
|
||||
...session,
|
||||
checkpointRevision,
|
||||
};
|
||||
safeWrite(sessionKey, JSON.stringify(payload));
|
||||
}
|
||||
|
||||
function clearSession() {
|
||||
safeRemove(sessionKey);
|
||||
}
|
||||
|
||||
function nextCheckpointRevision() {
|
||||
checkpointRevision += 1;
|
||||
const existing = loadSession();
|
||||
if (existing?.id) saveSession(existing);
|
||||
return checkpointRevision;
|
||||
}
|
||||
|
||||
function seedCheckpointRevision(value) {
|
||||
if (Number.isInteger(value)) checkpointRevision = Math.max(checkpointRevision, value);
|
||||
return checkpointRevision;
|
||||
}
|
||||
|
||||
function currentCheckpointRevision() {
|
||||
return checkpointRevision;
|
||||
}
|
||||
|
||||
function markHandled(id) {
|
||||
if (!id) return;
|
||||
safeWrite(handledKey, id);
|
||||
}
|
||||
|
||||
function isHandled(id) {
|
||||
return !!id && safeRead(handledKey) === id;
|
||||
}
|
||||
|
||||
function clearHandled() {
|
||||
safeRemove(handledKey);
|
||||
}
|
||||
|
||||
function writeScrollY(y) {
|
||||
safeWrite(scrollKey, String(y));
|
||||
}
|
||||
|
||||
function readScrollY() {
|
||||
const raw = safeRead(scrollKey);
|
||||
if (raw == null) return null;
|
||||
const n = parseFloat(raw);
|
||||
return isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
function clearScrollY() {
|
||||
safeRemove(scrollKey);
|
||||
}
|
||||
|
||||
return {
|
||||
owner,
|
||||
sessionKey,
|
||||
handledKey,
|
||||
scrollKey,
|
||||
saveSession,
|
||||
loadSession,
|
||||
clearSession,
|
||||
nextCheckpointRevision,
|
||||
seedCheckpointRevision,
|
||||
currentCheckpointRevision,
|
||||
markHandled,
|
||||
isHandled,
|
||||
clearHandled,
|
||||
writeScrollY,
|
||||
readScrollY,
|
||||
clearScrollY,
|
||||
};
|
||||
}
|
||||
|
||||
root.__IMPECCABLE_LIVE_SESSION__ = { createLiveBrowserSessionState };
|
||||
})(typeof window !== 'undefined' ? window : globalThis);
|
||||
@@ -50,6 +50,16 @@
|
||||
const Z = { highlight: 100001, bar: 100005, picker: 100007, toast: 100010 };
|
||||
const EASE = 'cubic-bezier(0.22, 1, 0.36, 1)'; // ease-out-quint
|
||||
const PREFIX = 'impeccable-live';
|
||||
const sessionState = window.__IMPECCABLE_LIVE_SESSION__?.createLiveBrowserSessionState({
|
||||
prefix: PREFIX,
|
||||
storage: localStorage,
|
||||
idFactory: () => crypto.randomUUID().replace(/-/g, '').slice(0, 8),
|
||||
});
|
||||
if (!sessionState) {
|
||||
console.error('[impeccable] live-browser-session.js was not loaded. Live mode cannot start safely.');
|
||||
window.__IMPECCABLE_LIVE_INIT__ = false;
|
||||
return;
|
||||
}
|
||||
const HIGHLIGHT_TRANSITION =
|
||||
'top 140ms ' + EASE +
|
||||
', left 140ms ' + EASE +
|
||||
@@ -112,6 +122,8 @@
|
||||
let hasProjectContext = false;
|
||||
let selectedAction = 'impeccable';
|
||||
let selectedCount = 3;
|
||||
const browserOwner = sessionState.owner;
|
||||
let checkpointTimer = null;
|
||||
|
||||
// Scroll lock — holds window.scrollY at a fixed value while the session is
|
||||
// active, so HMR DOM patches and variant swaps can't drift the page. See
|
||||
@@ -126,21 +138,9 @@
|
||||
// (Previously: saveSession wrote scrollY alongside state, so every call
|
||||
// during resume overwrote the pre-reload value with whatever the browser
|
||||
// had landed on, typically 0.)
|
||||
const SCROLL_KEY_SUFFIX = '-scroll';
|
||||
function writeScrollY(y) {
|
||||
try { localStorage.setItem(LS_KEY + SCROLL_KEY_SUFFIX, String(y)); } catch {}
|
||||
}
|
||||
function readScrollY() {
|
||||
try {
|
||||
const raw = localStorage.getItem(LS_KEY + SCROLL_KEY_SUFFIX);
|
||||
if (raw == null) return null;
|
||||
const n = parseFloat(raw);
|
||||
return isFinite(n) ? n : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
function clearScrollY() {
|
||||
try { localStorage.removeItem(LS_KEY + SCROLL_KEY_SUFFIX); } catch {}
|
||||
}
|
||||
function writeScrollY(y) { sessionState.writeScrollY(y); }
|
||||
function readScrollY() { return sessionState.readScrollY(); }
|
||||
function clearScrollY() { sessionState.clearScrollY(); }
|
||||
|
||||
// Pre-empt the browser: apply manual scroll restoration and jump to the
|
||||
// saved scrollY at script-parse time. Retries on fonts.ready and load
|
||||
@@ -1585,6 +1585,7 @@
|
||||
paramsCurrentValues[p.id] = v;
|
||||
readout.textContent = formatRangeValue(input);
|
||||
applyParamValue(variantEl, p, v);
|
||||
queueCheckpoint('param_changed');
|
||||
});
|
||||
row.appendChild(input);
|
||||
} else if (p.kind === 'toggle') {
|
||||
@@ -1615,6 +1616,7 @@
|
||||
knob.style.left = next ? '18px' : '2px';
|
||||
readout.textContent = next ? 'On' : 'Off';
|
||||
applyParamValue(variantEl, p, next);
|
||||
queueCheckpoint('param_changed');
|
||||
});
|
||||
row.appendChild(track);
|
||||
} else if (p.kind === 'steps') {
|
||||
@@ -1651,6 +1653,7 @@
|
||||
btn.style.color = on ? 'oklch(98% 0 0)' : P.text;
|
||||
});
|
||||
applyParamValue(variantEl, p, o.value);
|
||||
queueCheckpoint('param_changed');
|
||||
});
|
||||
segRow.appendChild(b);
|
||||
segBtns.push({ btn: b, val: o.value });
|
||||
@@ -1872,19 +1875,26 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const previousVisibleVariant = currentSessionId === sessionId ? visibleVariant : 0;
|
||||
|
||||
// Replace the live element with the full wrapper from source
|
||||
const wrapper = srcWrapper.cloneNode(true);
|
||||
liveEl.parentElement.replaceChild(wrapper, liveEl);
|
||||
|
||||
// Update state: count variants, show the first one
|
||||
// Update state: count variants, preserving the user's current variant
|
||||
// when a late HMR/source reinjection lands after they have cycled.
|
||||
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
|
||||
arrivedVariants = variants.length;
|
||||
expectedVariants = parseInt(wrapper.dataset.impeccableVariantCount || arrivedVariants);
|
||||
visibleVariant = 1;
|
||||
showVariantInDOM(sessionId, 1);
|
||||
const saved = loadSession();
|
||||
const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0;
|
||||
visibleVariant = previousVisibleVariant > 0 && previousVisibleVariant <= arrivedVariants
|
||||
? previousVisibleVariant
|
||||
: (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1);
|
||||
showVariantInDOM(sessionId, visibleVariant);
|
||||
|
||||
// Update selectedElement to the visible variant's content
|
||||
selectedElement = pickVariantContent(wrapper, 1) || wrapper.parentElement;
|
||||
selectedElement = pickVariantContent(wrapper, visibleVariant) || wrapper.parentElement;
|
||||
|
||||
state = 'CYCLING';
|
||||
hideShaderOverlay();
|
||||
@@ -1907,6 +1917,7 @@
|
||||
updateSelectedElement();
|
||||
updateBarContent('cycling');
|
||||
saveSession();
|
||||
queueCheckpoint('variant_changed');
|
||||
}
|
||||
|
||||
function updateSelectedElement() {
|
||||
@@ -1917,6 +1928,18 @@
|
||||
if (visEl) selectedElement = visEl;
|
||||
}
|
||||
|
||||
function readVisibleVariantFromDOM(sessionId) {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return 0;
|
||||
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
|
||||
for (const variant of variants) {
|
||||
if (variant.style.display === 'none') continue;
|
||||
const idx = parseInt(variant.dataset.impeccableVariant || '0', 10);
|
||||
if (idx > 0) return idx;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Resolve the element that represents the variant's visible content.
|
||||
// Contract: each variant div should contain exactly one top-level element
|
||||
// (the full replacement). In practice a model may ship loose siblings or
|
||||
@@ -2107,8 +2130,10 @@
|
||||
updating = true;
|
||||
arrivedVariants = count;
|
||||
if (visibleVariant === 0 && arrivedVariants > 0) {
|
||||
visibleVariant = 1;
|
||||
showVariantInDOM(sessionId, 1);
|
||||
const saved = loadSession();
|
||||
const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0;
|
||||
visibleVariant = savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1;
|
||||
showVariantInDOM(sessionId, visibleVariant);
|
||||
// showVariantInDOM hid the original (display:none); if we were still
|
||||
// anchored to the original's content, its boundingRect is now zero
|
||||
// and the bar snaps to (0,0). Re-point at the visible variant instead.
|
||||
@@ -2128,6 +2153,7 @@
|
||||
updateBarContent('generating');
|
||||
}
|
||||
saveSession();
|
||||
queueCheckpoint(state === 'CYCLING' ? 'variants_ready' : 'variants_progress');
|
||||
updating = false;
|
||||
});
|
||||
|
||||
@@ -2236,6 +2262,7 @@
|
||||
|
||||
/** Server died or became unreachable. Reset UI to a clean state. */
|
||||
function handleServerLost() {
|
||||
const recoveryState = currentSessionId ? state : 'IDLE';
|
||||
if (state === 'GENERATING' || state === 'CYCLING' || state === 'SAVING') {
|
||||
showToast('Live server disconnected. Session ended.', 5000);
|
||||
}
|
||||
@@ -2246,21 +2273,61 @@
|
||||
stopScrollTracking();
|
||||
if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
|
||||
stopScrollLock();
|
||||
clearScrollY();
|
||||
clearSession();
|
||||
// Preserve local session state on server loss. The durable journal is the
|
||||
// source of truth, but localStorage plus the variant wrapper lets the UI
|
||||
// resume after a helper restart or page reload instead of treating a
|
||||
// transient disconnect as an explicit discard.
|
||||
selectedElement = null;
|
||||
currentSessionId = null;
|
||||
selectedAction = 'impeccable';
|
||||
state = 'IDLE';
|
||||
state = recoveryState;
|
||||
if (currentSessionId) saveSession();
|
||||
}
|
||||
|
||||
function sendEvent(msg) {
|
||||
function sendEvent(msg, opts) {
|
||||
msg.token = TOKEN;
|
||||
fetch('http://localhost:' + PORT + '/events', {
|
||||
function handleFailure(err) {
|
||||
console.error('[impeccable] Failed to send event:', err);
|
||||
if (opts && opts.throwOnError) throw err;
|
||||
return null;
|
||||
}
|
||||
return fetch('http://localhost:' + PORT + '/events', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(msg),
|
||||
}).catch(err => console.error('[impeccable] Failed to send event:', err));
|
||||
}).then(res => {
|
||||
if (res.ok) return res;
|
||||
return handleFailure(new Error('HTTP ' + res.status + ' ' + res.statusText));
|
||||
}).catch(handleFailure);
|
||||
}
|
||||
|
||||
function checkpointPayload(reason) {
|
||||
return {
|
||||
type: 'checkpoint',
|
||||
id: currentSessionId,
|
||||
revision: sessionState.nextCheckpointRevision(),
|
||||
owner: browserOwner,
|
||||
phase: String(state || '').toLowerCase(),
|
||||
reason,
|
||||
pageUrl: location.pathname,
|
||||
expectedVariants,
|
||||
arrivedVariants,
|
||||
visibleVariant,
|
||||
paramValues: { ...paramsCurrentValues },
|
||||
};
|
||||
}
|
||||
|
||||
function sendCheckpoint(reason) {
|
||||
if (!currentSessionId) return Promise.resolve(null);
|
||||
return sendEvent(checkpointPayload(reason)).catch(() => null);
|
||||
}
|
||||
|
||||
function queueCheckpoint(reason) {
|
||||
if (!currentSessionId) return;
|
||||
if (checkpointTimer) clearTimeout(checkpointTimer);
|
||||
checkpointTimer = setTimeout(() => {
|
||||
checkpointTimer = null;
|
||||
sendCheckpoint(reason);
|
||||
}, 120);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -2496,6 +2563,7 @@
|
||||
state = 'GENERATING';
|
||||
showBar('generating');
|
||||
saveSession();
|
||||
sendCheckpoint('generate_started');
|
||||
writeScrollY(window.scrollY);
|
||||
if (variantObserver) variantObserver.disconnect();
|
||||
variantObserver = startVariantObserver(currentSessionId);
|
||||
@@ -2920,13 +2988,12 @@ void main() {
|
||||
|
||||
function handleAccept() {
|
||||
if (!currentSessionId || arrivedVariants === 0) return;
|
||||
const domVisibleVariant = readVisibleVariantFromDOM(currentSessionId);
|
||||
if (domVisibleVariant > 0) visibleVariant = domVisibleVariant;
|
||||
const acceptPayload = { type: 'accept', id: currentSessionId, variantId: String(visibleVariant) };
|
||||
if (Object.keys(paramsCurrentValues).length > 0) {
|
||||
acceptPayload.paramValues = { ...paramsCurrentValues };
|
||||
}
|
||||
sendEvent(acceptPayload);
|
||||
markSessionHandled();
|
||||
|
||||
// The accepted variant is already the only visible child of the wrapper
|
||||
// (all other variants are display:none). HMR from the source rewrite will
|
||||
// replace the wrapper imminently. Don't eagerly replaceChild here — React
|
||||
@@ -2936,9 +3003,28 @@ void main() {
|
||||
const acceptedSessionId = currentSessionId;
|
||||
const acceptedVariant = visibleVariant;
|
||||
|
||||
state = 'CONFIRMED';
|
||||
updateBarContent('confirmed');
|
||||
setTimeout(function() {
|
||||
state = 'SAVING';
|
||||
updateBarContent('saving');
|
||||
|
||||
sendEvent(acceptPayload, { throwOnError: true })
|
||||
.then(() => {
|
||||
markSessionHandled();
|
||||
confirmAcceptAfterReceipt();
|
||||
})
|
||||
.catch(() => {
|
||||
state = 'CYCLING';
|
||||
updateBarContent('cycling');
|
||||
showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000);
|
||||
});
|
||||
|
||||
function confirmAcceptAfterReceipt() {
|
||||
state = 'CONFIRMED';
|
||||
updateBarContent('confirmed');
|
||||
scheduleAcceptCleanup();
|
||||
}
|
||||
|
||||
function scheduleAcceptCleanup() {
|
||||
setTimeout(function() {
|
||||
hideBar();
|
||||
hideHighlight();
|
||||
stopScrollTracking();
|
||||
@@ -2967,50 +3053,46 @@ void main() {
|
||||
accepted.style.display = 'contents';
|
||||
parent.replaceChild(accepted, wrapper);
|
||||
}
|
||||
}, 2000);
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDiscard() {
|
||||
if (!currentSessionId) return;
|
||||
sendEvent({ type: 'discard', id: currentSessionId });
|
||||
markSessionHandled();
|
||||
// Instant DOM restore + fire-and-forget (script handles file cleanup)
|
||||
cleanup();
|
||||
sendEvent({ type: 'discard', id: currentSessionId }, { throwOnError: true })
|
||||
.then(() => {
|
||||
markSessionHandled();
|
||||
cleanup();
|
||||
})
|
||||
.catch(() => showToast('Could not confirm discard with the live server. Session kept for recovery.', 5000));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session persistence via localStorage
|
||||
// Session persistence via live-browser-session.js
|
||||
// ---------------------------------------------------------------------------
|
||||
// Survives page reloads, browser close/reopen, HMR, and accidental refreshes.
|
||||
|
||||
const LS_KEY = PREFIX + '-session';
|
||||
|
||||
function saveSession() {
|
||||
if (!currentSessionId) return;
|
||||
// NOTE: scrollY is stored under a separate key (writeScrollY). Storing
|
||||
// it here would overwrite the Go-time value every time state changes.
|
||||
try {
|
||||
localStorage.setItem(LS_KEY, JSON.stringify({
|
||||
id: currentSessionId,
|
||||
state: state,
|
||||
action: selectedAction,
|
||||
count: selectedCount,
|
||||
expected: expectedVariants,
|
||||
arrived: arrivedVariants,
|
||||
visible: visibleVariant,
|
||||
}));
|
||||
} catch { /* quota exceeded or private mode */ }
|
||||
sessionState.saveSession({
|
||||
id: currentSessionId,
|
||||
state,
|
||||
action: selectedAction,
|
||||
count: selectedCount,
|
||||
expected: expectedVariants,
|
||||
arrived: arrivedVariants,
|
||||
visible: visibleVariant,
|
||||
});
|
||||
}
|
||||
|
||||
function loadSession() {
|
||||
try {
|
||||
const raw = localStorage.getItem(LS_KEY);
|
||||
return raw ? JSON.parse(raw) : null;
|
||||
} catch { return null; }
|
||||
return sessionState.loadSession();
|
||||
}
|
||||
|
||||
function clearSession() {
|
||||
try { localStorage.removeItem(LS_KEY); } catch {}
|
||||
sessionState.clearSession();
|
||||
}
|
||||
|
||||
/** Mark session as handled (accepted/discarded). The agent will clean up
|
||||
@@ -3018,19 +3100,15 @@ void main() {
|
||||
* prevents resumeSession from picking it up again after reload. */
|
||||
function markSessionHandled() {
|
||||
if (!currentSessionId) return;
|
||||
try {
|
||||
localStorage.setItem(LS_KEY + '-handled', currentSessionId);
|
||||
} catch {}
|
||||
sessionState.markHandled(currentSessionId);
|
||||
}
|
||||
|
||||
function isSessionHandled(id) {
|
||||
try {
|
||||
return localStorage.getItem(LS_KEY + '-handled') === id;
|
||||
} catch { return false; }
|
||||
return sessionState.isHandled(id);
|
||||
}
|
||||
|
||||
function clearHandled() {
|
||||
try { localStorage.removeItem(LS_KEY + '-handled'); } catch {}
|
||||
sessionState.clearHandled();
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
@@ -3161,6 +3239,7 @@ void main() {
|
||||
// hid. Now that state is CYCLING, re-fire.
|
||||
if (state === 'CYCLING') refreshParamsPanel();
|
||||
saveSession();
|
||||
queueCheckpoint('browser_resumed');
|
||||
|
||||
// Start observing for more variants AFTER initial setup
|
||||
if (variantObserver) variantObserver.disconnect();
|
||||
@@ -3617,7 +3696,7 @@ void main() {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Design System Panel — visualizes the project's DESIGN.json sidecar
|
||||
// Design System Panel — visualizes the project's .impeccable/design.json sidecar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DESIGN_PREFS_KEY = 'impeccable-live-design-panel';
|
||||
@@ -3629,7 +3708,7 @@ void main() {
|
||||
open: false,
|
||||
tab: 'visual', // 'visual' | 'raw'
|
||||
parsed: null, // parseDesignMd output (frontmatter + body sections)
|
||||
sidecar: null, // DESIGN.json v2 payload (extensions + components + narrative)
|
||||
sidecar: null, // .impeccable/design.json v2 payload (extensions + components + narrative)
|
||||
hasMd: false,
|
||||
hasSidecar: false,
|
||||
present: null, // true/false once fetch resolves
|
||||
@@ -4130,7 +4209,7 @@ void main() {
|
||||
box.className = 'stale';
|
||||
box.innerHTML = `
|
||||
<span class="stale-dot"></span>
|
||||
<span class="stale-text"><strong>DESIGN.md is newer than DESIGN.json.</strong> Run <code>/impeccable document</code> to refresh the sidecar.</span>
|
||||
<span class="stale-text"><strong>DESIGN.md is newer than .impeccable/design.json.</strong> Run <code>/impeccable document</code> to refresh the sidecar.</span>
|
||||
`;
|
||||
return box;
|
||||
}
|
||||
@@ -4138,7 +4217,7 @@ void main() {
|
||||
function renderParsedMdCta() {
|
||||
const box = document.createElement('div');
|
||||
box.className = 'parsed-md-cta';
|
||||
box.innerHTML = `<strong>Basic view</strong>This panel reads the tokens in your <code>DESIGN.md</code> frontmatter. Running <code>/impeccable document</code> also generates a <code>DESIGN.json</code> sidecar with your project's actual component snippets (button, input, nav) and tonal ramps, rendered live below the tokens.`;
|
||||
box.innerHTML = `<strong>Basic view</strong>This panel reads the tokens in your <code>DESIGN.md</code> frontmatter. Running <code>/impeccable document</code> also generates a <code>.impeccable/design.json</code> sidecar with your project's actual component snippets (button, input, nav) and tonal ramps, rendered live below the tokens.`;
|
||||
return box;
|
||||
}
|
||||
|
||||
@@ -4598,7 +4677,7 @@ void main() {
|
||||
|
||||
function cssSafe(v) {
|
||||
// Strip anything outside valid CSS value chars to prevent injection via
|
||||
// DESIGN.json values rendered into inline style strings.
|
||||
// .impeccable/design.json values rendered into inline style strings.
|
||||
return String(v).replace(/[<>"'`\n]/g, '');
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Canonical durable completion acknowledgement for Impeccable live sessions.
|
||||
*/
|
||||
|
||||
import { createLiveSessionStore } from './live-session-store.mjs';
|
||||
import { readLiveServerInfo } from './impeccable-paths.mjs';
|
||||
|
||||
function parseArgs(argv) {
|
||||
const out = { status: 'complete' };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
if (arg === '--id') out.id = argv[++i];
|
||||
else if (arg.startsWith('--id=')) out.id = arg.slice('--id='.length);
|
||||
else if (arg === '--discarded' || arg === '--discard') out.status = 'discarded';
|
||||
else if (arg === '--error') { out.status = 'agent_error'; out.message = argv[++i] || 'unknown error'; }
|
||||
else if (arg.startsWith('--error=')) { out.status = 'agent_error'; out.message = arg.slice('--error='.length); }
|
||||
else if (arg === '--help' || arg === '-h') out.help = true;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function completeCli() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (args.help || !args.id) {
|
||||
console.log(`Usage: node live-complete.mjs --id SESSION_ID [--discarded|--error MESSAGE]\n\nAppend the final durable session acknowledgement. Use after accept/discard cleanup is verified.`);
|
||||
process.exit(args.help ? 0 : 1);
|
||||
}
|
||||
|
||||
const serverInfo = readServerInfo();
|
||||
const serverResult = serverInfo ? await completeThroughServer(serverInfo, args) : null;
|
||||
if (serverResult?.ok) {
|
||||
const store = createLiveSessionStore({ cwd: process.cwd(), sessionId: args.id });
|
||||
const snapshot = store.getSnapshot(args.id, { includeCompleted: true });
|
||||
console.log(JSON.stringify({ ok: true, id: args.id, phase: snapshot?.phase || args.status, snapshot }, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
const store = createLiveSessionStore({ cwd: process.cwd(), sessionId: args.id });
|
||||
const event = args.status === 'discarded'
|
||||
? { type: 'discarded', id: args.id }
|
||||
: args.status === 'agent_error'
|
||||
? { type: 'agent_error', id: args.id, message: args.message || 'unknown error' }
|
||||
: { type: 'complete', id: args.id };
|
||||
const snapshot = store.appendEvent(event);
|
||||
console.log(JSON.stringify({ ok: true, id: args.id, phase: snapshot.phase, snapshot }, null, 2));
|
||||
}
|
||||
|
||||
function readServerInfo() {
|
||||
return readLiveServerInfo(process.cwd())?.info || null;
|
||||
}
|
||||
|
||||
async function completeThroughServer(info, args) {
|
||||
const type = args.status === 'discarded'
|
||||
? 'discarded'
|
||||
: args.status === 'agent_error'
|
||||
? 'error'
|
||||
: 'complete';
|
||||
try {
|
||||
const res = await fetch(`http://localhost:${info.port}/poll`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token: info.token, id: args.id, type, message: args.message }),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
return await res.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const _running = process.argv[1];
|
||||
if (_running?.endsWith('live-complete.mjs') || _running?.endsWith('live-complete.mjs/')) {
|
||||
completeCli();
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
export function completionTypeForAcceptResult(eventType, acceptResult) {
|
||||
if (eventType === 'discard') return acceptResult?.handled === true ? 'discarded' : 'error';
|
||||
if (acceptResult?.handled === true && acceptResult?.carbonize === true) return 'agent_done';
|
||||
if (acceptResult?.handled === true) return 'complete';
|
||||
if (acceptResult?.mode === 'error') return 'error';
|
||||
return 'agent_done';
|
||||
}
|
||||
|
||||
export function completionAckForAcceptResult(eventId, completionType, acceptResult) {
|
||||
const ack = { ok: true, type: completionType };
|
||||
if (acceptResult?.handled === true && acceptResult?.carbonize === true) {
|
||||
ack.final = false;
|
||||
ack.requiresComplete = true;
|
||||
ack.nextCommand = `live-complete.mjs --id ${eventId}`;
|
||||
ack.message = 'Carbonize cleanup must be verified, then the session must be completed explicitly before polling again.';
|
||||
}
|
||||
return ack;
|
||||
}
|
||||
@@ -2,23 +2,24 @@
|
||||
* CLI helper: insert/remove the live variant mode script tag in the project's
|
||||
* main HTML entry point.
|
||||
*
|
||||
* On first live run, the agent generates `config.json` in this script's
|
||||
* directory with the project's insertion target (framework-specific). On
|
||||
* On first live run, the agent generates `.impeccable/live/config.json`
|
||||
* with the project's insertion target (framework-specific). On
|
||||
* every subsequent run, this script handles insert/remove deterministically
|
||||
* with zero LLM involvement.
|
||||
*
|
||||
* Usage:
|
||||
* node live-inject.mjs --port PORT # Insert the live script tag
|
||||
* node live-inject.mjs --remove # Remove the live script tag
|
||||
* node live-inject.mjs --check # Check whether config.json exists
|
||||
* node live-inject.mjs --check # Check whether live config exists
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { resolveLiveConfigPath } from './impeccable-paths.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const CONFIG_PATH = process.env.IMPECCABLE_LIVE_CONFIG || path.join(__dirname, 'config.json');
|
||||
const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname });
|
||||
const MARKER_OPEN_TEXT = 'impeccable-live-start';
|
||||
const MARKER_CLOSE_TEXT = 'impeccable-live-end';
|
||||
|
||||
@@ -39,12 +40,12 @@ export async function injectCli() {
|
||||
console.log(`Usage: node live-inject.mjs [options]
|
||||
|
||||
Insert or remove the live mode script tag in the project's HTML entry point.
|
||||
Reads configuration from config.json (in this same directory).
|
||||
Reads configuration from .impeccable/live/config.json.
|
||||
|
||||
Modes:
|
||||
--port PORT Insert script tag pointing at http://localhost:PORT/live.js
|
||||
--remove Remove the script tag (if present)
|
||||
--check Print whether config.json exists and its content
|
||||
--check Print whether .impeccable/live/config.json exists and its content
|
||||
|
||||
Output (JSON):
|
||||
{ ok, file, inserted|removed, config? }`);
|
||||
|
||||
@@ -9,10 +9,10 @@
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { completionAckForAcceptResult, completionTypeForAcceptResult } from './live-completion.mjs';
|
||||
import { readLiveServerInfo } from './impeccable-paths.mjs';
|
||||
|
||||
// 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
|
||||
@@ -20,15 +20,29 @@ import { fileURLToPath } from 'node:url';
|
||||
// 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 {
|
||||
const record = readLiveServerInfo(process.cwd());
|
||||
if (!record) {
|
||||
console.error('No running live server found. Start one with: npx impeccable live');
|
||||
process.exit(1);
|
||||
}
|
||||
return record.info;
|
||||
}
|
||||
|
||||
export function buildPollReplyPayload(token, { id, type, message, file, data }) {
|
||||
return { token, id, type, message, file, data };
|
||||
}
|
||||
|
||||
async function postReply(base, token, reply) {
|
||||
const res = await fetch(`${base}/poll`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(buildPollReplyPayload(token, reply)),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error || res.statusText);
|
||||
}
|
||||
}
|
||||
|
||||
export async function pollCli() {
|
||||
@@ -69,23 +83,7 @@ Options:
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
await postReply(base, info.token, { id, type: status, message, file: filePath });
|
||||
|
||||
// Success — silent exit (agent doesn't need output for replies)
|
||||
} catch (err) {
|
||||
@@ -156,7 +154,23 @@ Options:
|
||||
);
|
||||
event._acceptResult = JSON.parse(out.trim());
|
||||
} catch (err) {
|
||||
event._acceptResult = { handled: false, error: err.message };
|
||||
event._acceptResult = { handled: false, mode: 'error', error: err.message };
|
||||
}
|
||||
|
||||
const completionType = completionTypeForAcceptResult(event.type, event._acceptResult);
|
||||
try {
|
||||
await postReply(base, info.token, {
|
||||
id: event.id,
|
||||
type: completionType,
|
||||
message: event._acceptResult?.error,
|
||||
file: event._acceptResult?.file,
|
||||
data: event._acceptResult?.carbonize === true ? { carbonize: true } : undefined,
|
||||
});
|
||||
} catch (err) {
|
||||
event._completionAck = { ok: false, error: err.message };
|
||||
}
|
||||
if (!event._completionAck) {
|
||||
event._completionAck = completionAckForAcceptResult(event.id, completionType, event._acceptResult);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,7 +178,7 @@ Options:
|
||||
// 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');
|
||||
process.stderr.write('\n⚠ Carbonize cleanup REQUIRED before next poll. After cleanup, run live-complete.mjs --id ' + event.id + '. See reference/live.md "Required after accept".\n\n');
|
||||
}
|
||||
|
||||
// Print the event as JSON — the agent reads this from stdout
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Recover the next agent action from the durable live-session journal.
|
||||
*/
|
||||
|
||||
import { createLiveSessionStore } from './live-session-store.mjs';
|
||||
|
||||
function parseArgs(argv) {
|
||||
const out = { id: null };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
if (arg === '--id') out.id = argv[++i];
|
||||
else if (arg.startsWith('--id=')) out.id = arg.slice('--id='.length);
|
||||
else if (arg === '--help' || arg === '-h') out.help = true;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function resumeCli() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (args.help) {
|
||||
console.log(`Usage: node live-resume.mjs [--id SESSION_ID]\n\nPrint the active durable session checkpoint and the next safe agent action.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const store = createLiveSessionStore({ cwd: process.cwd(), sessionId: args.id || undefined });
|
||||
const snapshot = args.id ? store.getSnapshot(args.id) : store.listActiveSessions()[0] || null;
|
||||
if (!snapshot) {
|
||||
console.log(JSON.stringify({ active: false, nextAction: 'No active durable live session found.' }, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
const pending = snapshot.pendingEvent || null;
|
||||
const nextAction = pending
|
||||
? `Run live-poll.mjs, handle ${pending.type} ${pending.id}, then acknowledge with live-poll.mjs --reply ${pending.id} done.`
|
||||
: snapshot.phase === 'carbonize_required'
|
||||
? `Finish carbonize cleanup${snapshot.sourceFile ? ` in ${snapshot.sourceFile}` : ''}, then run live-complete.mjs --id ${snapshot.id}.`
|
||||
: snapshot.phase === 'accept_requested'
|
||||
? `Run live-complete.mjs --id ${snapshot.id} after verifying the accepted variant is written.`
|
||||
: `Inspect ${snapshot.id}; no pending agent event is currently queued.`;
|
||||
|
||||
console.log(JSON.stringify({ active: true, snapshot, pendingEvent: pending, nextAction }, null, 2));
|
||||
}
|
||||
|
||||
const _running = process.argv[1];
|
||||
if (_running?.endsWith('live-resume.mjs') || _running?.endsWith('live-resume.mjs/')) {
|
||||
resumeCli();
|
||||
}
|
||||
@@ -22,14 +22,20 @@ import net from 'node:net';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseDesignMd } from './design-parser.mjs';
|
||||
import { resolveContextDir } from './load-context.mjs';
|
||||
import { createLiveSessionStore } from './live-session-store.mjs';
|
||||
import {
|
||||
getDesignSidecarPath,
|
||||
getLiveAnnotationsDir,
|
||||
readLiveServerInfo,
|
||||
removeLiveServerInfo,
|
||||
resolveDesignSidecarPath,
|
||||
writeLiveServerInfo,
|
||||
} from './impeccable-paths.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');
|
||||
// PRODUCT.md / DESIGN.md / DESIGN.json live wherever load-context.mjs resolves.
|
||||
// Keeps live-server in sync with the loader when users keep the docs in
|
||||
// .agents/context/, docs/, or a path set via IMPECCABLE_CONTEXT_DIR.
|
||||
// PRODUCT.md / DESIGN.md live wherever load-context.mjs resolves. The generated
|
||||
// DESIGN sidecar is project-local at .impeccable/design.json, with legacy
|
||||
// DESIGN.json fallback for existing projects.
|
||||
const CONTEXT_DIR = resolveContextDir(process.cwd());
|
||||
const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway
|
||||
const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s
|
||||
@@ -57,10 +63,12 @@ const state = {
|
||||
token: null,
|
||||
port: null,
|
||||
sseClients: new Set(), // SSE response objects (server→browser push)
|
||||
pendingEvents: [], // browser events waiting for agent poll
|
||||
pendingEvents: [], // browser events waiting for agent ack ({ event, leaseUntil })
|
||||
pendingPolls: [], // agent poll callbacks waiting for browser events
|
||||
exitTimer: null,
|
||||
sessionDir: null, // per-session tmp dir for annotation screenshots
|
||||
sessionStore: null,
|
||||
leaseTimer: null,
|
||||
};
|
||||
|
||||
// Cap per-annotation upload size. A full 1920×1080 PNG is typically <1 MB;
|
||||
@@ -68,13 +76,72 @@ const state = {
|
||||
const MAX_ANNOTATION_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
function enqueueEvent(event) {
|
||||
if (state.pendingPolls.length > 0) {
|
||||
state.pendingPolls.shift()(event);
|
||||
} else {
|
||||
state.pendingEvents.push(event);
|
||||
if (!event || (event.id && state.pendingEvents.some((entry) => entry.event?.id === event.id && entry.event?.type === event.type))) return;
|
||||
state.pendingEvents.push({ event, leaseUntil: 0 });
|
||||
flushPendingPolls();
|
||||
}
|
||||
|
||||
function restorePendingEventsFromStore() {
|
||||
if (!state.sessionStore) return;
|
||||
for (const snapshot of state.sessionStore.listActiveSessions()) {
|
||||
if (snapshot.pendingEvent) enqueueEvent(snapshot.pendingEvent);
|
||||
}
|
||||
}
|
||||
|
||||
function findAvailablePendingEvent(now = Date.now()) {
|
||||
return state.pendingEvents.find((entry) => !entry.leaseUntil || entry.leaseUntil <= now);
|
||||
}
|
||||
|
||||
function leaseEvent(entry, leaseMs) {
|
||||
if (!entry.event?.id) {
|
||||
const idx = state.pendingEvents.indexOf(entry);
|
||||
if (idx !== -1) state.pendingEvents.splice(idx, 1);
|
||||
return entry.event;
|
||||
}
|
||||
entry.leaseUntil = Date.now() + leaseMs;
|
||||
return entry.event;
|
||||
}
|
||||
|
||||
function acknowledgePendingEvent(id) {
|
||||
if (!id) return false;
|
||||
const idx = state.pendingEvents.findIndex((entry) => entry.event?.id === id);
|
||||
if (idx === -1) return false;
|
||||
state.pendingEvents.splice(idx, 1);
|
||||
scheduleLeaseFlush();
|
||||
return true;
|
||||
}
|
||||
|
||||
function scheduleLeaseFlush() {
|
||||
if (state.leaseTimer) {
|
||||
clearTimeout(state.leaseTimer);
|
||||
state.leaseTimer = null;
|
||||
}
|
||||
if (state.pendingPolls.length === 0) return;
|
||||
const now = Date.now();
|
||||
const nextLeaseUntil = state.pendingEvents
|
||||
.map((entry) => entry.leaseUntil || 0)
|
||||
.filter((leaseUntil) => leaseUntil > now)
|
||||
.sort((a, b) => a - b)[0];
|
||||
if (!nextLeaseUntil) return;
|
||||
state.leaseTimer = setTimeout(() => {
|
||||
state.leaseTimer = null;
|
||||
flushPendingPolls();
|
||||
}, Math.max(0, nextLeaseUntil - now));
|
||||
}
|
||||
|
||||
function flushPendingPolls() {
|
||||
while (state.pendingPolls.length > 0) {
|
||||
const entry = findAvailablePendingEvent();
|
||||
if (!entry) {
|
||||
scheduleLeaseFlush();
|
||||
return;
|
||||
}
|
||||
const poll = state.pendingPolls.shift();
|
||||
poll.resolve(leaseEvent(entry, poll.leaseMs));
|
||||
}
|
||||
scheduleLeaseFlush();
|
||||
}
|
||||
|
||||
/** Push a message to all connected SSE clients. */
|
||||
function broadcast(msg) {
|
||||
const data = 'data: ' + JSON.stringify(msg) + '\n\n';
|
||||
@@ -103,13 +170,16 @@ function loadBrowserScripts() {
|
||||
// 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 sessionPath = path.join(__dirname, 'live-browser-session.js');
|
||||
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);
|
||||
for (const p of [sessionPath, livePath]) {
|
||||
if (!fs.existsSync(p)) {
|
||||
process.stderr.write('Error: live browser script not found at ' + p + '\n');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
return { detectScript, livePath };
|
||||
return { detectScript, sessionPath, livePath };
|
||||
}
|
||||
|
||||
function hasProjectContext() {
|
||||
@@ -170,6 +240,13 @@ function validateEvent(msg) {
|
||||
return null;
|
||||
case 'discard':
|
||||
return isValidId(msg.id) ? null : 'discard: missing or malformed id';
|
||||
case 'checkpoint':
|
||||
if (!isValidId(msg.id)) return 'checkpoint: missing or malformed id';
|
||||
if (!Number.isInteger(msg.revision) || msg.revision < 0) return 'checkpoint: revision must be a non-negative integer';
|
||||
if (msg.paramValues !== undefined && (typeof msg.paramValues !== 'object' || msg.paramValues === null || Array.isArray(msg.paramValues))) {
|
||||
return 'checkpoint: paramValues must be an object';
|
||||
}
|
||||
return null;
|
||||
case 'exit':
|
||||
return null;
|
||||
case 'prefetch':
|
||||
@@ -184,7 +261,7 @@ function validateEvent(msg) {
|
||||
// HTTP request handler
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createRequestHandler({ detectScript, livePath }) {
|
||||
function createRequestHandler({ detectScript, sessionPath, livePath }) {
|
||||
return (req, res) => {
|
||||
const url = new URL(req.url, `http://localhost:${state.port}`);
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
@@ -200,17 +277,20 @@ function createRequestHandler({ detectScript, livePath }) {
|
||||
// the next tab reload. No-store headers prevent browser caching across
|
||||
// sessions — during iteration, a cached old script silently breaks
|
||||
// every subsequent session.
|
||||
let sessionScript;
|
||||
let liveScript;
|
||||
try {
|
||||
sessionScript = fs.readFileSync(sessionPath, 'utf-8');
|
||||
liveScript = fs.readFileSync(livePath, 'utf-8');
|
||||
} catch (err) {
|
||||
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
||||
res.end('Error reading live-browser.js: ' + err.message);
|
||||
res.end('Error reading live browser scripts: ' + err.message);
|
||||
return;
|
||||
}
|
||||
const body =
|
||||
`window.__IMPECCABLE_TOKEN__ = '${state.token}';\n` +
|
||||
`window.__IMPECCABLE_PORT__ = ${state.port};\n` +
|
||||
sessionScript + '\n' +
|
||||
liveScript;
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/javascript',
|
||||
@@ -305,6 +385,26 @@ function createRequestHandler({ detectScript, livePath }) {
|
||||
}
|
||||
|
||||
// --- Health ---
|
||||
if (p === '/status') {
|
||||
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 sessions = state.sessionStore ? state.sessionStore.listActiveSessions() : [];
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
status: 'ok',
|
||||
port: state.port,
|
||||
connectedClients: state.sseClients.size,
|
||||
pendingEvents: state.pendingEvents.map((entry) => ({
|
||||
id: entry.event?.id,
|
||||
type: entry.event?.type,
|
||||
leased: !!(entry.leaseUntil && entry.leaseUntil > Date.now()),
|
||||
leaseUntil: entry.leaseUntil || null,
|
||||
})),
|
||||
activeSessions: sessions,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (p === '/health') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
@@ -316,13 +416,13 @@ function createRequestHandler({ detectScript, livePath }) {
|
||||
}
|
||||
|
||||
// --- Design system (unified v2 response) + raw ---
|
||||
// /design-system.json returns both parsed DESIGN.md and DESIGN.json
|
||||
// /design-system.json returns both parsed DESIGN.md and .impeccable/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.
|
||||
// - sidecar: .impeccable/design.json contents when present.
|
||||
// Expected shape: schemaVersion 2, carrying
|
||||
// extensions + components + narrative.
|
||||
// /design-system/raw returns DESIGN.md markdown verbatim
|
||||
@@ -331,7 +431,7 @@ function createRequestHandler({ detectScript, livePath }) {
|
||||
if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
|
||||
|
||||
const mdPath = path.join(CONTEXT_DIR, 'DESIGN.md');
|
||||
const jsonPath = path.join(CONTEXT_DIR, 'DESIGN.json');
|
||||
const jsonPath = resolveDesignSidecarPath(process.cwd(), CONTEXT_DIR) || getDesignSidecarPath(process.cwd());
|
||||
const mdStat = statOrNull(mdPath);
|
||||
const jsonStat = statOrNull(jsonPath);
|
||||
|
||||
@@ -367,7 +467,7 @@ function createRequestHandler({ detectScript, livePath }) {
|
||||
try {
|
||||
response.sidecar = JSON.parse(fs.readFileSync(jsonPath, 'utf-8'));
|
||||
} catch (err) {
|
||||
response.sidecarError = 'Failed to parse DESIGN.json: ' + err.message;
|
||||
response.sidecarError = 'Failed to parse .impeccable/design.json: ' + err.message;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -449,7 +549,16 @@ function createRequestHandler({ detectScript, livePath }) {
|
||||
res.end(JSON.stringify({ error }));
|
||||
return;
|
||||
}
|
||||
enqueueEvent(msg);
|
||||
if (state.sessionStore && msg.id) {
|
||||
try {
|
||||
state.sessionStore.appendEvent(msg);
|
||||
} catch (err) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'session_store_append_failed', message: err.message }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (msg.type !== 'checkpoint') enqueueEvent(msg);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ ok: true }));
|
||||
});
|
||||
@@ -492,13 +601,16 @@ function handlePollGet(req, res, url) {
|
||||
return;
|
||||
}
|
||||
const timeout = parseInt(url.searchParams.get('timeout') || DEFAULT_POLL_TIMEOUT, 10);
|
||||
if (state.pendingEvents.length > 0) {
|
||||
const leaseMs = parseInt(url.searchParams.get('leaseMs') || '30000', 10);
|
||||
const available = findAvailablePendingEvent();
|
||||
if (available) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(state.pendingEvents.shift()));
|
||||
res.end(JSON.stringify(leaseEvent(available, leaseMs)));
|
||||
return;
|
||||
}
|
||||
const poll = { resolve, leaseMs };
|
||||
const timer = setTimeout(() => {
|
||||
const idx = state.pendingPolls.indexOf(resolve);
|
||||
const idx = state.pendingPolls.indexOf(poll);
|
||||
if (idx !== -1) state.pendingPolls.splice(idx, 1);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ type: 'timeout' }));
|
||||
@@ -508,10 +620,11 @@ function handlePollGet(req, res, url) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(event));
|
||||
}
|
||||
state.pendingPolls.push(resolve);
|
||||
state.pendingPolls.push(poll);
|
||||
scheduleLeaseFlush();
|
||||
req.on('close', () => {
|
||||
clearTimeout(timer);
|
||||
const idx = state.pendingPolls.indexOf(resolve);
|
||||
const idx = state.pendingPolls.indexOf(poll);
|
||||
if (idx !== -1) state.pendingPolls.splice(idx, 1);
|
||||
});
|
||||
}
|
||||
@@ -531,6 +644,26 @@ function handlePollPost(req, res) {
|
||||
res.end(JSON.stringify({ error: 'Unauthorized' }));
|
||||
return;
|
||||
}
|
||||
acknowledgePendingEvent(msg.id);
|
||||
if (state.sessionStore && msg.id) {
|
||||
try {
|
||||
const eventType = msg.type === 'discard' || msg.type === 'discarded'
|
||||
? 'discarded'
|
||||
: msg.type === 'complete'
|
||||
? 'complete'
|
||||
: msg.type === 'error'
|
||||
? 'agent_error'
|
||||
: 'agent_done';
|
||||
state.sessionStore.appendEvent({
|
||||
type: eventType,
|
||||
id: msg.id,
|
||||
file: msg.file,
|
||||
message: msg.message,
|
||||
carbonize: msg.data?.carbonize === true,
|
||||
});
|
||||
} catch { /* keep reply path best-effort; browser still needs SSE */ }
|
||||
}
|
||||
flushPendingPolls();
|
||||
// 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' });
|
||||
@@ -545,13 +678,15 @@ function handlePollPost(req, res) {
|
||||
let httpServer = null;
|
||||
|
||||
function shutdown() {
|
||||
try { fs.unlinkSync(LIVE_PID_FILE); } catch {}
|
||||
removeLiveServerInfo(process.cwd());
|
||||
if (state.leaseTimer) clearTimeout(state.leaseTimer);
|
||||
state.leaseTimer = null;
|
||||
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' });
|
||||
for (const poll of state.pendingPolls) poll.resolve({ type: 'exit' });
|
||||
state.pendingPolls.length = 0;
|
||||
if (httpServer) httpServer.close();
|
||||
process.exit(0);
|
||||
@@ -587,6 +722,7 @@ Endpoints:
|
||||
/events SSE stream (server→browser) + POST (browser→server)
|
||||
/poll Long-poll for agent CLI
|
||||
/source Raw source file reader (no-HMR fallback)
|
||||
/status Durable recovery status (token-protected)
|
||||
/health Health check`);
|
||||
process.exit(0);
|
||||
}
|
||||
@@ -594,7 +730,7 @@ Endpoints:
|
||||
if (args.includes('stop')) {
|
||||
const keepInject = args.includes('--keep-inject');
|
||||
try {
|
||||
const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
|
||||
const { info } = readLiveServerInfo(process.cwd()) || {};
|
||||
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 {
|
||||
@@ -645,7 +781,7 @@ if (args.includes('--background')) {
|
||||
const deadline = Date.now() + 10_000;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
|
||||
const { info } = readLiveServerInfo(process.cwd()) || {};
|
||||
if (info.pid !== process.pid) {
|
||||
// Output JSON so the agent can read port + token from stdout.
|
||||
console.log(JSON.stringify(info));
|
||||
@@ -659,30 +795,36 @@ if (args.includes('--background')) {
|
||||
}
|
||||
|
||||
// Check for existing session
|
||||
try {
|
||||
const existing = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
|
||||
try { process.kill(existing.pid, 0);
|
||||
const existingRecord = readLiveServerInfo(process.cwd());
|
||||
if (existingRecord?.info) {
|
||||
const existing = existingRecord.info;
|
||||
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 {}
|
||||
} catch {
|
||||
try { fs.unlinkSync(existingRecord.path); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
state.token = randomUUID();
|
||||
state.sessionStore = createLiveSessionStore({ cwd: process.cwd() });
|
||||
restorePendingEventsFromStore();
|
||||
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');
|
||||
const annotRoot = getLiveAnnotationsDir(process.cwd());
|
||||
fs.mkdirSync(annotRoot, { recursive: true });
|
||||
state.sessionDir = fs.mkdtempSync(path.join(annotRoot, 'session-'));
|
||||
|
||||
const { detectScript, livePath } = loadBrowserScripts();
|
||||
httpServer = http.createServer(createRequestHandler({ detectScript, livePath }));
|
||||
const { detectScript, sessionPath, livePath } = loadBrowserScripts();
|
||||
httpServer = http.createServer(createRequestHandler({ detectScript, sessionPath, 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 }));
|
||||
writeLiveServerInfo(process.cwd(), { 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`);
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { getLegacyLiveSessionsDir, getLiveSessionsDir } from './impeccable-paths.mjs';
|
||||
|
||||
const COMPLETED_PHASES = new Set(['completed', 'discarded']);
|
||||
|
||||
export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) {
|
||||
const rootDir = getLiveSessionsDir(cwd);
|
||||
const legacyRootDir = getLegacyLiveSessionsDir(cwd);
|
||||
fs.mkdirSync(rootDir, { recursive: true });
|
||||
const snapshotCache = new Map();
|
||||
|
||||
function loadCachedOrRebuild(id) {
|
||||
const cached = snapshotCache.get(id);
|
||||
if (cached) return cached;
|
||||
const journalPath = getReadableJournalPath(id);
|
||||
const rebuilt = rebuildSnapshotFromJournal(journalPath, id);
|
||||
snapshotCache.set(id, rebuilt);
|
||||
return rebuilt;
|
||||
}
|
||||
|
||||
function getReadableJournalPath(id) {
|
||||
const primary = getJournalPath(rootDir, id);
|
||||
if (fs.existsSync(primary)) return primary;
|
||||
const legacy = getJournalPath(legacyRootDir, id);
|
||||
if (fs.existsSync(legacy)) return legacy;
|
||||
return primary;
|
||||
}
|
||||
|
||||
return {
|
||||
rootDir,
|
||||
legacyRootDir,
|
||||
appendEvent(event) {
|
||||
const normalized = normalizeEvent(event, sessionId);
|
||||
const journalPath = getJournalPath(rootDir, normalized.id);
|
||||
const snapshotPath = getSnapshotPath(rootDir, normalized.id);
|
||||
const legacyJournalPath = getJournalPath(legacyRootDir, normalized.id);
|
||||
if (!fs.existsSync(journalPath) && fs.existsSync(legacyJournalPath)) {
|
||||
fs.copyFileSync(legacyJournalPath, journalPath);
|
||||
}
|
||||
const prior = loadCachedOrRebuild(normalized.id);
|
||||
const seq = prior.nextSeq;
|
||||
const entry = {
|
||||
seq,
|
||||
id: normalized.id,
|
||||
type: normalized.type,
|
||||
ts: new Date().toISOString(),
|
||||
event: normalized,
|
||||
};
|
||||
fs.appendFileSync(journalPath, JSON.stringify(entry) + '\n');
|
||||
const next = applyEvent(prior.snapshot, entry, prior.diagnostics);
|
||||
snapshotCache.set(normalized.id, { snapshot: next, diagnostics: next.diagnostics || [], nextSeq: seq + 1 });
|
||||
writeSnapshot(snapshotPath, next);
|
||||
return next;
|
||||
},
|
||||
getSnapshot(id = sessionId, opts = {}) {
|
||||
if (!id) throw new Error('session id required');
|
||||
const journalPath = getReadableJournalPath(id);
|
||||
const snapshotPath = getSnapshotPath(rootDir, id);
|
||||
const rebuilt = rebuildSnapshotFromJournal(journalPath, id);
|
||||
snapshotCache.set(id, rebuilt);
|
||||
writeSnapshot(snapshotPath, rebuilt.snapshot);
|
||||
if (!opts.includeCompleted && COMPLETED_PHASES.has(rebuilt.snapshot.phase)) return null;
|
||||
return rebuilt.snapshot;
|
||||
},
|
||||
listActiveSessions() {
|
||||
const ids = new Set();
|
||||
for (const dir of [legacyRootDir, rootDir]) {
|
||||
if (!fs.existsSync(dir)) continue;
|
||||
for (const name of fs.readdirSync(dir)) {
|
||||
if (name.endsWith('.jsonl')) ids.add(name.slice(0, -'.jsonl'.length));
|
||||
}
|
||||
}
|
||||
return [...ids]
|
||||
.sort()
|
||||
.map((id) => this.getSnapshot(id))
|
||||
.filter(Boolean);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeEvent(event, fallbackId) {
|
||||
if (!event || typeof event !== 'object') throw new Error('event object required');
|
||||
const id = event.id || fallbackId;
|
||||
if (!id || typeof id !== 'string') throw new Error('event id required');
|
||||
if (!event.type || typeof event.type !== 'string') throw new Error('event type required');
|
||||
return { ...event, id };
|
||||
}
|
||||
|
||||
function getJournalPath(rootDir, id) {
|
||||
return path.join(rootDir, safeSessionId(id) + '.jsonl');
|
||||
}
|
||||
|
||||
function getSnapshotPath(rootDir, id) {
|
||||
return path.join(rootDir, safeSessionId(id) + '.snapshot.json');
|
||||
}
|
||||
|
||||
function safeSessionId(id) {
|
||||
if (!/^[A-Za-z0-9_-]{1,128}$/.test(id)) throw new Error('invalid session id: ' + id);
|
||||
return id;
|
||||
}
|
||||
|
||||
function baseSnapshot(id) {
|
||||
return {
|
||||
id,
|
||||
phase: 'new',
|
||||
pageUrl: null,
|
||||
sourceFile: null,
|
||||
expectedVariants: 0,
|
||||
arrivedVariants: 0,
|
||||
visibleVariant: null,
|
||||
paramValues: {},
|
||||
pendingEventSeq: null,
|
||||
pendingEvent: null,
|
||||
deliveryLease: null,
|
||||
checkpointRevision: 0,
|
||||
activeOwner: null,
|
||||
sourceMarkers: {},
|
||||
fallbackMode: null,
|
||||
annotationArtifacts: [],
|
||||
diagnostics: [],
|
||||
updatedAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
function rebuildSnapshotFromJournal(journalPath, id) {
|
||||
let snapshot = baseSnapshot(id);
|
||||
const diagnostics = [];
|
||||
let nextSeq = 1;
|
||||
if (!fs.existsSync(journalPath)) return { snapshot, diagnostics, nextSeq };
|
||||
|
||||
const lines = fs.readFileSync(journalPath, 'utf-8').split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const entry = JSON.parse(line);
|
||||
if (!entry || typeof entry !== 'object') throw new Error('entry is not object');
|
||||
if (Number.isInteger(entry.seq)) nextSeq = Math.max(nextSeq, entry.seq + 1);
|
||||
snapshot = applyEvent(snapshot, entry);
|
||||
} catch (err) {
|
||||
diagnostics.push({
|
||||
error: 'journal_parse_failed',
|
||||
line: i + 1,
|
||||
message: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
snapshot.diagnostics = [...snapshot.diagnostics, ...diagnostics];
|
||||
return { snapshot, diagnostics, nextSeq };
|
||||
}
|
||||
|
||||
function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
|
||||
const event = entry.event || entry;
|
||||
const next = {
|
||||
...snapshot,
|
||||
paramValues: { ...(snapshot.paramValues || {}) },
|
||||
sourceMarkers: { ...(snapshot.sourceMarkers || {}) },
|
||||
annotationArtifacts: [...(snapshot.annotationArtifacts || [])],
|
||||
diagnostics: [...(snapshot.diagnostics || [])],
|
||||
updatedAt: entry.ts || new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (inheritedDiagnostics.length && next.diagnostics.length === 0) {
|
||||
next.diagnostics = [...inheritedDiagnostics];
|
||||
}
|
||||
|
||||
switch (event.type) {
|
||||
case 'generate':
|
||||
next.phase = 'generate_requested';
|
||||
next.pageUrl = event.pageUrl ?? next.pageUrl;
|
||||
next.expectedVariants = event.count ?? next.expectedVariants;
|
||||
next.pendingEventSeq = entry.seq ?? next.pendingEventSeq;
|
||||
next.pendingEvent = toPendingEvent(event);
|
||||
if (event.screenshotPath) upsertArtifact(next.annotationArtifacts, { type: 'screenshot', path: event.screenshotPath });
|
||||
break;
|
||||
case 'variants_ready':
|
||||
case 'agent_done':
|
||||
next.phase = event.carbonize === true ? 'carbonize_required' : 'variants_ready';
|
||||
next.sourceFile = event.file ?? next.sourceFile;
|
||||
next.arrivedVariants = event.arrivedVariants ?? (next.arrivedVariants ?? next.expectedVariants);
|
||||
next.pendingEventSeq = null;
|
||||
next.pendingEvent = null;
|
||||
if (event.carbonize === true) {
|
||||
next.diagnostics.push({
|
||||
error: 'carbonize_cleanup_required',
|
||||
file: event.file || null,
|
||||
message: 'Accepted variant still has carbonize markers that must be folded into source CSS.',
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'checkpoint':
|
||||
if ((event.revision ?? 0) >= (next.checkpointRevision ?? 0)) {
|
||||
next.phase = event.phase ?? next.phase;
|
||||
next.checkpointRevision = event.revision ?? next.checkpointRevision;
|
||||
next.activeOwner = event.owner ?? next.activeOwner;
|
||||
next.arrivedVariants = event.arrivedVariants ?? next.arrivedVariants;
|
||||
next.visibleVariant = event.visibleVariant ?? next.visibleVariant;
|
||||
if (event.paramValues) next.paramValues = { ...event.paramValues };
|
||||
} else {
|
||||
next.diagnostics.push({ error: 'stale_checkpoint_ignored', revision: event.revision });
|
||||
}
|
||||
break;
|
||||
case 'accept':
|
||||
case 'accept_intent':
|
||||
next.phase = 'accept_requested';
|
||||
next.visibleVariant = Number(event.variantId ?? next.visibleVariant);
|
||||
if (event.paramValues) next.paramValues = { ...event.paramValues };
|
||||
next.pendingEventSeq = entry.seq ?? next.pendingEventSeq;
|
||||
next.pendingEvent = toPendingEvent(event);
|
||||
break;
|
||||
case 'discard':
|
||||
next.phase = 'discard_requested';
|
||||
next.pendingEventSeq = entry.seq ?? next.pendingEventSeq;
|
||||
next.pendingEvent = toPendingEvent(event);
|
||||
break;
|
||||
case 'discarded':
|
||||
next.phase = 'discarded';
|
||||
next.pendingEventSeq = null;
|
||||
next.pendingEvent = null;
|
||||
break;
|
||||
case 'complete':
|
||||
next.phase = 'completed';
|
||||
next.pendingEventSeq = null;
|
||||
next.pendingEvent = null;
|
||||
break;
|
||||
case 'agent_error':
|
||||
next.phase = 'agent_error';
|
||||
next.pendingEventSeq = null;
|
||||
next.pendingEvent = null;
|
||||
next.diagnostics.push({ error: 'agent_error', message: event.message || 'unknown agent error' });
|
||||
break;
|
||||
default:
|
||||
next.diagnostics.push({ error: 'unknown_event_type', type: event.type });
|
||||
break;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function toPendingEvent(event) {
|
||||
const pending = { ...event };
|
||||
delete pending.token;
|
||||
return pending;
|
||||
}
|
||||
|
||||
function upsertArtifact(artifacts, artifact) {
|
||||
if (!artifacts.some((existing) => existing.path === artifact.path && existing.type === artifact.type)) {
|
||||
artifacts.push(artifact);
|
||||
}
|
||||
}
|
||||
|
||||
function writeSnapshot(snapshotPath, snapshot) {
|
||||
fs.writeFileSync(snapshotPath, JSON.stringify(snapshot, null, 2) + '\n');
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Print durable recovery status for Impeccable live sessions.
|
||||
*/
|
||||
|
||||
import { createLiveSessionStore } from './live-session-store.mjs';
|
||||
import { readLiveServerInfo } from './impeccable-paths.mjs';
|
||||
|
||||
function readServerInfo() {
|
||||
return readLiveServerInfo(process.cwd())?.info || null;
|
||||
}
|
||||
|
||||
async function fetchServerStatus(info) {
|
||||
if (!info) return null;
|
||||
try {
|
||||
const res = await fetch(`http://localhost:${info.port}/status?token=${info.token}`);
|
||||
if (!res.ok) return null;
|
||||
return await res.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function statusCli() {
|
||||
const info = readServerInfo();
|
||||
const server = await fetchServerStatus(info);
|
||||
const store = createLiveSessionStore({ cwd: process.cwd() });
|
||||
const activeSessions = store.listActiveSessions();
|
||||
const payload = {
|
||||
liveServer: server ? {
|
||||
status: server.status,
|
||||
port: server.port,
|
||||
connectedClients: server.connectedClients,
|
||||
pendingEvents: server.pendingEvents,
|
||||
} : null,
|
||||
activeSessions: server?.activeSessions || activeSessions,
|
||||
recoveryHint: server
|
||||
? 'Run live-poll.mjs to continue pending work, or live-complete.mjs --id <session> after manual cleanup.'
|
||||
: 'Start live-server.mjs to requeue pending durable events, then run live-poll.mjs.',
|
||||
};
|
||||
console.log(JSON.stringify(payload, null, 2));
|
||||
}
|
||||
|
||||
const _running = process.argv[1];
|
||||
if (_running?.endsWith('live-status.mjs') || _running?.endsWith('live-status.mjs/')) {
|
||||
statusCli();
|
||||
}
|
||||
@@ -185,6 +185,7 @@ The agent should insert variant HTML at insertLine.`);
|
||||
|
||||
const { startLine, endLine } = match;
|
||||
const commentSyntax = detectCommentSyntax(targetFile);
|
||||
const styleMode = detectStyleMode(targetFile);
|
||||
const isJsx = commentSyntax.open === '{/*';
|
||||
const indent = lines[startLine].match(/^(\s*)/)[1];
|
||||
|
||||
@@ -269,6 +270,10 @@ The agent should insert variant HTML at insertLine.`);
|
||||
endLine: startLine + wrapperLines.length + (originalLines.length - 1), // 1-indexed
|
||||
insertLine: insertLine + 1, // 1-indexed: where variants go
|
||||
commentSyntax: commentSyntax,
|
||||
styleMode: styleMode.mode,
|
||||
styleTag: styleMode.styleTag,
|
||||
cssSelectorPrefixExamples: buildCssSelectorPrefixExamples(styleMode.mode, count),
|
||||
cssAuthoring: buildCssAuthoring(styleMode, count),
|
||||
originalLineCount: originalLines.length,
|
||||
}));
|
||||
}
|
||||
@@ -335,6 +340,62 @@ function detectCommentSyntax(filePath) {
|
||||
return { open: '<!--', close: '-->' };
|
||||
}
|
||||
|
||||
function detectStyleMode(filePath) {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
if (ext === '.astro') {
|
||||
return {
|
||||
mode: 'astro-global-prefixed',
|
||||
styleTag: '<style is:inline data-impeccable-css="SESSION_ID">',
|
||||
};
|
||||
}
|
||||
return {
|
||||
mode: 'scoped',
|
||||
styleTag: '<style data-impeccable-css="SESSION_ID">',
|
||||
};
|
||||
}
|
||||
|
||||
function buildCssSelectorPrefixExamples(styleMode, count) {
|
||||
if (styleMode !== 'astro-global-prefixed') return [];
|
||||
return Array.from({ length: count }, (_, i) => `[data-impeccable-variant="${i + 1}"]`);
|
||||
}
|
||||
|
||||
function buildCssAuthoring(styleMode, count) {
|
||||
const variantNumbers = Array.from({ length: count }, (_, i) => i + 1);
|
||||
if (styleMode.mode === 'astro-global-prefixed') {
|
||||
return {
|
||||
mode: styleMode.mode,
|
||||
styleTag: styleMode.styleTag,
|
||||
strategy: 'global-prefixed',
|
||||
rulePattern: '[data-impeccable-variant="N"] > .variant-class { ... }',
|
||||
selectorExamples: variantNumbers.map((n) => `[data-impeccable-variant="${n}"] > .variant-class`),
|
||||
requirements: [
|
||||
'Use the styleTag exactly; the is:inline attribute is required for this file.',
|
||||
'Prefix every preview selector with the matching [data-impeccable-variant="N"] selector.',
|
||||
'Keep selectors anchored to the generated variant wrapper; do not rely on component CSS scoping for preview rules.',
|
||||
],
|
||||
forbidden: [
|
||||
'Do not use @scope for this styleMode.',
|
||||
],
|
||||
};
|
||||
}
|
||||
return {
|
||||
mode: styleMode.mode,
|
||||
styleTag: styleMode.styleTag,
|
||||
strategy: 'scope-rule',
|
||||
rulePattern: '@scope ([data-impeccable-variant="N"]) { :scope > .variant-class { ... } }',
|
||||
selectorExamples: variantNumbers.map((n) => `@scope ([data-impeccable-variant="${n}"]) { :scope > .variant-class { ... } }`),
|
||||
requirements: [
|
||||
'Use @scope blocks keyed to each [data-impeccable-variant="N"] wrapper.',
|
||||
'Inside each @scope block, make :scope rules step into the replacement element with a descendant combinator.',
|
||||
'Use the styleTag exactly; do not add framework-specific style attributes unless this object says to.',
|
||||
],
|
||||
forbidden: [
|
||||
'Do not use global [data-impeccable-variant="N"] selector prefixes for this styleMode.',
|
||||
'Do not add is:inline to the style tag for this styleMode.',
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Search project files for the query string (class name, ID, etc.)
|
||||
* Returns the first matching file path, or null.
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
* CLI entry point: prepare everything needed to enter the live variant poll loop.
|
||||
*
|
||||
* Does (all in one command):
|
||||
* 1. Check config.json (returns config_missing if first-ever run)
|
||||
* 1. Check .impeccable/live/config.json (returns config_missing if first-ever run)
|
||||
* 2. Start the live server in the background (or reuse a running one)
|
||||
* 3. Inject the browser script tag into the project's entry file
|
||||
* 4. Read .impeccable.md for design context (if present)
|
||||
* 4. Read PRODUCT.md / DESIGN.md for project context
|
||||
* 5. Print a single JSON blob with everything the agent needs
|
||||
*
|
||||
* After this, the agent's only remaining steps are:
|
||||
@@ -23,9 +23,9 @@ import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { loadContext } from './load-context.mjs';
|
||||
import { resolveFiles } from './live-inject.mjs';
|
||||
import { readLiveServerInfo } from './impeccable-paths.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PID_FILE = path.join(process.cwd(), '.impeccable-live.json');
|
||||
|
||||
async function liveCli() {
|
||||
const args = process.argv.slice(2);
|
||||
@@ -34,10 +34,10 @@ async function liveCli() {
|
||||
console.log(`Usage: node live.mjs
|
||||
|
||||
Prepare everything for live variant mode in a single command:
|
||||
- Checks scripts/config.json (required, created once per project)
|
||||
- Checks .impeccable/live/config.json (required, created once per project)
|
||||
- Starts (or reuses) the live server in the background
|
||||
- Injects the browser script tag
|
||||
- Reads .impeccable.md for design context
|
||||
- Reads PRODUCT.md / DESIGN.md for project context
|
||||
|
||||
On success, prints a JSON blob with:
|
||||
{ ok, serverPort, serverToken, pageFile, hasContext, context }
|
||||
@@ -223,7 +223,7 @@ function safeParse(out) {
|
||||
function ensureServerRunning() {
|
||||
// Try to reuse an existing server
|
||||
try {
|
||||
const existing = JSON.parse(fs.readFileSync(PID_FILE, 'utf-8'));
|
||||
const existing = readLiveServerInfo(process.cwd())?.info;
|
||||
if (existing && existing.pid) {
|
||||
try {
|
||||
process.kill(existing.pid, 0); // throws if dead
|
||||
|
||||
@@ -39,7 +39,7 @@ const LEGACY_NAMES = ['.impeccable.md'];
|
||||
const FALLBACK_DIRS = ['.agents/context', 'docs'];
|
||||
|
||||
/**
|
||||
* Resolve the directory that holds PRODUCT.md / DESIGN.md / DESIGN.json for
|
||||
* Resolve the directory that holds PRODUCT.md / DESIGN.md for
|
||||
* this project. Exported so other scripts (e.g. live-server.mjs) can read the
|
||||
* design files from the same location the loader uses.
|
||||
*/
|
||||
|
||||
@@ -237,11 +237,11 @@ Concrete, forceful guardrails. Lead each with "Do" or "Don't". Be specific: incl
|
||||
- **Don't** [...]
|
||||
```
|
||||
|
||||
### Step 4b: Write DESIGN.json sidecar (extensions only)
|
||||
### Step 4b: Write .impeccable/design.json sidecar (extensions only)
|
||||
|
||||
The frontmatter owns token primitives (colors, typography, rounded, spacing, components). The sidecar at `DESIGN.json` carries **what Stitch's schema can't hold**: tonal ramps per color, shadow/elevation tokens, motion tokens, breakpoints, full component HTML/CSS snippets (the panel renders these into a shadow DOM), and narrative (north star, rules, do's/don'ts). It extends the frontmatter, it doesn't duplicate it.
|
||||
The frontmatter owns token primitives (colors, typography, rounded, spacing, components). The sidecar at `.impeccable/design.json` carries **what Stitch's schema can't hold**: tonal ramps per color, shadow/elevation tokens, motion tokens, breakpoints, full component HTML/CSS snippets (the panel renders these into a shadow DOM), and narrative (north star, rules, do's/don'ts). It extends the frontmatter, it doesn't duplicate it.
|
||||
|
||||
Regenerate the sidecar whenever you regenerate DESIGN.md. If the user only asks to refresh the sidecar (e.g., from the live panel's stale-hint), preserve DESIGN.md and write only DESIGN.json.
|
||||
Regenerate the sidecar whenever you regenerate root `DESIGN.md`. If the user only asks to refresh the sidecar (e.g., from the live panel's stale-hint), preserve `DESIGN.md` and write only `.impeccable/design.json`.
|
||||
|
||||
#### Schema
|
||||
|
||||
@@ -310,7 +310,7 @@ Aim for a tight set of **5-10 components** that best represent the visual system
|
||||
- **Signature components (include if distinctive):** hero CTA, featured card, filter pill, any custom pattern the user mentioned as important in PRODUCT.md.
|
||||
- **Skip the rest.** Utility components, form building blocks, wrapper layouts: not worth documenting unless visually distinctive.
|
||||
|
||||
If the project has **no component library yet** (bare landing page, new project), synthesize canonical primitives from the tokens using best-practice defaults consistent with the DESIGN.md's rules. Every DESIGN.json has *something* to render, even on day zero.
|
||||
If the project has **no component library yet** (bare landing page, new project), synthesize canonical primitives from the tokens using best-practice defaults consistent with the DESIGN.md's rules. Every `.impeccable/design.json` has *something* to render, even on day zero.
|
||||
|
||||
#### Tonal ramps
|
||||
|
||||
@@ -331,7 +331,7 @@ Do not reword. The panel shows these as secondary collapsible context; the same
|
||||
### Step 5: Confirm, refine, and refresh session cache
|
||||
|
||||
1. Show the user the full DESIGN.md you wrote. Briefly highlight the non-obvious creative choices (descriptive color names, atmosphere language, named rules).
|
||||
2. Mention that `DESIGN.json` was also written alongside; the live panel will now render this project's actual button/input/nav primitives instead of generic approximations.
|
||||
2. Mention that `.impeccable/design.json` was also written alongside; the live panel will now render this project's actual button/input/nav primitives instead of generic approximations.
|
||||
3. Offer to refine any section: "Want me to revise a section, add component patterns I missed, or adjust the atmosphere language?"
|
||||
4. **Refresh the session cache.** Run `node .gemini/skills/impeccable/scripts/load-context.mjs` one final time so the newly-written DESIGN.md lands in conversation. Subsequent commands in this session will use the fresh version automatically without re-reading.
|
||||
|
||||
@@ -392,7 +392,7 @@ Per-section guidance in seed mode:
|
||||
- **Components**: omit entirely; no components exist yet.
|
||||
- **Do's and Don'ts**: carry PRODUCT.md's anti-references directly plus the anti-reference named in Q5.
|
||||
|
||||
Seed mode writes a minimal frontmatter with `name` and `description` only; no colors, typography, rounded, spacing, or components yet. Real tokens land on the next Scan-mode run. Skip the `DESIGN.json` sidecar in seed mode for the same reason: nothing to render.
|
||||
Seed mode writes a minimal frontmatter with `name` and `description` only; no colors, typography, rounded, spacing, or components yet. Real tokens land on the next Scan-mode run. Skip the `.impeccable/design.json` sidecar in seed mode for the same reason: nothing to render.
|
||||
|
||||
### Step 4: Confirm and refresh session cache
|
||||
|
||||
|
||||
@@ -12,8 +12,9 @@ Execute in order. No step skipped, no step reordered.
|
||||
2. Navigate to the URL that serves `pageFile` (infer from `package.json`, docs, terminal output, or an open tab). If you can't infer it confidently, tell the user once to open their dev/preview URL. Never use `serverPort` as that URL; it's the helper, not the app.
|
||||
3. Poll loop with the default long timeout (600000 ms). After every event or `--reply`, run `live-poll.mjs` again immediately. Never pass a short `--timeout=`.
|
||||
4. On `generate`: read screenshot if present; load the action's reference; plan three distinct directions; write all variants in one edit; `--reply done`; poll again.
|
||||
5. On `accept` / `discard`: the poll script already cleaned up; just poll again.
|
||||
6. On `exit`: run the cleanup at the bottom.
|
||||
5. On `accept` / `discard`: the poll script runs `live-accept.mjs`, acknowledges the delivered event, and prints `_completionAck`. Plain accepts/discards are terminal immediately; carbonize accepts remain recoverable until you finish cleanup, run `live-complete.mjs --id EVENT_ID`, and only then poll again.
|
||||
6. If interrupted, run `live-status.mjs` or `live-resume.mjs` before guessing. The durable journal replays unacknowledged work after helper restart.
|
||||
7. On `exit`: run the cleanup at the bottom.
|
||||
|
||||
Harness policy:
|
||||
- **Claude Code**: run the poll as a **background task** (no short timeout). The harness notifies you when it completes, so the main conversation stays free. Do not block the shell.
|
||||
@@ -43,13 +44,31 @@ LOOP:
|
||||
Read JSON; dispatch on "type"
|
||||
|
||||
"generate" → Handle Generate; reply done; LOOP
|
||||
"accept" → Handle Accept; LOOP
|
||||
"accept" → Handle Accept; complete carbonize cleanup if required; LOOP
|
||||
"discard" → Handle Discard; LOOP
|
||||
"prefetch" → Handle Prefetch; LOOP
|
||||
"timeout" → LOOP
|
||||
"exit" → break → Cleanup
|
||||
```
|
||||
|
||||
## Recovery commands
|
||||
|
||||
The live helper persists an append-only journal under `.impeccable/live/sessions/`. Browser checkpoints are advisory but durable; the journal is canonical. This is local durable recovery state, not project source.
|
||||
|
||||
Use these commands when the chat was interrupted, polling was missed, the helper restarted, or the browser reloaded:
|
||||
|
||||
```bash
|
||||
node .gemini/skills/impeccable/scripts/live-status.mjs
|
||||
node .gemini/skills/impeccable/scripts/live-resume.mjs --id SESSION_ID
|
||||
node .gemini/skills/impeccable/scripts/live-complete.mjs --id SESSION_ID
|
||||
```
|
||||
|
||||
- `live-status.mjs` prints connected helper state, active durable sessions, and queued pending events. It works even when the helper is down by reading the journal directly.
|
||||
- `live-resume.mjs` prints the active snapshot, pending event, checkpoint phase, visible variant, parameter values, and the next safe agent action.
|
||||
- `live-complete.mjs` is the canonical manual final acknowledgement. Use it after carbonize/manual cleanup is verified and no further poll acknowledgement will happen automatically.
|
||||
|
||||
Server restart rule: start `live-server.mjs` again, then poll. Startup requeues unacknowledged pending events from the journal, so do not ask the user to click Go again unless `live-resume.mjs` says no active session exists.
|
||||
|
||||
## Handle `generate`
|
||||
|
||||
Event: `{id, action, freeformPrompt?, count, pageUrl, element, screenshotPath?, comments?, strokes?}`.
|
||||
@@ -88,7 +107,14 @@ The helper searches ID first, then classes, then tag + class combo. If `event.pa
|
||||
|
||||
If `--text` matches multiple candidates equally well, wrap exits with `{ error: "element_ambiguous", candidates: [...] }` and `fallback: "agent-driven"`: read the candidate line ranges, decide which one matches the picked element from page context, and write the wrapper manually per the fallback flow.
|
||||
|
||||
Output on success: `{ file, insertLine, commentSyntax }`.
|
||||
Output on success: `{ file, insertLine, commentSyntax, styleMode, styleTag, cssSelectorPrefixExamples, cssAuthoring }`.
|
||||
|
||||
`styleMode` controls how preview CSS must be authored. Treat it as a detected capability mode, not a framework guess:
|
||||
|
||||
- `scoped`: use `@scope ([data-impeccable-variant="N"])` rules.
|
||||
- `astro-global-prefixed`: use explicit `[data-impeccable-variant="N"]` selector prefixes and the exact `styleTag` returned by the tool.
|
||||
|
||||
Use `cssAuthoring` as the source of truth for the current file. It includes the exact `styleTag`, selector strategy, selector examples, requirements, and forbidden patterns. Do not apply a framework-specific exception unless the returned `styleMode` / `cssAuthoring.mode` says to.
|
||||
|
||||
**Fallback errors.** Wrap only writes into files it judges to be source (tracked by git, not marked GENERATED, not listed in config's `generatedFiles`). If it can't land on a source file, it errors without writing; accepting a variant into a generated file is silent data loss. Three shapes:
|
||||
|
||||
@@ -208,13 +234,14 @@ When the prompt and PRODUCT.md anti-references conflict (the prompt asks for X,
|
||||
|
||||
Complete HTML replacement of the original element for each variant, not a CSS-only patch. Consider the element's context (computed styles, parent structure, CSS variables from `event.element`).
|
||||
|
||||
Write CSS + all variants in ONE edit at the `insertLine` reported by `wrap`. Colocate scoped CSS as a `<style>` tag inside the variant wrapper; `<style>` works anywhere in modern browsers and this ensures CSS and HTML arrive atomically (no FOUC).
|
||||
Write CSS + all variants in ONE edit at the `insertLine` reported by `wrap`. Colocate CSS as a `<style>` tag inside the variant wrapper; `<style>` works anywhere in modern browsers and this ensures CSS and HTML arrive atomically (no FOUC).
|
||||
|
||||
Use the `cssAuthoring` object returned by `live-wrap.mjs` to author the temporary preview CSS. The style opening tag shown below is the common case; replace it with `cssAuthoring.styleTag` when the tool returns a different one. The variant markup shape is otherwise stable:
|
||||
|
||||
```html
|
||||
<!-- Variants: insert below this line -->
|
||||
<style data-impeccable-css="SESSION_ID">
|
||||
@scope ([data-impeccable-variant="1"]) { ... }
|
||||
@scope ([data-impeccable-variant="2"]) { ... }
|
||||
/* rules matching cssAuthoring.rulePattern */
|
||||
</style>
|
||||
<div data-impeccable-variant="1">
|
||||
<!-- variant 1: full element replacement (single top-level element) -->
|
||||
@@ -229,11 +256,11 @@ Write CSS + all variants in ONE edit at the `insertLine` reported by `wrap`. Col
|
||||
|
||||
**Each variant div contains exactly one top-level element: the full replacement for the original.** Use the same tag as the original (e.g. `<section>` if the user picked a `<section>`). 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 `<style>` tag entirely. Use `@scope` for CSS isolation (Chrome 118+ / Firefox 128+ / Safari 17.4+).
|
||||
The first variant has no `display: none` (visible by default). All others do. If variants use only inline styles and no preview CSS, omit the `<style>` tag entirely.
|
||||
|
||||
One edit, all variants; the browser's MutationObserver picks everything up in one pass.
|
||||
|
||||
**Author every `:scope` rule with a descendant combinator.** The `@scope` boundary is the **variant wrapper `<div data-impeccable-variant="N">`**, not the element you're designing. A bare `:scope { background: cream; }` styles the wrapper, not the inner replacement, so the cream lands on a `display: contents` shell while the actual element keeps page defaults. Always step in: `:scope > .card`, `:scope > section`, `:scope .hero-title`, etc. The fake test agent's CSS in `tests/live-e2e/agent.mjs` is a faithful template; every rule starts `:scope > ...`.
|
||||
For `styleMode: "scoped"`, author every `:scope` rule with a descendant combinator. The `@scope` boundary is the **variant wrapper `<div data-impeccable-variant="N">`**, not the element you're designing. A bare `:scope { background: cream; }` styles the wrapper, not the inner replacement, so the cream lands on a `display: contents` shell while the actual element keeps page defaults. Always step in: `:scope > .card`, `:scope > section`, `:scope .hero-title`, etc. The fake test agent's CSS in `tests/live-e2e/agent.mjs` is a faithful template; every scoped rule starts `:scope > ...`.
|
||||
|
||||
**JSX / TSX target files.** Wrap `<style>` content in a template literal so the CSS `{` / `}` aren't parsed as JSX expressions, and use `className=` / `style={{…}}` on every variant element. Keep `data-impeccable-*` attributes as-is; they're plain strings:
|
||||
|
||||
@@ -369,10 +396,11 @@ Remove the wrapper you inserted in Step 2. Nothing else to do.
|
||||
|
||||
## Handle `accept`
|
||||
|
||||
Event: `{id, variantId, _acceptResult}`. The poll script already ran `live-accept.mjs` to handle the file operation deterministically; the browser DOM is already updated.
|
||||
Event: `{id, variantId, _acceptResult, _completionAck}`. The poll script already ran `live-accept.mjs` to handle the file operation deterministically, then acknowledged event delivery to the helper. The browser DOM is already updated.
|
||||
|
||||
- `_completionAck.ok !== true`: do not poll yet. Run `live-status.mjs` / `live-resume.mjs`, complete the cleanup manually if needed, then run `live-complete.mjs --id EVENT_ID`.
|
||||
- `_acceptResult.handled: true` and `carbonize: false`: nothing to do. Poll again.
|
||||
- `_acceptResult.handled: true` and `carbonize: true`: **post-accept cleanup is required before the next poll.** See the "Required after accept (carbonize)" section below. The `event._acceptResult.todo` field and a stderr banner both list the steps explicitly; neither is decorative.
|
||||
- `_acceptResult.handled: true` and `carbonize: true`: **post-accept cleanup is required before the next poll.** See the "Required after accept (carbonize)" section below. The `event._acceptResult.todo` field, `_completionAck.requiresComplete`, and a stderr banner all point at this required follow-up; none are decorative. After cleanup, run `live-complete.mjs --id EVENT_ID`, then poll again.
|
||||
- `_acceptResult.handled: false, mode: "fallback"`: the session lived in a generated file and the script refused to persist there. You've already written the accepted variant into true source during Handle fallback Step 3; just clean up the temporary wrapper in the served file if any, and poll again.
|
||||
- `_acceptResult.handled: false` without `mode`: manual cleanup: read file, find markers, edit.
|
||||
|
||||
@@ -388,13 +416,13 @@ Do these five steps in the current thread, synchronously, before the next poll.
|
||||
4. **Unwrap the accepted content.** Delete the `<div data-impeccable-variant="N" style="display: contents">` that wraps it. Drop `data-impeccable-params` and any `data-p-*` attributes from it; those are live-mode plumbing, not source.
|
||||
5. **Delete the inline `<style>` block, the `<!-- impeccable-param-values -->` comment if present, and both `<!-- impeccable-carbonize-start/end -->` markers.** Also drop any `@scope` rules for variants other than the accepted one; those are dead code now.
|
||||
|
||||
Then poll again.
|
||||
After the file is clean, run `live-complete.mjs --id SESSION_ID`, verify it reports `phase: "completed"`, then poll again.
|
||||
|
||||
A background agent may be used for the rewrite, but the current thread is responsible for verifying the five steps are complete before issuing the next poll. In practice, inline is usually faster and less error-prone.
|
||||
|
||||
## Handle `discard`
|
||||
|
||||
Event: `{id, _acceptResult}`. The poll script already restored the original and removed all variant markers. Nothing to do. Poll again.
|
||||
Event: `{id, _acceptResult, _completionAck}`. The poll script already restored the original, removed all variant markers, and acknowledged `discarded` durable completion. Nothing to do unless `_completionAck.ok !== true`; in that case run `live-complete.mjs --id EVENT_ID --discarded`, then poll again.
|
||||
|
||||
## Handle `prefetch`
|
||||
|
||||
@@ -424,7 +452,7 @@ When the poll returns `exit`, proceed to cleanup. If the poll is still running a
|
||||
node .gemini/skills/impeccable/scripts/live-server.mjs stop
|
||||
```
|
||||
|
||||
Stops the HTTP server and runs `live-inject.mjs --remove` to strip `localhost:…/live.js` from the HTML entry. To stop the server but keep the inject tag (for a quick restart), use `stop --keep-inject`. `config.json` persists for future sessions.
|
||||
Stops the HTTP server and runs `live-inject.mjs --remove` to strip `localhost:…/live.js` from the HTML entry. To stop the server but keep the inject tag (for a quick restart), use `stop --keep-inject`. `.impeccable/live/config.json` persists as project config for future sessions.
|
||||
|
||||
Then:
|
||||
- Remove any leftover variant wrappers (search for `impeccable-variants-start` markers).
|
||||
@@ -432,7 +460,7 @@ Then:
|
||||
|
||||
## First-time setup (config missing or invalid)
|
||||
|
||||
If `live.mjs` outputs `{ ok: false, error: "config_missing" | "config_invalid", path }`, write `config.json` at the reported path.
|
||||
If `live.mjs` outputs `{ ok: false, error: "config_missing" | "config_invalid", path }`, write the live config at the reported path. By default this is `.impeccable/live/config.json`.
|
||||
|
||||
Schema:
|
||||
|
||||
@@ -512,7 +540,7 @@ node .gemini/skills/impeccable/scripts/detect-csp.mjs
|
||||
|
||||
Output: `{ shape, signals }` where `shape` is one of `append-arrays`, `append-string`, `middleware`, `meta-tag`, or `null`. The shape is named by *patch mechanism*, so one template covers many frameworks.
|
||||
|
||||
- **`null`**: no CSP; skip to writing `config.json` with `cspChecked: true`.
|
||||
- **`null`**: no CSP; skip to writing `.impeccable/live/config.json` with `cspChecked: true`.
|
||||
- **`append-arrays`**: CSP defined as structured directive arrays. Auto-patchable. See *append-arrays* below. Covers:
|
||||
- Monorepo helpers with `additionalScriptSrc` / `additionalConnectSrc` options (Next.js + shared config package)
|
||||
- SvelteKit `kit.csp.directives`
|
||||
@@ -589,6 +617,6 @@ Reference outputs:
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
If a user says "no" to the CSP patch at setup time and later complains that live doesn't work: their dev CSP blocks `http://localhost:8400`. Fix: delete `cspChecked` from `config.json` and re-run `live.mjs`: setup will ask again.
|
||||
If a user says "no" to the CSP patch at setup time and later complains that live doesn't work: their dev CSP blocks `http://localhost:8400`. Fix: delete `cspChecked` from `.impeccable/live/config.json` and re-run `live.mjs`: setup will ask again.
|
||||
|
||||
Then re-run `live.mjs`.
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
Gathers design context for a project and writes two complementary files at the project root:
|
||||
|
||||
- **PRODUCT.md** (strategic): register, target users, product purpose, brand personality, anti-references, strategic design principles. Answers "who/what/why".
|
||||
- **DESIGN.md** (visual): visual theme, color palette, typography, components, layout. Follows the [Google Stitch DESIGN.md format](https://stitch.withgoogle.com/docs/design-md/format/). Answers "how it looks".
|
||||
- **PRODUCT.md** (strategic): root project file for register, target users, product purpose, brand personality, anti-references, strategic design principles. Answers "who/what/why".
|
||||
- **DESIGN.md** (visual): root project file for visual theme, color palette, typography, components, layout. Follows the [Google Stitch DESIGN.md format](https://stitch.withgoogle.com/docs/design-md/format/). Answers "how it looks".
|
||||
|
||||
Every other impeccable command reads these files before doing any work.
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
export const IMPECCABLE_DIR = '.impeccable';
|
||||
export const LIVE_DIR = 'live';
|
||||
|
||||
export function getImpeccableDir(cwd = process.cwd()) {
|
||||
return path.join(cwd, IMPECCABLE_DIR);
|
||||
}
|
||||
|
||||
export function getDesignSidecarPath(cwd = process.cwd()) {
|
||||
return path.join(getImpeccableDir(cwd), 'design.json');
|
||||
}
|
||||
|
||||
export function getDesignSidecarCandidates(cwd = process.cwd(), contextDir = cwd) {
|
||||
const candidates = [
|
||||
getDesignSidecarPath(cwd),
|
||||
path.join(cwd, 'DESIGN.json'),
|
||||
];
|
||||
const contextLegacy = path.join(contextDir, 'DESIGN.json');
|
||||
if (!candidates.includes(contextLegacy)) candidates.push(contextLegacy);
|
||||
return candidates;
|
||||
}
|
||||
|
||||
export function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd) {
|
||||
return firstExisting(getDesignSidecarCandidates(cwd, contextDir));
|
||||
}
|
||||
|
||||
export function getLiveDir(cwd = process.cwd()) {
|
||||
return path.join(getImpeccableDir(cwd), LIVE_DIR);
|
||||
}
|
||||
|
||||
export function getLiveConfigPath(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'config.json');
|
||||
}
|
||||
|
||||
export function getLegacyLiveConfigPath(scriptsDir) {
|
||||
return path.join(scriptsDir, 'config.json');
|
||||
}
|
||||
|
||||
export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = process.env } = {}) {
|
||||
if (env.IMPECCABLE_LIVE_CONFIG && env.IMPECCABLE_LIVE_CONFIG.trim()) {
|
||||
const configured = env.IMPECCABLE_LIVE_CONFIG.trim();
|
||||
return path.isAbsolute(configured) ? configured : path.resolve(cwd, configured);
|
||||
}
|
||||
const primary = getLiveConfigPath(cwd);
|
||||
if (fs.existsSync(primary)) return primary;
|
||||
if (scriptsDir) {
|
||||
const legacy = getLegacyLiveConfigPath(scriptsDir);
|
||||
if (fs.existsSync(legacy)) return legacy;
|
||||
}
|
||||
return primary;
|
||||
}
|
||||
|
||||
export function getLiveServerPath(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'server.json');
|
||||
}
|
||||
|
||||
export function getLegacyLiveServerPath(cwd = process.cwd()) {
|
||||
return path.join(cwd, '.impeccable-live.json');
|
||||
}
|
||||
|
||||
export function readLiveServerInfo(cwd = process.cwd()) {
|
||||
for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) {
|
||||
try {
|
||||
return { info: JSON.parse(fs.readFileSync(filePath, 'utf-8')), path: filePath };
|
||||
} catch {
|
||||
/* try next */
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function writeLiveServerInfo(cwd = process.cwd(), info) {
|
||||
const filePath = getLiveServerPath(cwd);
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, JSON.stringify(info));
|
||||
return filePath;
|
||||
}
|
||||
|
||||
export function removeLiveServerInfo(cwd = process.cwd()) {
|
||||
for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) {
|
||||
try { fs.unlinkSync(filePath); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
export function getLiveSessionsDir(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'sessions');
|
||||
}
|
||||
|
||||
export function getLegacyLiveSessionsDir(cwd = process.cwd()) {
|
||||
return path.join(cwd, '.impeccable-live', 'sessions');
|
||||
}
|
||||
|
||||
export function getLiveAnnotationsDir(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'annotations');
|
||||
}
|
||||
|
||||
export function getLegacyLiveAnnotationsDir(cwd = process.cwd()) {
|
||||
return path.join(cwd, '.impeccable-live', 'annotations');
|
||||
}
|
||||
|
||||
function firstExisting(paths) {
|
||||
return paths.find((filePath) => fs.existsSync(filePath)) || null;
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Browser-side durable session helpers for Impeccable live mode.
|
||||
*
|
||||
* Kept separate from live-browser.js so recovery state can be tested without
|
||||
* booting the full overlay UI. Served before live-browser.js and attached to
|
||||
* window.__IMPECCABLE_LIVE_SESSION__.
|
||||
*/
|
||||
(function (root) {
|
||||
'use strict';
|
||||
|
||||
function createLiveBrowserSessionState({ prefix, storage, idFactory }) {
|
||||
if (!prefix) throw new Error('prefix required');
|
||||
const store = storage || root.localStorage;
|
||||
const makeId = idFactory || function () { return Math.random().toString(16).slice(2, 10); };
|
||||
const sessionKey = prefix + '-session';
|
||||
const handledKey = sessionKey + '-handled';
|
||||
const scrollKey = sessionKey + '-scroll';
|
||||
let checkpointRevision = 0;
|
||||
const owner = makeId();
|
||||
|
||||
function safeRead(key) {
|
||||
try { return store.getItem(key); } catch { return null; }
|
||||
}
|
||||
|
||||
function safeWrite(key, value) {
|
||||
try { store.setItem(key, value); } catch { /* quota exceeded or private mode */ }
|
||||
}
|
||||
|
||||
function safeRemove(key) {
|
||||
try { store.removeItem(key); } catch { /* unavailable storage */ }
|
||||
}
|
||||
|
||||
function loadSession() {
|
||||
try {
|
||||
const raw = safeRead(sessionKey);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Number.isInteger(parsed.checkpointRevision)) {
|
||||
checkpointRevision = Math.max(checkpointRevision, parsed.checkpointRevision);
|
||||
}
|
||||
return parsed;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
function saveSession(session) {
|
||||
if (!session || !session.id) return;
|
||||
const payload = {
|
||||
...session,
|
||||
checkpointRevision,
|
||||
};
|
||||
safeWrite(sessionKey, JSON.stringify(payload));
|
||||
}
|
||||
|
||||
function clearSession() {
|
||||
safeRemove(sessionKey);
|
||||
}
|
||||
|
||||
function nextCheckpointRevision() {
|
||||
checkpointRevision += 1;
|
||||
const existing = loadSession();
|
||||
if (existing?.id) saveSession(existing);
|
||||
return checkpointRevision;
|
||||
}
|
||||
|
||||
function seedCheckpointRevision(value) {
|
||||
if (Number.isInteger(value)) checkpointRevision = Math.max(checkpointRevision, value);
|
||||
return checkpointRevision;
|
||||
}
|
||||
|
||||
function currentCheckpointRevision() {
|
||||
return checkpointRevision;
|
||||
}
|
||||
|
||||
function markHandled(id) {
|
||||
if (!id) return;
|
||||
safeWrite(handledKey, id);
|
||||
}
|
||||
|
||||
function isHandled(id) {
|
||||
return !!id && safeRead(handledKey) === id;
|
||||
}
|
||||
|
||||
function clearHandled() {
|
||||
safeRemove(handledKey);
|
||||
}
|
||||
|
||||
function writeScrollY(y) {
|
||||
safeWrite(scrollKey, String(y));
|
||||
}
|
||||
|
||||
function readScrollY() {
|
||||
const raw = safeRead(scrollKey);
|
||||
if (raw == null) return null;
|
||||
const n = parseFloat(raw);
|
||||
return isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
function clearScrollY() {
|
||||
safeRemove(scrollKey);
|
||||
}
|
||||
|
||||
return {
|
||||
owner,
|
||||
sessionKey,
|
||||
handledKey,
|
||||
scrollKey,
|
||||
saveSession,
|
||||
loadSession,
|
||||
clearSession,
|
||||
nextCheckpointRevision,
|
||||
seedCheckpointRevision,
|
||||
currentCheckpointRevision,
|
||||
markHandled,
|
||||
isHandled,
|
||||
clearHandled,
|
||||
writeScrollY,
|
||||
readScrollY,
|
||||
clearScrollY,
|
||||
};
|
||||
}
|
||||
|
||||
root.__IMPECCABLE_LIVE_SESSION__ = { createLiveBrowserSessionState };
|
||||
})(typeof window !== 'undefined' ? window : globalThis);
|
||||
@@ -50,6 +50,16 @@
|
||||
const Z = { highlight: 100001, bar: 100005, picker: 100007, toast: 100010 };
|
||||
const EASE = 'cubic-bezier(0.22, 1, 0.36, 1)'; // ease-out-quint
|
||||
const PREFIX = 'impeccable-live';
|
||||
const sessionState = window.__IMPECCABLE_LIVE_SESSION__?.createLiveBrowserSessionState({
|
||||
prefix: PREFIX,
|
||||
storage: localStorage,
|
||||
idFactory: () => crypto.randomUUID().replace(/-/g, '').slice(0, 8),
|
||||
});
|
||||
if (!sessionState) {
|
||||
console.error('[impeccable] live-browser-session.js was not loaded. Live mode cannot start safely.');
|
||||
window.__IMPECCABLE_LIVE_INIT__ = false;
|
||||
return;
|
||||
}
|
||||
const HIGHLIGHT_TRANSITION =
|
||||
'top 140ms ' + EASE +
|
||||
', left 140ms ' + EASE +
|
||||
@@ -112,6 +122,8 @@
|
||||
let hasProjectContext = false;
|
||||
let selectedAction = 'impeccable';
|
||||
let selectedCount = 3;
|
||||
const browserOwner = sessionState.owner;
|
||||
let checkpointTimer = null;
|
||||
|
||||
// Scroll lock — holds window.scrollY at a fixed value while the session is
|
||||
// active, so HMR DOM patches and variant swaps can't drift the page. See
|
||||
@@ -126,21 +138,9 @@
|
||||
// (Previously: saveSession wrote scrollY alongside state, so every call
|
||||
// during resume overwrote the pre-reload value with whatever the browser
|
||||
// had landed on, typically 0.)
|
||||
const SCROLL_KEY_SUFFIX = '-scroll';
|
||||
function writeScrollY(y) {
|
||||
try { localStorage.setItem(LS_KEY + SCROLL_KEY_SUFFIX, String(y)); } catch {}
|
||||
}
|
||||
function readScrollY() {
|
||||
try {
|
||||
const raw = localStorage.getItem(LS_KEY + SCROLL_KEY_SUFFIX);
|
||||
if (raw == null) return null;
|
||||
const n = parseFloat(raw);
|
||||
return isFinite(n) ? n : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
function clearScrollY() {
|
||||
try { localStorage.removeItem(LS_KEY + SCROLL_KEY_SUFFIX); } catch {}
|
||||
}
|
||||
function writeScrollY(y) { sessionState.writeScrollY(y); }
|
||||
function readScrollY() { return sessionState.readScrollY(); }
|
||||
function clearScrollY() { sessionState.clearScrollY(); }
|
||||
|
||||
// Pre-empt the browser: apply manual scroll restoration and jump to the
|
||||
// saved scrollY at script-parse time. Retries on fonts.ready and load
|
||||
@@ -1585,6 +1585,7 @@
|
||||
paramsCurrentValues[p.id] = v;
|
||||
readout.textContent = formatRangeValue(input);
|
||||
applyParamValue(variantEl, p, v);
|
||||
queueCheckpoint('param_changed');
|
||||
});
|
||||
row.appendChild(input);
|
||||
} else if (p.kind === 'toggle') {
|
||||
@@ -1615,6 +1616,7 @@
|
||||
knob.style.left = next ? '18px' : '2px';
|
||||
readout.textContent = next ? 'On' : 'Off';
|
||||
applyParamValue(variantEl, p, next);
|
||||
queueCheckpoint('param_changed');
|
||||
});
|
||||
row.appendChild(track);
|
||||
} else if (p.kind === 'steps') {
|
||||
@@ -1651,6 +1653,7 @@
|
||||
btn.style.color = on ? 'oklch(98% 0 0)' : P.text;
|
||||
});
|
||||
applyParamValue(variantEl, p, o.value);
|
||||
queueCheckpoint('param_changed');
|
||||
});
|
||||
segRow.appendChild(b);
|
||||
segBtns.push({ btn: b, val: o.value });
|
||||
@@ -1872,19 +1875,26 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const previousVisibleVariant = currentSessionId === sessionId ? visibleVariant : 0;
|
||||
|
||||
// Replace the live element with the full wrapper from source
|
||||
const wrapper = srcWrapper.cloneNode(true);
|
||||
liveEl.parentElement.replaceChild(wrapper, liveEl);
|
||||
|
||||
// Update state: count variants, show the first one
|
||||
// Update state: count variants, preserving the user's current variant
|
||||
// when a late HMR/source reinjection lands after they have cycled.
|
||||
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
|
||||
arrivedVariants = variants.length;
|
||||
expectedVariants = parseInt(wrapper.dataset.impeccableVariantCount || arrivedVariants);
|
||||
visibleVariant = 1;
|
||||
showVariantInDOM(sessionId, 1);
|
||||
const saved = loadSession();
|
||||
const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0;
|
||||
visibleVariant = previousVisibleVariant > 0 && previousVisibleVariant <= arrivedVariants
|
||||
? previousVisibleVariant
|
||||
: (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1);
|
||||
showVariantInDOM(sessionId, visibleVariant);
|
||||
|
||||
// Update selectedElement to the visible variant's content
|
||||
selectedElement = pickVariantContent(wrapper, 1) || wrapper.parentElement;
|
||||
selectedElement = pickVariantContent(wrapper, visibleVariant) || wrapper.parentElement;
|
||||
|
||||
state = 'CYCLING';
|
||||
hideShaderOverlay();
|
||||
@@ -1907,6 +1917,7 @@
|
||||
updateSelectedElement();
|
||||
updateBarContent('cycling');
|
||||
saveSession();
|
||||
queueCheckpoint('variant_changed');
|
||||
}
|
||||
|
||||
function updateSelectedElement() {
|
||||
@@ -1917,6 +1928,18 @@
|
||||
if (visEl) selectedElement = visEl;
|
||||
}
|
||||
|
||||
function readVisibleVariantFromDOM(sessionId) {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return 0;
|
||||
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
|
||||
for (const variant of variants) {
|
||||
if (variant.style.display === 'none') continue;
|
||||
const idx = parseInt(variant.dataset.impeccableVariant || '0', 10);
|
||||
if (idx > 0) return idx;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Resolve the element that represents the variant's visible content.
|
||||
// Contract: each variant div should contain exactly one top-level element
|
||||
// (the full replacement). In practice a model may ship loose siblings or
|
||||
@@ -2107,8 +2130,10 @@
|
||||
updating = true;
|
||||
arrivedVariants = count;
|
||||
if (visibleVariant === 0 && arrivedVariants > 0) {
|
||||
visibleVariant = 1;
|
||||
showVariantInDOM(sessionId, 1);
|
||||
const saved = loadSession();
|
||||
const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0;
|
||||
visibleVariant = savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1;
|
||||
showVariantInDOM(sessionId, visibleVariant);
|
||||
// showVariantInDOM hid the original (display:none); if we were still
|
||||
// anchored to the original's content, its boundingRect is now zero
|
||||
// and the bar snaps to (0,0). Re-point at the visible variant instead.
|
||||
@@ -2128,6 +2153,7 @@
|
||||
updateBarContent('generating');
|
||||
}
|
||||
saveSession();
|
||||
queueCheckpoint(state === 'CYCLING' ? 'variants_ready' : 'variants_progress');
|
||||
updating = false;
|
||||
});
|
||||
|
||||
@@ -2236,6 +2262,7 @@
|
||||
|
||||
/** Server died or became unreachable. Reset UI to a clean state. */
|
||||
function handleServerLost() {
|
||||
const recoveryState = currentSessionId ? state : 'IDLE';
|
||||
if (state === 'GENERATING' || state === 'CYCLING' || state === 'SAVING') {
|
||||
showToast('Live server disconnected. Session ended.', 5000);
|
||||
}
|
||||
@@ -2246,21 +2273,61 @@
|
||||
stopScrollTracking();
|
||||
if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
|
||||
stopScrollLock();
|
||||
clearScrollY();
|
||||
clearSession();
|
||||
// Preserve local session state on server loss. The durable journal is the
|
||||
// source of truth, but localStorage plus the variant wrapper lets the UI
|
||||
// resume after a helper restart or page reload instead of treating a
|
||||
// transient disconnect as an explicit discard.
|
||||
selectedElement = null;
|
||||
currentSessionId = null;
|
||||
selectedAction = 'impeccable';
|
||||
state = 'IDLE';
|
||||
state = recoveryState;
|
||||
if (currentSessionId) saveSession();
|
||||
}
|
||||
|
||||
function sendEvent(msg) {
|
||||
function sendEvent(msg, opts) {
|
||||
msg.token = TOKEN;
|
||||
fetch('http://localhost:' + PORT + '/events', {
|
||||
function handleFailure(err) {
|
||||
console.error('[impeccable] Failed to send event:', err);
|
||||
if (opts && opts.throwOnError) throw err;
|
||||
return null;
|
||||
}
|
||||
return fetch('http://localhost:' + PORT + '/events', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(msg),
|
||||
}).catch(err => console.error('[impeccable] Failed to send event:', err));
|
||||
}).then(res => {
|
||||
if (res.ok) return res;
|
||||
return handleFailure(new Error('HTTP ' + res.status + ' ' + res.statusText));
|
||||
}).catch(handleFailure);
|
||||
}
|
||||
|
||||
function checkpointPayload(reason) {
|
||||
return {
|
||||
type: 'checkpoint',
|
||||
id: currentSessionId,
|
||||
revision: sessionState.nextCheckpointRevision(),
|
||||
owner: browserOwner,
|
||||
phase: String(state || '').toLowerCase(),
|
||||
reason,
|
||||
pageUrl: location.pathname,
|
||||
expectedVariants,
|
||||
arrivedVariants,
|
||||
visibleVariant,
|
||||
paramValues: { ...paramsCurrentValues },
|
||||
};
|
||||
}
|
||||
|
||||
function sendCheckpoint(reason) {
|
||||
if (!currentSessionId) return Promise.resolve(null);
|
||||
return sendEvent(checkpointPayload(reason)).catch(() => null);
|
||||
}
|
||||
|
||||
function queueCheckpoint(reason) {
|
||||
if (!currentSessionId) return;
|
||||
if (checkpointTimer) clearTimeout(checkpointTimer);
|
||||
checkpointTimer = setTimeout(() => {
|
||||
checkpointTimer = null;
|
||||
sendCheckpoint(reason);
|
||||
}, 120);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -2496,6 +2563,7 @@
|
||||
state = 'GENERATING';
|
||||
showBar('generating');
|
||||
saveSession();
|
||||
sendCheckpoint('generate_started');
|
||||
writeScrollY(window.scrollY);
|
||||
if (variantObserver) variantObserver.disconnect();
|
||||
variantObserver = startVariantObserver(currentSessionId);
|
||||
@@ -2920,13 +2988,12 @@ void main() {
|
||||
|
||||
function handleAccept() {
|
||||
if (!currentSessionId || arrivedVariants === 0) return;
|
||||
const domVisibleVariant = readVisibleVariantFromDOM(currentSessionId);
|
||||
if (domVisibleVariant > 0) visibleVariant = domVisibleVariant;
|
||||
const acceptPayload = { type: 'accept', id: currentSessionId, variantId: String(visibleVariant) };
|
||||
if (Object.keys(paramsCurrentValues).length > 0) {
|
||||
acceptPayload.paramValues = { ...paramsCurrentValues };
|
||||
}
|
||||
sendEvent(acceptPayload);
|
||||
markSessionHandled();
|
||||
|
||||
// The accepted variant is already the only visible child of the wrapper
|
||||
// (all other variants are display:none). HMR from the source rewrite will
|
||||
// replace the wrapper imminently. Don't eagerly replaceChild here — React
|
||||
@@ -2936,9 +3003,28 @@ void main() {
|
||||
const acceptedSessionId = currentSessionId;
|
||||
const acceptedVariant = visibleVariant;
|
||||
|
||||
state = 'CONFIRMED';
|
||||
updateBarContent('confirmed');
|
||||
setTimeout(function() {
|
||||
state = 'SAVING';
|
||||
updateBarContent('saving');
|
||||
|
||||
sendEvent(acceptPayload, { throwOnError: true })
|
||||
.then(() => {
|
||||
markSessionHandled();
|
||||
confirmAcceptAfterReceipt();
|
||||
})
|
||||
.catch(() => {
|
||||
state = 'CYCLING';
|
||||
updateBarContent('cycling');
|
||||
showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000);
|
||||
});
|
||||
|
||||
function confirmAcceptAfterReceipt() {
|
||||
state = 'CONFIRMED';
|
||||
updateBarContent('confirmed');
|
||||
scheduleAcceptCleanup();
|
||||
}
|
||||
|
||||
function scheduleAcceptCleanup() {
|
||||
setTimeout(function() {
|
||||
hideBar();
|
||||
hideHighlight();
|
||||
stopScrollTracking();
|
||||
@@ -2967,50 +3053,46 @@ void main() {
|
||||
accepted.style.display = 'contents';
|
||||
parent.replaceChild(accepted, wrapper);
|
||||
}
|
||||
}, 2000);
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDiscard() {
|
||||
if (!currentSessionId) return;
|
||||
sendEvent({ type: 'discard', id: currentSessionId });
|
||||
markSessionHandled();
|
||||
// Instant DOM restore + fire-and-forget (script handles file cleanup)
|
||||
cleanup();
|
||||
sendEvent({ type: 'discard', id: currentSessionId }, { throwOnError: true })
|
||||
.then(() => {
|
||||
markSessionHandled();
|
||||
cleanup();
|
||||
})
|
||||
.catch(() => showToast('Could not confirm discard with the live server. Session kept for recovery.', 5000));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session persistence via localStorage
|
||||
// Session persistence via live-browser-session.js
|
||||
// ---------------------------------------------------------------------------
|
||||
// Survives page reloads, browser close/reopen, HMR, and accidental refreshes.
|
||||
|
||||
const LS_KEY = PREFIX + '-session';
|
||||
|
||||
function saveSession() {
|
||||
if (!currentSessionId) return;
|
||||
// NOTE: scrollY is stored under a separate key (writeScrollY). Storing
|
||||
// it here would overwrite the Go-time value every time state changes.
|
||||
try {
|
||||
localStorage.setItem(LS_KEY, JSON.stringify({
|
||||
id: currentSessionId,
|
||||
state: state,
|
||||
action: selectedAction,
|
||||
count: selectedCount,
|
||||
expected: expectedVariants,
|
||||
arrived: arrivedVariants,
|
||||
visible: visibleVariant,
|
||||
}));
|
||||
} catch { /* quota exceeded or private mode */ }
|
||||
sessionState.saveSession({
|
||||
id: currentSessionId,
|
||||
state,
|
||||
action: selectedAction,
|
||||
count: selectedCount,
|
||||
expected: expectedVariants,
|
||||
arrived: arrivedVariants,
|
||||
visible: visibleVariant,
|
||||
});
|
||||
}
|
||||
|
||||
function loadSession() {
|
||||
try {
|
||||
const raw = localStorage.getItem(LS_KEY);
|
||||
return raw ? JSON.parse(raw) : null;
|
||||
} catch { return null; }
|
||||
return sessionState.loadSession();
|
||||
}
|
||||
|
||||
function clearSession() {
|
||||
try { localStorage.removeItem(LS_KEY); } catch {}
|
||||
sessionState.clearSession();
|
||||
}
|
||||
|
||||
/** Mark session as handled (accepted/discarded). The agent will clean up
|
||||
@@ -3018,19 +3100,15 @@ void main() {
|
||||
* prevents resumeSession from picking it up again after reload. */
|
||||
function markSessionHandled() {
|
||||
if (!currentSessionId) return;
|
||||
try {
|
||||
localStorage.setItem(LS_KEY + '-handled', currentSessionId);
|
||||
} catch {}
|
||||
sessionState.markHandled(currentSessionId);
|
||||
}
|
||||
|
||||
function isSessionHandled(id) {
|
||||
try {
|
||||
return localStorage.getItem(LS_KEY + '-handled') === id;
|
||||
} catch { return false; }
|
||||
return sessionState.isHandled(id);
|
||||
}
|
||||
|
||||
function clearHandled() {
|
||||
try { localStorage.removeItem(LS_KEY + '-handled'); } catch {}
|
||||
sessionState.clearHandled();
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
@@ -3161,6 +3239,7 @@ void main() {
|
||||
// hid. Now that state is CYCLING, re-fire.
|
||||
if (state === 'CYCLING') refreshParamsPanel();
|
||||
saveSession();
|
||||
queueCheckpoint('browser_resumed');
|
||||
|
||||
// Start observing for more variants AFTER initial setup
|
||||
if (variantObserver) variantObserver.disconnect();
|
||||
@@ -3617,7 +3696,7 @@ void main() {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Design System Panel — visualizes the project's DESIGN.json sidecar
|
||||
// Design System Panel — visualizes the project's .impeccable/design.json sidecar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DESIGN_PREFS_KEY = 'impeccable-live-design-panel';
|
||||
@@ -3629,7 +3708,7 @@ void main() {
|
||||
open: false,
|
||||
tab: 'visual', // 'visual' | 'raw'
|
||||
parsed: null, // parseDesignMd output (frontmatter + body sections)
|
||||
sidecar: null, // DESIGN.json v2 payload (extensions + components + narrative)
|
||||
sidecar: null, // .impeccable/design.json v2 payload (extensions + components + narrative)
|
||||
hasMd: false,
|
||||
hasSidecar: false,
|
||||
present: null, // true/false once fetch resolves
|
||||
@@ -4130,7 +4209,7 @@ void main() {
|
||||
box.className = 'stale';
|
||||
box.innerHTML = `
|
||||
<span class="stale-dot"></span>
|
||||
<span class="stale-text"><strong>DESIGN.md is newer than DESIGN.json.</strong> Run <code>/impeccable document</code> to refresh the sidecar.</span>
|
||||
<span class="stale-text"><strong>DESIGN.md is newer than .impeccable/design.json.</strong> Run <code>/impeccable document</code> to refresh the sidecar.</span>
|
||||
`;
|
||||
return box;
|
||||
}
|
||||
@@ -4138,7 +4217,7 @@ void main() {
|
||||
function renderParsedMdCta() {
|
||||
const box = document.createElement('div');
|
||||
box.className = 'parsed-md-cta';
|
||||
box.innerHTML = `<strong>Basic view</strong>This panel reads the tokens in your <code>DESIGN.md</code> frontmatter. Running <code>/impeccable document</code> also generates a <code>DESIGN.json</code> sidecar with your project's actual component snippets (button, input, nav) and tonal ramps, rendered live below the tokens.`;
|
||||
box.innerHTML = `<strong>Basic view</strong>This panel reads the tokens in your <code>DESIGN.md</code> frontmatter. Running <code>/impeccable document</code> also generates a <code>.impeccable/design.json</code> sidecar with your project's actual component snippets (button, input, nav) and tonal ramps, rendered live below the tokens.`;
|
||||
return box;
|
||||
}
|
||||
|
||||
@@ -4598,7 +4677,7 @@ void main() {
|
||||
|
||||
function cssSafe(v) {
|
||||
// Strip anything outside valid CSS value chars to prevent injection via
|
||||
// DESIGN.json values rendered into inline style strings.
|
||||
// .impeccable/design.json values rendered into inline style strings.
|
||||
return String(v).replace(/[<>"'`\n]/g, '');
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Canonical durable completion acknowledgement for Impeccable live sessions.
|
||||
*/
|
||||
|
||||
import { createLiveSessionStore } from './live-session-store.mjs';
|
||||
import { readLiveServerInfo } from './impeccable-paths.mjs';
|
||||
|
||||
function parseArgs(argv) {
|
||||
const out = { status: 'complete' };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
if (arg === '--id') out.id = argv[++i];
|
||||
else if (arg.startsWith('--id=')) out.id = arg.slice('--id='.length);
|
||||
else if (arg === '--discarded' || arg === '--discard') out.status = 'discarded';
|
||||
else if (arg === '--error') { out.status = 'agent_error'; out.message = argv[++i] || 'unknown error'; }
|
||||
else if (arg.startsWith('--error=')) { out.status = 'agent_error'; out.message = arg.slice('--error='.length); }
|
||||
else if (arg === '--help' || arg === '-h') out.help = true;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function completeCli() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (args.help || !args.id) {
|
||||
console.log(`Usage: node live-complete.mjs --id SESSION_ID [--discarded|--error MESSAGE]\n\nAppend the final durable session acknowledgement. Use after accept/discard cleanup is verified.`);
|
||||
process.exit(args.help ? 0 : 1);
|
||||
}
|
||||
|
||||
const serverInfo = readServerInfo();
|
||||
const serverResult = serverInfo ? await completeThroughServer(serverInfo, args) : null;
|
||||
if (serverResult?.ok) {
|
||||
const store = createLiveSessionStore({ cwd: process.cwd(), sessionId: args.id });
|
||||
const snapshot = store.getSnapshot(args.id, { includeCompleted: true });
|
||||
console.log(JSON.stringify({ ok: true, id: args.id, phase: snapshot?.phase || args.status, snapshot }, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
const store = createLiveSessionStore({ cwd: process.cwd(), sessionId: args.id });
|
||||
const event = args.status === 'discarded'
|
||||
? { type: 'discarded', id: args.id }
|
||||
: args.status === 'agent_error'
|
||||
? { type: 'agent_error', id: args.id, message: args.message || 'unknown error' }
|
||||
: { type: 'complete', id: args.id };
|
||||
const snapshot = store.appendEvent(event);
|
||||
console.log(JSON.stringify({ ok: true, id: args.id, phase: snapshot.phase, snapshot }, null, 2));
|
||||
}
|
||||
|
||||
function readServerInfo() {
|
||||
return readLiveServerInfo(process.cwd())?.info || null;
|
||||
}
|
||||
|
||||
async function completeThroughServer(info, args) {
|
||||
const type = args.status === 'discarded'
|
||||
? 'discarded'
|
||||
: args.status === 'agent_error'
|
||||
? 'error'
|
||||
: 'complete';
|
||||
try {
|
||||
const res = await fetch(`http://localhost:${info.port}/poll`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token: info.token, id: args.id, type, message: args.message }),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
return await res.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const _running = process.argv[1];
|
||||
if (_running?.endsWith('live-complete.mjs') || _running?.endsWith('live-complete.mjs/')) {
|
||||
completeCli();
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
export function completionTypeForAcceptResult(eventType, acceptResult) {
|
||||
if (eventType === 'discard') return acceptResult?.handled === true ? 'discarded' : 'error';
|
||||
if (acceptResult?.handled === true && acceptResult?.carbonize === true) return 'agent_done';
|
||||
if (acceptResult?.handled === true) return 'complete';
|
||||
if (acceptResult?.mode === 'error') return 'error';
|
||||
return 'agent_done';
|
||||
}
|
||||
|
||||
export function completionAckForAcceptResult(eventId, completionType, acceptResult) {
|
||||
const ack = { ok: true, type: completionType };
|
||||
if (acceptResult?.handled === true && acceptResult?.carbonize === true) {
|
||||
ack.final = false;
|
||||
ack.requiresComplete = true;
|
||||
ack.nextCommand = `live-complete.mjs --id ${eventId}`;
|
||||
ack.message = 'Carbonize cleanup must be verified, then the session must be completed explicitly before polling again.';
|
||||
}
|
||||
return ack;
|
||||
}
|
||||
@@ -2,23 +2,24 @@
|
||||
* CLI helper: insert/remove the live variant mode script tag in the project's
|
||||
* main HTML entry point.
|
||||
*
|
||||
* On first live run, the agent generates `config.json` in this script's
|
||||
* directory with the project's insertion target (framework-specific). On
|
||||
* On first live run, the agent generates `.impeccable/live/config.json`
|
||||
* with the project's insertion target (framework-specific). On
|
||||
* every subsequent run, this script handles insert/remove deterministically
|
||||
* with zero LLM involvement.
|
||||
*
|
||||
* Usage:
|
||||
* node live-inject.mjs --port PORT # Insert the live script tag
|
||||
* node live-inject.mjs --remove # Remove the live script tag
|
||||
* node live-inject.mjs --check # Check whether config.json exists
|
||||
* node live-inject.mjs --check # Check whether live config exists
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { resolveLiveConfigPath } from './impeccable-paths.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const CONFIG_PATH = process.env.IMPECCABLE_LIVE_CONFIG || path.join(__dirname, 'config.json');
|
||||
const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname });
|
||||
const MARKER_OPEN_TEXT = 'impeccable-live-start';
|
||||
const MARKER_CLOSE_TEXT = 'impeccable-live-end';
|
||||
|
||||
@@ -39,12 +40,12 @@ export async function injectCli() {
|
||||
console.log(`Usage: node live-inject.mjs [options]
|
||||
|
||||
Insert or remove the live mode script tag in the project's HTML entry point.
|
||||
Reads configuration from config.json (in this same directory).
|
||||
Reads configuration from .impeccable/live/config.json.
|
||||
|
||||
Modes:
|
||||
--port PORT Insert script tag pointing at http://localhost:PORT/live.js
|
||||
--remove Remove the script tag (if present)
|
||||
--check Print whether config.json exists and its content
|
||||
--check Print whether .impeccable/live/config.json exists and its content
|
||||
|
||||
Output (JSON):
|
||||
{ ok, file, inserted|removed, config? }`);
|
||||
|
||||
@@ -9,10 +9,10 @@
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { completionAckForAcceptResult, completionTypeForAcceptResult } from './live-completion.mjs';
|
||||
import { readLiveServerInfo } from './impeccable-paths.mjs';
|
||||
|
||||
// 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
|
||||
@@ -20,15 +20,29 @@ import { fileURLToPath } from 'node:url';
|
||||
// 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 {
|
||||
const record = readLiveServerInfo(process.cwd());
|
||||
if (!record) {
|
||||
console.error('No running live server found. Start one with: npx impeccable live');
|
||||
process.exit(1);
|
||||
}
|
||||
return record.info;
|
||||
}
|
||||
|
||||
export function buildPollReplyPayload(token, { id, type, message, file, data }) {
|
||||
return { token, id, type, message, file, data };
|
||||
}
|
||||
|
||||
async function postReply(base, token, reply) {
|
||||
const res = await fetch(`${base}/poll`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(buildPollReplyPayload(token, reply)),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error || res.statusText);
|
||||
}
|
||||
}
|
||||
|
||||
export async function pollCli() {
|
||||
@@ -69,23 +83,7 @@ Options:
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
await postReply(base, info.token, { id, type: status, message, file: filePath });
|
||||
|
||||
// Success — silent exit (agent doesn't need output for replies)
|
||||
} catch (err) {
|
||||
@@ -156,7 +154,23 @@ Options:
|
||||
);
|
||||
event._acceptResult = JSON.parse(out.trim());
|
||||
} catch (err) {
|
||||
event._acceptResult = { handled: false, error: err.message };
|
||||
event._acceptResult = { handled: false, mode: 'error', error: err.message };
|
||||
}
|
||||
|
||||
const completionType = completionTypeForAcceptResult(event.type, event._acceptResult);
|
||||
try {
|
||||
await postReply(base, info.token, {
|
||||
id: event.id,
|
||||
type: completionType,
|
||||
message: event._acceptResult?.error,
|
||||
file: event._acceptResult?.file,
|
||||
data: event._acceptResult?.carbonize === true ? { carbonize: true } : undefined,
|
||||
});
|
||||
} catch (err) {
|
||||
event._completionAck = { ok: false, error: err.message };
|
||||
}
|
||||
if (!event._completionAck) {
|
||||
event._completionAck = completionAckForAcceptResult(event.id, completionType, event._acceptResult);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,7 +178,7 @@ Options:
|
||||
// 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');
|
||||
process.stderr.write('\n⚠ Carbonize cleanup REQUIRED before next poll. After cleanup, run live-complete.mjs --id ' + event.id + '. See reference/live.md "Required after accept".\n\n');
|
||||
}
|
||||
|
||||
// Print the event as JSON — the agent reads this from stdout
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Recover the next agent action from the durable live-session journal.
|
||||
*/
|
||||
|
||||
import { createLiveSessionStore } from './live-session-store.mjs';
|
||||
|
||||
function parseArgs(argv) {
|
||||
const out = { id: null };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
if (arg === '--id') out.id = argv[++i];
|
||||
else if (arg.startsWith('--id=')) out.id = arg.slice('--id='.length);
|
||||
else if (arg === '--help' || arg === '-h') out.help = true;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function resumeCli() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (args.help) {
|
||||
console.log(`Usage: node live-resume.mjs [--id SESSION_ID]\n\nPrint the active durable session checkpoint and the next safe agent action.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const store = createLiveSessionStore({ cwd: process.cwd(), sessionId: args.id || undefined });
|
||||
const snapshot = args.id ? store.getSnapshot(args.id) : store.listActiveSessions()[0] || null;
|
||||
if (!snapshot) {
|
||||
console.log(JSON.stringify({ active: false, nextAction: 'No active durable live session found.' }, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
const pending = snapshot.pendingEvent || null;
|
||||
const nextAction = pending
|
||||
? `Run live-poll.mjs, handle ${pending.type} ${pending.id}, then acknowledge with live-poll.mjs --reply ${pending.id} done.`
|
||||
: snapshot.phase === 'carbonize_required'
|
||||
? `Finish carbonize cleanup${snapshot.sourceFile ? ` in ${snapshot.sourceFile}` : ''}, then run live-complete.mjs --id ${snapshot.id}.`
|
||||
: snapshot.phase === 'accept_requested'
|
||||
? `Run live-complete.mjs --id ${snapshot.id} after verifying the accepted variant is written.`
|
||||
: `Inspect ${snapshot.id}; no pending agent event is currently queued.`;
|
||||
|
||||
console.log(JSON.stringify({ active: true, snapshot, pendingEvent: pending, nextAction }, null, 2));
|
||||
}
|
||||
|
||||
const _running = process.argv[1];
|
||||
if (_running?.endsWith('live-resume.mjs') || _running?.endsWith('live-resume.mjs/')) {
|
||||
resumeCli();
|
||||
}
|
||||
@@ -22,14 +22,20 @@ import net from 'node:net';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseDesignMd } from './design-parser.mjs';
|
||||
import { resolveContextDir } from './load-context.mjs';
|
||||
import { createLiveSessionStore } from './live-session-store.mjs';
|
||||
import {
|
||||
getDesignSidecarPath,
|
||||
getLiveAnnotationsDir,
|
||||
readLiveServerInfo,
|
||||
removeLiveServerInfo,
|
||||
resolveDesignSidecarPath,
|
||||
writeLiveServerInfo,
|
||||
} from './impeccable-paths.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');
|
||||
// PRODUCT.md / DESIGN.md / DESIGN.json live wherever load-context.mjs resolves.
|
||||
// Keeps live-server in sync with the loader when users keep the docs in
|
||||
// .agents/context/, docs/, or a path set via IMPECCABLE_CONTEXT_DIR.
|
||||
// PRODUCT.md / DESIGN.md live wherever load-context.mjs resolves. The generated
|
||||
// DESIGN sidecar is project-local at .impeccable/design.json, with legacy
|
||||
// DESIGN.json fallback for existing projects.
|
||||
const CONTEXT_DIR = resolveContextDir(process.cwd());
|
||||
const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway
|
||||
const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s
|
||||
@@ -57,10 +63,12 @@ const state = {
|
||||
token: null,
|
||||
port: null,
|
||||
sseClients: new Set(), // SSE response objects (server→browser push)
|
||||
pendingEvents: [], // browser events waiting for agent poll
|
||||
pendingEvents: [], // browser events waiting for agent ack ({ event, leaseUntil })
|
||||
pendingPolls: [], // agent poll callbacks waiting for browser events
|
||||
exitTimer: null,
|
||||
sessionDir: null, // per-session tmp dir for annotation screenshots
|
||||
sessionStore: null,
|
||||
leaseTimer: null,
|
||||
};
|
||||
|
||||
// Cap per-annotation upload size. A full 1920×1080 PNG is typically <1 MB;
|
||||
@@ -68,13 +76,72 @@ const state = {
|
||||
const MAX_ANNOTATION_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
function enqueueEvent(event) {
|
||||
if (state.pendingPolls.length > 0) {
|
||||
state.pendingPolls.shift()(event);
|
||||
} else {
|
||||
state.pendingEvents.push(event);
|
||||
if (!event || (event.id && state.pendingEvents.some((entry) => entry.event?.id === event.id && entry.event?.type === event.type))) return;
|
||||
state.pendingEvents.push({ event, leaseUntil: 0 });
|
||||
flushPendingPolls();
|
||||
}
|
||||
|
||||
function restorePendingEventsFromStore() {
|
||||
if (!state.sessionStore) return;
|
||||
for (const snapshot of state.sessionStore.listActiveSessions()) {
|
||||
if (snapshot.pendingEvent) enqueueEvent(snapshot.pendingEvent);
|
||||
}
|
||||
}
|
||||
|
||||
function findAvailablePendingEvent(now = Date.now()) {
|
||||
return state.pendingEvents.find((entry) => !entry.leaseUntil || entry.leaseUntil <= now);
|
||||
}
|
||||
|
||||
function leaseEvent(entry, leaseMs) {
|
||||
if (!entry.event?.id) {
|
||||
const idx = state.pendingEvents.indexOf(entry);
|
||||
if (idx !== -1) state.pendingEvents.splice(idx, 1);
|
||||
return entry.event;
|
||||
}
|
||||
entry.leaseUntil = Date.now() + leaseMs;
|
||||
return entry.event;
|
||||
}
|
||||
|
||||
function acknowledgePendingEvent(id) {
|
||||
if (!id) return false;
|
||||
const idx = state.pendingEvents.findIndex((entry) => entry.event?.id === id);
|
||||
if (idx === -1) return false;
|
||||
state.pendingEvents.splice(idx, 1);
|
||||
scheduleLeaseFlush();
|
||||
return true;
|
||||
}
|
||||
|
||||
function scheduleLeaseFlush() {
|
||||
if (state.leaseTimer) {
|
||||
clearTimeout(state.leaseTimer);
|
||||
state.leaseTimer = null;
|
||||
}
|
||||
if (state.pendingPolls.length === 0) return;
|
||||
const now = Date.now();
|
||||
const nextLeaseUntil = state.pendingEvents
|
||||
.map((entry) => entry.leaseUntil || 0)
|
||||
.filter((leaseUntil) => leaseUntil > now)
|
||||
.sort((a, b) => a - b)[0];
|
||||
if (!nextLeaseUntil) return;
|
||||
state.leaseTimer = setTimeout(() => {
|
||||
state.leaseTimer = null;
|
||||
flushPendingPolls();
|
||||
}, Math.max(0, nextLeaseUntil - now));
|
||||
}
|
||||
|
||||
function flushPendingPolls() {
|
||||
while (state.pendingPolls.length > 0) {
|
||||
const entry = findAvailablePendingEvent();
|
||||
if (!entry) {
|
||||
scheduleLeaseFlush();
|
||||
return;
|
||||
}
|
||||
const poll = state.pendingPolls.shift();
|
||||
poll.resolve(leaseEvent(entry, poll.leaseMs));
|
||||
}
|
||||
scheduleLeaseFlush();
|
||||
}
|
||||
|
||||
/** Push a message to all connected SSE clients. */
|
||||
function broadcast(msg) {
|
||||
const data = 'data: ' + JSON.stringify(msg) + '\n\n';
|
||||
@@ -103,13 +170,16 @@ function loadBrowserScripts() {
|
||||
// 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 sessionPath = path.join(__dirname, 'live-browser-session.js');
|
||||
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);
|
||||
for (const p of [sessionPath, livePath]) {
|
||||
if (!fs.existsSync(p)) {
|
||||
process.stderr.write('Error: live browser script not found at ' + p + '\n');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
return { detectScript, livePath };
|
||||
return { detectScript, sessionPath, livePath };
|
||||
}
|
||||
|
||||
function hasProjectContext() {
|
||||
@@ -170,6 +240,13 @@ function validateEvent(msg) {
|
||||
return null;
|
||||
case 'discard':
|
||||
return isValidId(msg.id) ? null : 'discard: missing or malformed id';
|
||||
case 'checkpoint':
|
||||
if (!isValidId(msg.id)) return 'checkpoint: missing or malformed id';
|
||||
if (!Number.isInteger(msg.revision) || msg.revision < 0) return 'checkpoint: revision must be a non-negative integer';
|
||||
if (msg.paramValues !== undefined && (typeof msg.paramValues !== 'object' || msg.paramValues === null || Array.isArray(msg.paramValues))) {
|
||||
return 'checkpoint: paramValues must be an object';
|
||||
}
|
||||
return null;
|
||||
case 'exit':
|
||||
return null;
|
||||
case 'prefetch':
|
||||
@@ -184,7 +261,7 @@ function validateEvent(msg) {
|
||||
// HTTP request handler
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createRequestHandler({ detectScript, livePath }) {
|
||||
function createRequestHandler({ detectScript, sessionPath, livePath }) {
|
||||
return (req, res) => {
|
||||
const url = new URL(req.url, `http://localhost:${state.port}`);
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
@@ -200,17 +277,20 @@ function createRequestHandler({ detectScript, livePath }) {
|
||||
// the next tab reload. No-store headers prevent browser caching across
|
||||
// sessions — during iteration, a cached old script silently breaks
|
||||
// every subsequent session.
|
||||
let sessionScript;
|
||||
let liveScript;
|
||||
try {
|
||||
sessionScript = fs.readFileSync(sessionPath, 'utf-8');
|
||||
liveScript = fs.readFileSync(livePath, 'utf-8');
|
||||
} catch (err) {
|
||||
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
||||
res.end('Error reading live-browser.js: ' + err.message);
|
||||
res.end('Error reading live browser scripts: ' + err.message);
|
||||
return;
|
||||
}
|
||||
const body =
|
||||
`window.__IMPECCABLE_TOKEN__ = '${state.token}';\n` +
|
||||
`window.__IMPECCABLE_PORT__ = ${state.port};\n` +
|
||||
sessionScript + '\n' +
|
||||
liveScript;
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/javascript',
|
||||
@@ -305,6 +385,26 @@ function createRequestHandler({ detectScript, livePath }) {
|
||||
}
|
||||
|
||||
// --- Health ---
|
||||
if (p === '/status') {
|
||||
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 sessions = state.sessionStore ? state.sessionStore.listActiveSessions() : [];
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
status: 'ok',
|
||||
port: state.port,
|
||||
connectedClients: state.sseClients.size,
|
||||
pendingEvents: state.pendingEvents.map((entry) => ({
|
||||
id: entry.event?.id,
|
||||
type: entry.event?.type,
|
||||
leased: !!(entry.leaseUntil && entry.leaseUntil > Date.now()),
|
||||
leaseUntil: entry.leaseUntil || null,
|
||||
})),
|
||||
activeSessions: sessions,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (p === '/health') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
@@ -316,13 +416,13 @@ function createRequestHandler({ detectScript, livePath }) {
|
||||
}
|
||||
|
||||
// --- Design system (unified v2 response) + raw ---
|
||||
// /design-system.json returns both parsed DESIGN.md and DESIGN.json
|
||||
// /design-system.json returns both parsed DESIGN.md and .impeccable/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.
|
||||
// - sidecar: .impeccable/design.json contents when present.
|
||||
// Expected shape: schemaVersion 2, carrying
|
||||
// extensions + components + narrative.
|
||||
// /design-system/raw returns DESIGN.md markdown verbatim
|
||||
@@ -331,7 +431,7 @@ function createRequestHandler({ detectScript, livePath }) {
|
||||
if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
|
||||
|
||||
const mdPath = path.join(CONTEXT_DIR, 'DESIGN.md');
|
||||
const jsonPath = path.join(CONTEXT_DIR, 'DESIGN.json');
|
||||
const jsonPath = resolveDesignSidecarPath(process.cwd(), CONTEXT_DIR) || getDesignSidecarPath(process.cwd());
|
||||
const mdStat = statOrNull(mdPath);
|
||||
const jsonStat = statOrNull(jsonPath);
|
||||
|
||||
@@ -367,7 +467,7 @@ function createRequestHandler({ detectScript, livePath }) {
|
||||
try {
|
||||
response.sidecar = JSON.parse(fs.readFileSync(jsonPath, 'utf-8'));
|
||||
} catch (err) {
|
||||
response.sidecarError = 'Failed to parse DESIGN.json: ' + err.message;
|
||||
response.sidecarError = 'Failed to parse .impeccable/design.json: ' + err.message;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -449,7 +549,16 @@ function createRequestHandler({ detectScript, livePath }) {
|
||||
res.end(JSON.stringify({ error }));
|
||||
return;
|
||||
}
|
||||
enqueueEvent(msg);
|
||||
if (state.sessionStore && msg.id) {
|
||||
try {
|
||||
state.sessionStore.appendEvent(msg);
|
||||
} catch (err) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'session_store_append_failed', message: err.message }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (msg.type !== 'checkpoint') enqueueEvent(msg);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ ok: true }));
|
||||
});
|
||||
@@ -492,13 +601,16 @@ function handlePollGet(req, res, url) {
|
||||
return;
|
||||
}
|
||||
const timeout = parseInt(url.searchParams.get('timeout') || DEFAULT_POLL_TIMEOUT, 10);
|
||||
if (state.pendingEvents.length > 0) {
|
||||
const leaseMs = parseInt(url.searchParams.get('leaseMs') || '30000', 10);
|
||||
const available = findAvailablePendingEvent();
|
||||
if (available) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(state.pendingEvents.shift()));
|
||||
res.end(JSON.stringify(leaseEvent(available, leaseMs)));
|
||||
return;
|
||||
}
|
||||
const poll = { resolve, leaseMs };
|
||||
const timer = setTimeout(() => {
|
||||
const idx = state.pendingPolls.indexOf(resolve);
|
||||
const idx = state.pendingPolls.indexOf(poll);
|
||||
if (idx !== -1) state.pendingPolls.splice(idx, 1);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ type: 'timeout' }));
|
||||
@@ -508,10 +620,11 @@ function handlePollGet(req, res, url) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(event));
|
||||
}
|
||||
state.pendingPolls.push(resolve);
|
||||
state.pendingPolls.push(poll);
|
||||
scheduleLeaseFlush();
|
||||
req.on('close', () => {
|
||||
clearTimeout(timer);
|
||||
const idx = state.pendingPolls.indexOf(resolve);
|
||||
const idx = state.pendingPolls.indexOf(poll);
|
||||
if (idx !== -1) state.pendingPolls.splice(idx, 1);
|
||||
});
|
||||
}
|
||||
@@ -531,6 +644,26 @@ function handlePollPost(req, res) {
|
||||
res.end(JSON.stringify({ error: 'Unauthorized' }));
|
||||
return;
|
||||
}
|
||||
acknowledgePendingEvent(msg.id);
|
||||
if (state.sessionStore && msg.id) {
|
||||
try {
|
||||
const eventType = msg.type === 'discard' || msg.type === 'discarded'
|
||||
? 'discarded'
|
||||
: msg.type === 'complete'
|
||||
? 'complete'
|
||||
: msg.type === 'error'
|
||||
? 'agent_error'
|
||||
: 'agent_done';
|
||||
state.sessionStore.appendEvent({
|
||||
type: eventType,
|
||||
id: msg.id,
|
||||
file: msg.file,
|
||||
message: msg.message,
|
||||
carbonize: msg.data?.carbonize === true,
|
||||
});
|
||||
} catch { /* keep reply path best-effort; browser still needs SSE */ }
|
||||
}
|
||||
flushPendingPolls();
|
||||
// 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' });
|
||||
@@ -545,13 +678,15 @@ function handlePollPost(req, res) {
|
||||
let httpServer = null;
|
||||
|
||||
function shutdown() {
|
||||
try { fs.unlinkSync(LIVE_PID_FILE); } catch {}
|
||||
removeLiveServerInfo(process.cwd());
|
||||
if (state.leaseTimer) clearTimeout(state.leaseTimer);
|
||||
state.leaseTimer = null;
|
||||
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' });
|
||||
for (const poll of state.pendingPolls) poll.resolve({ type: 'exit' });
|
||||
state.pendingPolls.length = 0;
|
||||
if (httpServer) httpServer.close();
|
||||
process.exit(0);
|
||||
@@ -587,6 +722,7 @@ Endpoints:
|
||||
/events SSE stream (server→browser) + POST (browser→server)
|
||||
/poll Long-poll for agent CLI
|
||||
/source Raw source file reader (no-HMR fallback)
|
||||
/status Durable recovery status (token-protected)
|
||||
/health Health check`);
|
||||
process.exit(0);
|
||||
}
|
||||
@@ -594,7 +730,7 @@ Endpoints:
|
||||
if (args.includes('stop')) {
|
||||
const keepInject = args.includes('--keep-inject');
|
||||
try {
|
||||
const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
|
||||
const { info } = readLiveServerInfo(process.cwd()) || {};
|
||||
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 {
|
||||
@@ -645,7 +781,7 @@ if (args.includes('--background')) {
|
||||
const deadline = Date.now() + 10_000;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
|
||||
const { info } = readLiveServerInfo(process.cwd()) || {};
|
||||
if (info.pid !== process.pid) {
|
||||
// Output JSON so the agent can read port + token from stdout.
|
||||
console.log(JSON.stringify(info));
|
||||
@@ -659,30 +795,36 @@ if (args.includes('--background')) {
|
||||
}
|
||||
|
||||
// Check for existing session
|
||||
try {
|
||||
const existing = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
|
||||
try { process.kill(existing.pid, 0);
|
||||
const existingRecord = readLiveServerInfo(process.cwd());
|
||||
if (existingRecord?.info) {
|
||||
const existing = existingRecord.info;
|
||||
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 {}
|
||||
} catch {
|
||||
try { fs.unlinkSync(existingRecord.path); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
state.token = randomUUID();
|
||||
state.sessionStore = createLiveSessionStore({ cwd: process.cwd() });
|
||||
restorePendingEventsFromStore();
|
||||
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');
|
||||
const annotRoot = getLiveAnnotationsDir(process.cwd());
|
||||
fs.mkdirSync(annotRoot, { recursive: true });
|
||||
state.sessionDir = fs.mkdtempSync(path.join(annotRoot, 'session-'));
|
||||
|
||||
const { detectScript, livePath } = loadBrowserScripts();
|
||||
httpServer = http.createServer(createRequestHandler({ detectScript, livePath }));
|
||||
const { detectScript, sessionPath, livePath } = loadBrowserScripts();
|
||||
httpServer = http.createServer(createRequestHandler({ detectScript, sessionPath, 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 }));
|
||||
writeLiveServerInfo(process.cwd(), { 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`);
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { getLegacyLiveSessionsDir, getLiveSessionsDir } from './impeccable-paths.mjs';
|
||||
|
||||
const COMPLETED_PHASES = new Set(['completed', 'discarded']);
|
||||
|
||||
export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) {
|
||||
const rootDir = getLiveSessionsDir(cwd);
|
||||
const legacyRootDir = getLegacyLiveSessionsDir(cwd);
|
||||
fs.mkdirSync(rootDir, { recursive: true });
|
||||
const snapshotCache = new Map();
|
||||
|
||||
function loadCachedOrRebuild(id) {
|
||||
const cached = snapshotCache.get(id);
|
||||
if (cached) return cached;
|
||||
const journalPath = getReadableJournalPath(id);
|
||||
const rebuilt = rebuildSnapshotFromJournal(journalPath, id);
|
||||
snapshotCache.set(id, rebuilt);
|
||||
return rebuilt;
|
||||
}
|
||||
|
||||
function getReadableJournalPath(id) {
|
||||
const primary = getJournalPath(rootDir, id);
|
||||
if (fs.existsSync(primary)) return primary;
|
||||
const legacy = getJournalPath(legacyRootDir, id);
|
||||
if (fs.existsSync(legacy)) return legacy;
|
||||
return primary;
|
||||
}
|
||||
|
||||
return {
|
||||
rootDir,
|
||||
legacyRootDir,
|
||||
appendEvent(event) {
|
||||
const normalized = normalizeEvent(event, sessionId);
|
||||
const journalPath = getJournalPath(rootDir, normalized.id);
|
||||
const snapshotPath = getSnapshotPath(rootDir, normalized.id);
|
||||
const legacyJournalPath = getJournalPath(legacyRootDir, normalized.id);
|
||||
if (!fs.existsSync(journalPath) && fs.existsSync(legacyJournalPath)) {
|
||||
fs.copyFileSync(legacyJournalPath, journalPath);
|
||||
}
|
||||
const prior = loadCachedOrRebuild(normalized.id);
|
||||
const seq = prior.nextSeq;
|
||||
const entry = {
|
||||
seq,
|
||||
id: normalized.id,
|
||||
type: normalized.type,
|
||||
ts: new Date().toISOString(),
|
||||
event: normalized,
|
||||
};
|
||||
fs.appendFileSync(journalPath, JSON.stringify(entry) + '\n');
|
||||
const next = applyEvent(prior.snapshot, entry, prior.diagnostics);
|
||||
snapshotCache.set(normalized.id, { snapshot: next, diagnostics: next.diagnostics || [], nextSeq: seq + 1 });
|
||||
writeSnapshot(snapshotPath, next);
|
||||
return next;
|
||||
},
|
||||
getSnapshot(id = sessionId, opts = {}) {
|
||||
if (!id) throw new Error('session id required');
|
||||
const journalPath = getReadableJournalPath(id);
|
||||
const snapshotPath = getSnapshotPath(rootDir, id);
|
||||
const rebuilt = rebuildSnapshotFromJournal(journalPath, id);
|
||||
snapshotCache.set(id, rebuilt);
|
||||
writeSnapshot(snapshotPath, rebuilt.snapshot);
|
||||
if (!opts.includeCompleted && COMPLETED_PHASES.has(rebuilt.snapshot.phase)) return null;
|
||||
return rebuilt.snapshot;
|
||||
},
|
||||
listActiveSessions() {
|
||||
const ids = new Set();
|
||||
for (const dir of [legacyRootDir, rootDir]) {
|
||||
if (!fs.existsSync(dir)) continue;
|
||||
for (const name of fs.readdirSync(dir)) {
|
||||
if (name.endsWith('.jsonl')) ids.add(name.slice(0, -'.jsonl'.length));
|
||||
}
|
||||
}
|
||||
return [...ids]
|
||||
.sort()
|
||||
.map((id) => this.getSnapshot(id))
|
||||
.filter(Boolean);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeEvent(event, fallbackId) {
|
||||
if (!event || typeof event !== 'object') throw new Error('event object required');
|
||||
const id = event.id || fallbackId;
|
||||
if (!id || typeof id !== 'string') throw new Error('event id required');
|
||||
if (!event.type || typeof event.type !== 'string') throw new Error('event type required');
|
||||
return { ...event, id };
|
||||
}
|
||||
|
||||
function getJournalPath(rootDir, id) {
|
||||
return path.join(rootDir, safeSessionId(id) + '.jsonl');
|
||||
}
|
||||
|
||||
function getSnapshotPath(rootDir, id) {
|
||||
return path.join(rootDir, safeSessionId(id) + '.snapshot.json');
|
||||
}
|
||||
|
||||
function safeSessionId(id) {
|
||||
if (!/^[A-Za-z0-9_-]{1,128}$/.test(id)) throw new Error('invalid session id: ' + id);
|
||||
return id;
|
||||
}
|
||||
|
||||
function baseSnapshot(id) {
|
||||
return {
|
||||
id,
|
||||
phase: 'new',
|
||||
pageUrl: null,
|
||||
sourceFile: null,
|
||||
expectedVariants: 0,
|
||||
arrivedVariants: 0,
|
||||
visibleVariant: null,
|
||||
paramValues: {},
|
||||
pendingEventSeq: null,
|
||||
pendingEvent: null,
|
||||
deliveryLease: null,
|
||||
checkpointRevision: 0,
|
||||
activeOwner: null,
|
||||
sourceMarkers: {},
|
||||
fallbackMode: null,
|
||||
annotationArtifacts: [],
|
||||
diagnostics: [],
|
||||
updatedAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
function rebuildSnapshotFromJournal(journalPath, id) {
|
||||
let snapshot = baseSnapshot(id);
|
||||
const diagnostics = [];
|
||||
let nextSeq = 1;
|
||||
if (!fs.existsSync(journalPath)) return { snapshot, diagnostics, nextSeq };
|
||||
|
||||
const lines = fs.readFileSync(journalPath, 'utf-8').split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const entry = JSON.parse(line);
|
||||
if (!entry || typeof entry !== 'object') throw new Error('entry is not object');
|
||||
if (Number.isInteger(entry.seq)) nextSeq = Math.max(nextSeq, entry.seq + 1);
|
||||
snapshot = applyEvent(snapshot, entry);
|
||||
} catch (err) {
|
||||
diagnostics.push({
|
||||
error: 'journal_parse_failed',
|
||||
line: i + 1,
|
||||
message: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
snapshot.diagnostics = [...snapshot.diagnostics, ...diagnostics];
|
||||
return { snapshot, diagnostics, nextSeq };
|
||||
}
|
||||
|
||||
function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
|
||||
const event = entry.event || entry;
|
||||
const next = {
|
||||
...snapshot,
|
||||
paramValues: { ...(snapshot.paramValues || {}) },
|
||||
sourceMarkers: { ...(snapshot.sourceMarkers || {}) },
|
||||
annotationArtifacts: [...(snapshot.annotationArtifacts || [])],
|
||||
diagnostics: [...(snapshot.diagnostics || [])],
|
||||
updatedAt: entry.ts || new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (inheritedDiagnostics.length && next.diagnostics.length === 0) {
|
||||
next.diagnostics = [...inheritedDiagnostics];
|
||||
}
|
||||
|
||||
switch (event.type) {
|
||||
case 'generate':
|
||||
next.phase = 'generate_requested';
|
||||
next.pageUrl = event.pageUrl ?? next.pageUrl;
|
||||
next.expectedVariants = event.count ?? next.expectedVariants;
|
||||
next.pendingEventSeq = entry.seq ?? next.pendingEventSeq;
|
||||
next.pendingEvent = toPendingEvent(event);
|
||||
if (event.screenshotPath) upsertArtifact(next.annotationArtifacts, { type: 'screenshot', path: event.screenshotPath });
|
||||
break;
|
||||
case 'variants_ready':
|
||||
case 'agent_done':
|
||||
next.phase = event.carbonize === true ? 'carbonize_required' : 'variants_ready';
|
||||
next.sourceFile = event.file ?? next.sourceFile;
|
||||
next.arrivedVariants = event.arrivedVariants ?? (next.arrivedVariants ?? next.expectedVariants);
|
||||
next.pendingEventSeq = null;
|
||||
next.pendingEvent = null;
|
||||
if (event.carbonize === true) {
|
||||
next.diagnostics.push({
|
||||
error: 'carbonize_cleanup_required',
|
||||
file: event.file || null,
|
||||
message: 'Accepted variant still has carbonize markers that must be folded into source CSS.',
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'checkpoint':
|
||||
if ((event.revision ?? 0) >= (next.checkpointRevision ?? 0)) {
|
||||
next.phase = event.phase ?? next.phase;
|
||||
next.checkpointRevision = event.revision ?? next.checkpointRevision;
|
||||
next.activeOwner = event.owner ?? next.activeOwner;
|
||||
next.arrivedVariants = event.arrivedVariants ?? next.arrivedVariants;
|
||||
next.visibleVariant = event.visibleVariant ?? next.visibleVariant;
|
||||
if (event.paramValues) next.paramValues = { ...event.paramValues };
|
||||
} else {
|
||||
next.diagnostics.push({ error: 'stale_checkpoint_ignored', revision: event.revision });
|
||||
}
|
||||
break;
|
||||
case 'accept':
|
||||
case 'accept_intent':
|
||||
next.phase = 'accept_requested';
|
||||
next.visibleVariant = Number(event.variantId ?? next.visibleVariant);
|
||||
if (event.paramValues) next.paramValues = { ...event.paramValues };
|
||||
next.pendingEventSeq = entry.seq ?? next.pendingEventSeq;
|
||||
next.pendingEvent = toPendingEvent(event);
|
||||
break;
|
||||
case 'discard':
|
||||
next.phase = 'discard_requested';
|
||||
next.pendingEventSeq = entry.seq ?? next.pendingEventSeq;
|
||||
next.pendingEvent = toPendingEvent(event);
|
||||
break;
|
||||
case 'discarded':
|
||||
next.phase = 'discarded';
|
||||
next.pendingEventSeq = null;
|
||||
next.pendingEvent = null;
|
||||
break;
|
||||
case 'complete':
|
||||
next.phase = 'completed';
|
||||
next.pendingEventSeq = null;
|
||||
next.pendingEvent = null;
|
||||
break;
|
||||
case 'agent_error':
|
||||
next.phase = 'agent_error';
|
||||
next.pendingEventSeq = null;
|
||||
next.pendingEvent = null;
|
||||
next.diagnostics.push({ error: 'agent_error', message: event.message || 'unknown agent error' });
|
||||
break;
|
||||
default:
|
||||
next.diagnostics.push({ error: 'unknown_event_type', type: event.type });
|
||||
break;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function toPendingEvent(event) {
|
||||
const pending = { ...event };
|
||||
delete pending.token;
|
||||
return pending;
|
||||
}
|
||||
|
||||
function upsertArtifact(artifacts, artifact) {
|
||||
if (!artifacts.some((existing) => existing.path === artifact.path && existing.type === artifact.type)) {
|
||||
artifacts.push(artifact);
|
||||
}
|
||||
}
|
||||
|
||||
function writeSnapshot(snapshotPath, snapshot) {
|
||||
fs.writeFileSync(snapshotPath, JSON.stringify(snapshot, null, 2) + '\n');
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Print durable recovery status for Impeccable live sessions.
|
||||
*/
|
||||
|
||||
import { createLiveSessionStore } from './live-session-store.mjs';
|
||||
import { readLiveServerInfo } from './impeccable-paths.mjs';
|
||||
|
||||
function readServerInfo() {
|
||||
return readLiveServerInfo(process.cwd())?.info || null;
|
||||
}
|
||||
|
||||
async function fetchServerStatus(info) {
|
||||
if (!info) return null;
|
||||
try {
|
||||
const res = await fetch(`http://localhost:${info.port}/status?token=${info.token}`);
|
||||
if (!res.ok) return null;
|
||||
return await res.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function statusCli() {
|
||||
const info = readServerInfo();
|
||||
const server = await fetchServerStatus(info);
|
||||
const store = createLiveSessionStore({ cwd: process.cwd() });
|
||||
const activeSessions = store.listActiveSessions();
|
||||
const payload = {
|
||||
liveServer: server ? {
|
||||
status: server.status,
|
||||
port: server.port,
|
||||
connectedClients: server.connectedClients,
|
||||
pendingEvents: server.pendingEvents,
|
||||
} : null,
|
||||
activeSessions: server?.activeSessions || activeSessions,
|
||||
recoveryHint: server
|
||||
? 'Run live-poll.mjs to continue pending work, or live-complete.mjs --id <session> after manual cleanup.'
|
||||
: 'Start live-server.mjs to requeue pending durable events, then run live-poll.mjs.',
|
||||
};
|
||||
console.log(JSON.stringify(payload, null, 2));
|
||||
}
|
||||
|
||||
const _running = process.argv[1];
|
||||
if (_running?.endsWith('live-status.mjs') || _running?.endsWith('live-status.mjs/')) {
|
||||
statusCli();
|
||||
}
|
||||
@@ -185,6 +185,7 @@ The agent should insert variant HTML at insertLine.`);
|
||||
|
||||
const { startLine, endLine } = match;
|
||||
const commentSyntax = detectCommentSyntax(targetFile);
|
||||
const styleMode = detectStyleMode(targetFile);
|
||||
const isJsx = commentSyntax.open === '{/*';
|
||||
const indent = lines[startLine].match(/^(\s*)/)[1];
|
||||
|
||||
@@ -269,6 +270,10 @@ The agent should insert variant HTML at insertLine.`);
|
||||
endLine: startLine + wrapperLines.length + (originalLines.length - 1), // 1-indexed
|
||||
insertLine: insertLine + 1, // 1-indexed: where variants go
|
||||
commentSyntax: commentSyntax,
|
||||
styleMode: styleMode.mode,
|
||||
styleTag: styleMode.styleTag,
|
||||
cssSelectorPrefixExamples: buildCssSelectorPrefixExamples(styleMode.mode, count),
|
||||
cssAuthoring: buildCssAuthoring(styleMode, count),
|
||||
originalLineCount: originalLines.length,
|
||||
}));
|
||||
}
|
||||
@@ -335,6 +340,62 @@ function detectCommentSyntax(filePath) {
|
||||
return { open: '<!--', close: '-->' };
|
||||
}
|
||||
|
||||
function detectStyleMode(filePath) {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
if (ext === '.astro') {
|
||||
return {
|
||||
mode: 'astro-global-prefixed',
|
||||
styleTag: '<style is:inline data-impeccable-css="SESSION_ID">',
|
||||
};
|
||||
}
|
||||
return {
|
||||
mode: 'scoped',
|
||||
styleTag: '<style data-impeccable-css="SESSION_ID">',
|
||||
};
|
||||
}
|
||||
|
||||
function buildCssSelectorPrefixExamples(styleMode, count) {
|
||||
if (styleMode !== 'astro-global-prefixed') return [];
|
||||
return Array.from({ length: count }, (_, i) => `[data-impeccable-variant="${i + 1}"]`);
|
||||
}
|
||||
|
||||
function buildCssAuthoring(styleMode, count) {
|
||||
const variantNumbers = Array.from({ length: count }, (_, i) => i + 1);
|
||||
if (styleMode.mode === 'astro-global-prefixed') {
|
||||
return {
|
||||
mode: styleMode.mode,
|
||||
styleTag: styleMode.styleTag,
|
||||
strategy: 'global-prefixed',
|
||||
rulePattern: '[data-impeccable-variant="N"] > .variant-class { ... }',
|
||||
selectorExamples: variantNumbers.map((n) => `[data-impeccable-variant="${n}"] > .variant-class`),
|
||||
requirements: [
|
||||
'Use the styleTag exactly; the is:inline attribute is required for this file.',
|
||||
'Prefix every preview selector with the matching [data-impeccable-variant="N"] selector.',
|
||||
'Keep selectors anchored to the generated variant wrapper; do not rely on component CSS scoping for preview rules.',
|
||||
],
|
||||
forbidden: [
|
||||
'Do not use @scope for this styleMode.',
|
||||
],
|
||||
};
|
||||
}
|
||||
return {
|
||||
mode: styleMode.mode,
|
||||
styleTag: styleMode.styleTag,
|
||||
strategy: 'scope-rule',
|
||||
rulePattern: '@scope ([data-impeccable-variant="N"]) { :scope > .variant-class { ... } }',
|
||||
selectorExamples: variantNumbers.map((n) => `@scope ([data-impeccable-variant="${n}"]) { :scope > .variant-class { ... } }`),
|
||||
requirements: [
|
||||
'Use @scope blocks keyed to each [data-impeccable-variant="N"] wrapper.',
|
||||
'Inside each @scope block, make :scope rules step into the replacement element with a descendant combinator.',
|
||||
'Use the styleTag exactly; do not add framework-specific style attributes unless this object says to.',
|
||||
],
|
||||
forbidden: [
|
||||
'Do not use global [data-impeccable-variant="N"] selector prefixes for this styleMode.',
|
||||
'Do not add is:inline to the style tag for this styleMode.',
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Search project files for the query string (class name, ID, etc.)
|
||||
* Returns the first matching file path, or null.
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
* CLI entry point: prepare everything needed to enter the live variant poll loop.
|
||||
*
|
||||
* Does (all in one command):
|
||||
* 1. Check config.json (returns config_missing if first-ever run)
|
||||
* 1. Check .impeccable/live/config.json (returns config_missing if first-ever run)
|
||||
* 2. Start the live server in the background (or reuse a running one)
|
||||
* 3. Inject the browser script tag into the project's entry file
|
||||
* 4. Read .impeccable.md for design context (if present)
|
||||
* 4. Read PRODUCT.md / DESIGN.md for project context
|
||||
* 5. Print a single JSON blob with everything the agent needs
|
||||
*
|
||||
* After this, the agent's only remaining steps are:
|
||||
@@ -23,9 +23,9 @@ import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { loadContext } from './load-context.mjs';
|
||||
import { resolveFiles } from './live-inject.mjs';
|
||||
import { readLiveServerInfo } from './impeccable-paths.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PID_FILE = path.join(process.cwd(), '.impeccable-live.json');
|
||||
|
||||
async function liveCli() {
|
||||
const args = process.argv.slice(2);
|
||||
@@ -34,10 +34,10 @@ async function liveCli() {
|
||||
console.log(`Usage: node live.mjs
|
||||
|
||||
Prepare everything for live variant mode in a single command:
|
||||
- Checks scripts/config.json (required, created once per project)
|
||||
- Checks .impeccable/live/config.json (required, created once per project)
|
||||
- Starts (or reuses) the live server in the background
|
||||
- Injects the browser script tag
|
||||
- Reads .impeccable.md for design context
|
||||
- Reads PRODUCT.md / DESIGN.md for project context
|
||||
|
||||
On success, prints a JSON blob with:
|
||||
{ ok, serverPort, serverToken, pageFile, hasContext, context }
|
||||
@@ -223,7 +223,7 @@ function safeParse(out) {
|
||||
function ensureServerRunning() {
|
||||
// Try to reuse an existing server
|
||||
try {
|
||||
const existing = JSON.parse(fs.readFileSync(PID_FILE, 'utf-8'));
|
||||
const existing = readLiveServerInfo(process.cwd())?.info;
|
||||
if (existing && existing.pid) {
|
||||
try {
|
||||
process.kill(existing.pid, 0); // throws if dead
|
||||
|
||||
@@ -39,7 +39,7 @@ const LEGACY_NAMES = ['.impeccable.md'];
|
||||
const FALLBACK_DIRS = ['.agents/context', 'docs'];
|
||||
|
||||
/**
|
||||
* Resolve the directory that holds PRODUCT.md / DESIGN.md / DESIGN.json for
|
||||
* Resolve the directory that holds PRODUCT.md / DESIGN.md for
|
||||
* this project. Exported so other scripts (e.g. live-server.mjs) can read the
|
||||
* design files from the same location the loader uses.
|
||||
*/
|
||||
|
||||
@@ -237,11 +237,11 @@ Concrete, forceful guardrails. Lead each with "Do" or "Don't". Be specific: incl
|
||||
- **Don't** [...]
|
||||
```
|
||||
|
||||
### Step 4b: Write DESIGN.json sidecar (extensions only)
|
||||
### Step 4b: Write .impeccable/design.json sidecar (extensions only)
|
||||
|
||||
The frontmatter owns token primitives (colors, typography, rounded, spacing, components). The sidecar at `DESIGN.json` carries **what Stitch's schema can't hold**: tonal ramps per color, shadow/elevation tokens, motion tokens, breakpoints, full component HTML/CSS snippets (the panel renders these into a shadow DOM), and narrative (north star, rules, do's/don'ts). It extends the frontmatter, it doesn't duplicate it.
|
||||
The frontmatter owns token primitives (colors, typography, rounded, spacing, components). The sidecar at `.impeccable/design.json` carries **what Stitch's schema can't hold**: tonal ramps per color, shadow/elevation tokens, motion tokens, breakpoints, full component HTML/CSS snippets (the panel renders these into a shadow DOM), and narrative (north star, rules, do's/don'ts). It extends the frontmatter, it doesn't duplicate it.
|
||||
|
||||
Regenerate the sidecar whenever you regenerate DESIGN.md. If the user only asks to refresh the sidecar (e.g., from the live panel's stale-hint), preserve DESIGN.md and write only DESIGN.json.
|
||||
Regenerate the sidecar whenever you regenerate root `DESIGN.md`. If the user only asks to refresh the sidecar (e.g., from the live panel's stale-hint), preserve `DESIGN.md` and write only `.impeccable/design.json`.
|
||||
|
||||
#### Schema
|
||||
|
||||
@@ -310,7 +310,7 @@ Aim for a tight set of **5-10 components** that best represent the visual system
|
||||
- **Signature components (include if distinctive):** hero CTA, featured card, filter pill, any custom pattern the user mentioned as important in PRODUCT.md.
|
||||
- **Skip the rest.** Utility components, form building blocks, wrapper layouts: not worth documenting unless visually distinctive.
|
||||
|
||||
If the project has **no component library yet** (bare landing page, new project), synthesize canonical primitives from the tokens using best-practice defaults consistent with the DESIGN.md's rules. Every DESIGN.json has *something* to render, even on day zero.
|
||||
If the project has **no component library yet** (bare landing page, new project), synthesize canonical primitives from the tokens using best-practice defaults consistent with the DESIGN.md's rules. Every `.impeccable/design.json` has *something* to render, even on day zero.
|
||||
|
||||
#### Tonal ramps
|
||||
|
||||
@@ -331,7 +331,7 @@ Do not reword. The panel shows these as secondary collapsible context; the same
|
||||
### Step 5: Confirm, refine, and refresh session cache
|
||||
|
||||
1. Show the user the full DESIGN.md you wrote. Briefly highlight the non-obvious creative choices (descriptive color names, atmosphere language, named rules).
|
||||
2. Mention that `DESIGN.json` was also written alongside; the live panel will now render this project's actual button/input/nav primitives instead of generic approximations.
|
||||
2. Mention that `.impeccable/design.json` was also written alongside; the live panel will now render this project's actual button/input/nav primitives instead of generic approximations.
|
||||
3. Offer to refine any section: "Want me to revise a section, add component patterns I missed, or adjust the atmosphere language?"
|
||||
4. **Refresh the session cache.** Run `node .github/skills/impeccable/scripts/load-context.mjs` one final time so the newly-written DESIGN.md lands in conversation. Subsequent commands in this session will use the fresh version automatically without re-reading.
|
||||
|
||||
@@ -392,7 +392,7 @@ Per-section guidance in seed mode:
|
||||
- **Components**: omit entirely; no components exist yet.
|
||||
- **Do's and Don'ts**: carry PRODUCT.md's anti-references directly plus the anti-reference named in Q5.
|
||||
|
||||
Seed mode writes a minimal frontmatter with `name` and `description` only; no colors, typography, rounded, spacing, or components yet. Real tokens land on the next Scan-mode run. Skip the `DESIGN.json` sidecar in seed mode for the same reason: nothing to render.
|
||||
Seed mode writes a minimal frontmatter with `name` and `description` only; no colors, typography, rounded, spacing, or components yet. Real tokens land on the next Scan-mode run. Skip the `.impeccable/design.json` sidecar in seed mode for the same reason: nothing to render.
|
||||
|
||||
### Step 4: Confirm and refresh session cache
|
||||
|
||||
|
||||
@@ -12,8 +12,9 @@ Execute in order. No step skipped, no step reordered.
|
||||
2. Navigate to the URL that serves `pageFile` (infer from `package.json`, docs, terminal output, or an open tab). If you can't infer it confidently, tell the user once to open their dev/preview URL. Never use `serverPort` as that URL; it's the helper, not the app.
|
||||
3. Poll loop with the default long timeout (600000 ms). After every event or `--reply`, run `live-poll.mjs` again immediately. Never pass a short `--timeout=`.
|
||||
4. On `generate`: read screenshot if present; load the action's reference; plan three distinct directions; write all variants in one edit; `--reply done`; poll again.
|
||||
5. On `accept` / `discard`: the poll script already cleaned up; just poll again.
|
||||
6. On `exit`: run the cleanup at the bottom.
|
||||
5. On `accept` / `discard`: the poll script runs `live-accept.mjs`, acknowledges the delivered event, and prints `_completionAck`. Plain accepts/discards are terminal immediately; carbonize accepts remain recoverable until you finish cleanup, run `live-complete.mjs --id EVENT_ID`, and only then poll again.
|
||||
6. If interrupted, run `live-status.mjs` or `live-resume.mjs` before guessing. The durable journal replays unacknowledged work after helper restart.
|
||||
7. On `exit`: run the cleanup at the bottom.
|
||||
|
||||
Harness policy:
|
||||
- **Claude Code**: run the poll as a **background task** (no short timeout). The harness notifies you when it completes, so the main conversation stays free. Do not block the shell.
|
||||
@@ -43,13 +44,31 @@ LOOP:
|
||||
Read JSON; dispatch on "type"
|
||||
|
||||
"generate" → Handle Generate; reply done; LOOP
|
||||
"accept" → Handle Accept; LOOP
|
||||
"accept" → Handle Accept; complete carbonize cleanup if required; LOOP
|
||||
"discard" → Handle Discard; LOOP
|
||||
"prefetch" → Handle Prefetch; LOOP
|
||||
"timeout" → LOOP
|
||||
"exit" → break → Cleanup
|
||||
```
|
||||
|
||||
## Recovery commands
|
||||
|
||||
The live helper persists an append-only journal under `.impeccable/live/sessions/`. Browser checkpoints are advisory but durable; the journal is canonical. This is local durable recovery state, not project source.
|
||||
|
||||
Use these commands when the chat was interrupted, polling was missed, the helper restarted, or the browser reloaded:
|
||||
|
||||
```bash
|
||||
node .github/skills/impeccable/scripts/live-status.mjs
|
||||
node .github/skills/impeccable/scripts/live-resume.mjs --id SESSION_ID
|
||||
node .github/skills/impeccable/scripts/live-complete.mjs --id SESSION_ID
|
||||
```
|
||||
|
||||
- `live-status.mjs` prints connected helper state, active durable sessions, and queued pending events. It works even when the helper is down by reading the journal directly.
|
||||
- `live-resume.mjs` prints the active snapshot, pending event, checkpoint phase, visible variant, parameter values, and the next safe agent action.
|
||||
- `live-complete.mjs` is the canonical manual final acknowledgement. Use it after carbonize/manual cleanup is verified and no further poll acknowledgement will happen automatically.
|
||||
|
||||
Server restart rule: start `live-server.mjs` again, then poll. Startup requeues unacknowledged pending events from the journal, so do not ask the user to click Go again unless `live-resume.mjs` says no active session exists.
|
||||
|
||||
## Handle `generate`
|
||||
|
||||
Event: `{id, action, freeformPrompt?, count, pageUrl, element, screenshotPath?, comments?, strokes?}`.
|
||||
@@ -88,7 +107,14 @@ The helper searches ID first, then classes, then tag + class combo. If `event.pa
|
||||
|
||||
If `--text` matches multiple candidates equally well, wrap exits with `{ error: "element_ambiguous", candidates: [...] }` and `fallback: "agent-driven"`: read the candidate line ranges, decide which one matches the picked element from page context, and write the wrapper manually per the fallback flow.
|
||||
|
||||
Output on success: `{ file, insertLine, commentSyntax }`.
|
||||
Output on success: `{ file, insertLine, commentSyntax, styleMode, styleTag, cssSelectorPrefixExamples, cssAuthoring }`.
|
||||
|
||||
`styleMode` controls how preview CSS must be authored. Treat it as a detected capability mode, not a framework guess:
|
||||
|
||||
- `scoped`: use `@scope ([data-impeccable-variant="N"])` rules.
|
||||
- `astro-global-prefixed`: use explicit `[data-impeccable-variant="N"]` selector prefixes and the exact `styleTag` returned by the tool.
|
||||
|
||||
Use `cssAuthoring` as the source of truth for the current file. It includes the exact `styleTag`, selector strategy, selector examples, requirements, and forbidden patterns. Do not apply a framework-specific exception unless the returned `styleMode` / `cssAuthoring.mode` says to.
|
||||
|
||||
**Fallback errors.** Wrap only writes into files it judges to be source (tracked by git, not marked GENERATED, not listed in config's `generatedFiles`). If it can't land on a source file, it errors without writing; accepting a variant into a generated file is silent data loss. Three shapes:
|
||||
|
||||
@@ -208,13 +234,14 @@ When the prompt and PRODUCT.md anti-references conflict (the prompt asks for X,
|
||||
|
||||
Complete HTML replacement of the original element for each variant, not a CSS-only patch. Consider the element's context (computed styles, parent structure, CSS variables from `event.element`).
|
||||
|
||||
Write CSS + all variants in ONE edit at the `insertLine` reported by `wrap`. Colocate scoped CSS as a `<style>` tag inside the variant wrapper; `<style>` works anywhere in modern browsers and this ensures CSS and HTML arrive atomically (no FOUC).
|
||||
Write CSS + all variants in ONE edit at the `insertLine` reported by `wrap`. Colocate CSS as a `<style>` tag inside the variant wrapper; `<style>` works anywhere in modern browsers and this ensures CSS and HTML arrive atomically (no FOUC).
|
||||
|
||||
Use the `cssAuthoring` object returned by `live-wrap.mjs` to author the temporary preview CSS. The style opening tag shown below is the common case; replace it with `cssAuthoring.styleTag` when the tool returns a different one. The variant markup shape is otherwise stable:
|
||||
|
||||
```html
|
||||
<!-- Variants: insert below this line -->
|
||||
<style data-impeccable-css="SESSION_ID">
|
||||
@scope ([data-impeccable-variant="1"]) { ... }
|
||||
@scope ([data-impeccable-variant="2"]) { ... }
|
||||
/* rules matching cssAuthoring.rulePattern */
|
||||
</style>
|
||||
<div data-impeccable-variant="1">
|
||||
<!-- variant 1: full element replacement (single top-level element) -->
|
||||
@@ -229,11 +256,11 @@ Write CSS + all variants in ONE edit at the `insertLine` reported by `wrap`. Col
|
||||
|
||||
**Each variant div contains exactly one top-level element: the full replacement for the original.** Use the same tag as the original (e.g. `<section>` if the user picked a `<section>`). 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 `<style>` tag entirely. Use `@scope` for CSS isolation (Chrome 118+ / Firefox 128+ / Safari 17.4+).
|
||||
The first variant has no `display: none` (visible by default). All others do. If variants use only inline styles and no preview CSS, omit the `<style>` tag entirely.
|
||||
|
||||
One edit, all variants; the browser's MutationObserver picks everything up in one pass.
|
||||
|
||||
**Author every `:scope` rule with a descendant combinator.** The `@scope` boundary is the **variant wrapper `<div data-impeccable-variant="N">`**, not the element you're designing. A bare `:scope { background: cream; }` styles the wrapper, not the inner replacement, so the cream lands on a `display: contents` shell while the actual element keeps page defaults. Always step in: `:scope > .card`, `:scope > section`, `:scope .hero-title`, etc. The fake test agent's CSS in `tests/live-e2e/agent.mjs` is a faithful template; every rule starts `:scope > ...`.
|
||||
For `styleMode: "scoped"`, author every `:scope` rule with a descendant combinator. The `@scope` boundary is the **variant wrapper `<div data-impeccable-variant="N">`**, not the element you're designing. A bare `:scope { background: cream; }` styles the wrapper, not the inner replacement, so the cream lands on a `display: contents` shell while the actual element keeps page defaults. Always step in: `:scope > .card`, `:scope > section`, `:scope .hero-title`, etc. The fake test agent's CSS in `tests/live-e2e/agent.mjs` is a faithful template; every scoped rule starts `:scope > ...`.
|
||||
|
||||
**JSX / TSX target files.** Wrap `<style>` content in a template literal so the CSS `{` / `}` aren't parsed as JSX expressions, and use `className=` / `style={{…}}` on every variant element. Keep `data-impeccable-*` attributes as-is; they're plain strings:
|
||||
|
||||
@@ -369,10 +396,11 @@ Remove the wrapper you inserted in Step 2. Nothing else to do.
|
||||
|
||||
## Handle `accept`
|
||||
|
||||
Event: `{id, variantId, _acceptResult}`. The poll script already ran `live-accept.mjs` to handle the file operation deterministically; the browser DOM is already updated.
|
||||
Event: `{id, variantId, _acceptResult, _completionAck}`. The poll script already ran `live-accept.mjs` to handle the file operation deterministically, then acknowledged event delivery to the helper. The browser DOM is already updated.
|
||||
|
||||
- `_completionAck.ok !== true`: do not poll yet. Run `live-status.mjs` / `live-resume.mjs`, complete the cleanup manually if needed, then run `live-complete.mjs --id EVENT_ID`.
|
||||
- `_acceptResult.handled: true` and `carbonize: false`: nothing to do. Poll again.
|
||||
- `_acceptResult.handled: true` and `carbonize: true`: **post-accept cleanup is required before the next poll.** See the "Required after accept (carbonize)" section below. The `event._acceptResult.todo` field and a stderr banner both list the steps explicitly; neither is decorative.
|
||||
- `_acceptResult.handled: true` and `carbonize: true`: **post-accept cleanup is required before the next poll.** See the "Required after accept (carbonize)" section below. The `event._acceptResult.todo` field, `_completionAck.requiresComplete`, and a stderr banner all point at this required follow-up; none are decorative. After cleanup, run `live-complete.mjs --id EVENT_ID`, then poll again.
|
||||
- `_acceptResult.handled: false, mode: "fallback"`: the session lived in a generated file and the script refused to persist there. You've already written the accepted variant into true source during Handle fallback Step 3; just clean up the temporary wrapper in the served file if any, and poll again.
|
||||
- `_acceptResult.handled: false` without `mode`: manual cleanup: read file, find markers, edit.
|
||||
|
||||
@@ -388,13 +416,13 @@ Do these five steps in the current thread, synchronously, before the next poll.
|
||||
4. **Unwrap the accepted content.** Delete the `<div data-impeccable-variant="N" style="display: contents">` that wraps it. Drop `data-impeccable-params` and any `data-p-*` attributes from it; those are live-mode plumbing, not source.
|
||||
5. **Delete the inline `<style>` block, the `<!-- impeccable-param-values -->` comment if present, and both `<!-- impeccable-carbonize-start/end -->` markers.** Also drop any `@scope` rules for variants other than the accepted one; those are dead code now.
|
||||
|
||||
Then poll again.
|
||||
After the file is clean, run `live-complete.mjs --id SESSION_ID`, verify it reports `phase: "completed"`, then poll again.
|
||||
|
||||
A background agent may be used for the rewrite, but the current thread is responsible for verifying the five steps are complete before issuing the next poll. In practice, inline is usually faster and less error-prone.
|
||||
|
||||
## Handle `discard`
|
||||
|
||||
Event: `{id, _acceptResult}`. The poll script already restored the original and removed all variant markers. Nothing to do. Poll again.
|
||||
Event: `{id, _acceptResult, _completionAck}`. The poll script already restored the original, removed all variant markers, and acknowledged `discarded` durable completion. Nothing to do unless `_completionAck.ok !== true`; in that case run `live-complete.mjs --id EVENT_ID --discarded`, then poll again.
|
||||
|
||||
## Handle `prefetch`
|
||||
|
||||
@@ -424,7 +452,7 @@ When the poll returns `exit`, proceed to cleanup. If the poll is still running a
|
||||
node .github/skills/impeccable/scripts/live-server.mjs stop
|
||||
```
|
||||
|
||||
Stops the HTTP server and runs `live-inject.mjs --remove` to strip `localhost:…/live.js` from the HTML entry. To stop the server but keep the inject tag (for a quick restart), use `stop --keep-inject`. `config.json` persists for future sessions.
|
||||
Stops the HTTP server and runs `live-inject.mjs --remove` to strip `localhost:…/live.js` from the HTML entry. To stop the server but keep the inject tag (for a quick restart), use `stop --keep-inject`. `.impeccable/live/config.json` persists as project config for future sessions.
|
||||
|
||||
Then:
|
||||
- Remove any leftover variant wrappers (search for `impeccable-variants-start` markers).
|
||||
@@ -432,7 +460,7 @@ Then:
|
||||
|
||||
## First-time setup (config missing or invalid)
|
||||
|
||||
If `live.mjs` outputs `{ ok: false, error: "config_missing" | "config_invalid", path }`, write `config.json` at the reported path.
|
||||
If `live.mjs` outputs `{ ok: false, error: "config_missing" | "config_invalid", path }`, write the live config at the reported path. By default this is `.impeccable/live/config.json`.
|
||||
|
||||
Schema:
|
||||
|
||||
@@ -512,7 +540,7 @@ node .github/skills/impeccable/scripts/detect-csp.mjs
|
||||
|
||||
Output: `{ shape, signals }` where `shape` is one of `append-arrays`, `append-string`, `middleware`, `meta-tag`, or `null`. The shape is named by *patch mechanism*, so one template covers many frameworks.
|
||||
|
||||
- **`null`**: no CSP; skip to writing `config.json` with `cspChecked: true`.
|
||||
- **`null`**: no CSP; skip to writing `.impeccable/live/config.json` with `cspChecked: true`.
|
||||
- **`append-arrays`**: CSP defined as structured directive arrays. Auto-patchable. See *append-arrays* below. Covers:
|
||||
- Monorepo helpers with `additionalScriptSrc` / `additionalConnectSrc` options (Next.js + shared config package)
|
||||
- SvelteKit `kit.csp.directives`
|
||||
@@ -589,6 +617,6 @@ Reference outputs:
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
If a user says "no" to the CSP patch at setup time and later complains that live doesn't work: their dev CSP blocks `http://localhost:8400`. Fix: delete `cspChecked` from `config.json` and re-run `live.mjs`: setup will ask again.
|
||||
If a user says "no" to the CSP patch at setup time and later complains that live doesn't work: their dev CSP blocks `http://localhost:8400`. Fix: delete `cspChecked` from `.impeccable/live/config.json` and re-run `live.mjs`: setup will ask again.
|
||||
|
||||
Then re-run `live.mjs`.
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
Gathers design context for a project and writes two complementary files at the project root:
|
||||
|
||||
- **PRODUCT.md** (strategic): register, target users, product purpose, brand personality, anti-references, strategic design principles. Answers "who/what/why".
|
||||
- **DESIGN.md** (visual): visual theme, color palette, typography, components, layout. Follows the [Google Stitch DESIGN.md format](https://stitch.withgoogle.com/docs/design-md/format/). Answers "how it looks".
|
||||
- **PRODUCT.md** (strategic): root project file for register, target users, product purpose, brand personality, anti-references, strategic design principles. Answers "who/what/why".
|
||||
- **DESIGN.md** (visual): root project file for visual theme, color palette, typography, components, layout. Follows the [Google Stitch DESIGN.md format](https://stitch.withgoogle.com/docs/design-md/format/). Answers "how it looks".
|
||||
|
||||
Every other impeccable command reads these files before doing any work.
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
export const IMPECCABLE_DIR = '.impeccable';
|
||||
export const LIVE_DIR = 'live';
|
||||
|
||||
export function getImpeccableDir(cwd = process.cwd()) {
|
||||
return path.join(cwd, IMPECCABLE_DIR);
|
||||
}
|
||||
|
||||
export function getDesignSidecarPath(cwd = process.cwd()) {
|
||||
return path.join(getImpeccableDir(cwd), 'design.json');
|
||||
}
|
||||
|
||||
export function getDesignSidecarCandidates(cwd = process.cwd(), contextDir = cwd) {
|
||||
const candidates = [
|
||||
getDesignSidecarPath(cwd),
|
||||
path.join(cwd, 'DESIGN.json'),
|
||||
];
|
||||
const contextLegacy = path.join(contextDir, 'DESIGN.json');
|
||||
if (!candidates.includes(contextLegacy)) candidates.push(contextLegacy);
|
||||
return candidates;
|
||||
}
|
||||
|
||||
export function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd) {
|
||||
return firstExisting(getDesignSidecarCandidates(cwd, contextDir));
|
||||
}
|
||||
|
||||
export function getLiveDir(cwd = process.cwd()) {
|
||||
return path.join(getImpeccableDir(cwd), LIVE_DIR);
|
||||
}
|
||||
|
||||
export function getLiveConfigPath(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'config.json');
|
||||
}
|
||||
|
||||
export function getLegacyLiveConfigPath(scriptsDir) {
|
||||
return path.join(scriptsDir, 'config.json');
|
||||
}
|
||||
|
||||
export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = process.env } = {}) {
|
||||
if (env.IMPECCABLE_LIVE_CONFIG && env.IMPECCABLE_LIVE_CONFIG.trim()) {
|
||||
const configured = env.IMPECCABLE_LIVE_CONFIG.trim();
|
||||
return path.isAbsolute(configured) ? configured : path.resolve(cwd, configured);
|
||||
}
|
||||
const primary = getLiveConfigPath(cwd);
|
||||
if (fs.existsSync(primary)) return primary;
|
||||
if (scriptsDir) {
|
||||
const legacy = getLegacyLiveConfigPath(scriptsDir);
|
||||
if (fs.existsSync(legacy)) return legacy;
|
||||
}
|
||||
return primary;
|
||||
}
|
||||
|
||||
export function getLiveServerPath(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'server.json');
|
||||
}
|
||||
|
||||
export function getLegacyLiveServerPath(cwd = process.cwd()) {
|
||||
return path.join(cwd, '.impeccable-live.json');
|
||||
}
|
||||
|
||||
export function readLiveServerInfo(cwd = process.cwd()) {
|
||||
for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) {
|
||||
try {
|
||||
return { info: JSON.parse(fs.readFileSync(filePath, 'utf-8')), path: filePath };
|
||||
} catch {
|
||||
/* try next */
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function writeLiveServerInfo(cwd = process.cwd(), info) {
|
||||
const filePath = getLiveServerPath(cwd);
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, JSON.stringify(info));
|
||||
return filePath;
|
||||
}
|
||||
|
||||
export function removeLiveServerInfo(cwd = process.cwd()) {
|
||||
for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) {
|
||||
try { fs.unlinkSync(filePath); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
export function getLiveSessionsDir(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'sessions');
|
||||
}
|
||||
|
||||
export function getLegacyLiveSessionsDir(cwd = process.cwd()) {
|
||||
return path.join(cwd, '.impeccable-live', 'sessions');
|
||||
}
|
||||
|
||||
export function getLiveAnnotationsDir(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'annotations');
|
||||
}
|
||||
|
||||
export function getLegacyLiveAnnotationsDir(cwd = process.cwd()) {
|
||||
return path.join(cwd, '.impeccable-live', 'annotations');
|
||||
}
|
||||
|
||||
function firstExisting(paths) {
|
||||
return paths.find((filePath) => fs.existsSync(filePath)) || null;
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Browser-side durable session helpers for Impeccable live mode.
|
||||
*
|
||||
* Kept separate from live-browser.js so recovery state can be tested without
|
||||
* booting the full overlay UI. Served before live-browser.js and attached to
|
||||
* window.__IMPECCABLE_LIVE_SESSION__.
|
||||
*/
|
||||
(function (root) {
|
||||
'use strict';
|
||||
|
||||
function createLiveBrowserSessionState({ prefix, storage, idFactory }) {
|
||||
if (!prefix) throw new Error('prefix required');
|
||||
const store = storage || root.localStorage;
|
||||
const makeId = idFactory || function () { return Math.random().toString(16).slice(2, 10); };
|
||||
const sessionKey = prefix + '-session';
|
||||
const handledKey = sessionKey + '-handled';
|
||||
const scrollKey = sessionKey + '-scroll';
|
||||
let checkpointRevision = 0;
|
||||
const owner = makeId();
|
||||
|
||||
function safeRead(key) {
|
||||
try { return store.getItem(key); } catch { return null; }
|
||||
}
|
||||
|
||||
function safeWrite(key, value) {
|
||||
try { store.setItem(key, value); } catch { /* quota exceeded or private mode */ }
|
||||
}
|
||||
|
||||
function safeRemove(key) {
|
||||
try { store.removeItem(key); } catch { /* unavailable storage */ }
|
||||
}
|
||||
|
||||
function loadSession() {
|
||||
try {
|
||||
const raw = safeRead(sessionKey);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Number.isInteger(parsed.checkpointRevision)) {
|
||||
checkpointRevision = Math.max(checkpointRevision, parsed.checkpointRevision);
|
||||
}
|
||||
return parsed;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
function saveSession(session) {
|
||||
if (!session || !session.id) return;
|
||||
const payload = {
|
||||
...session,
|
||||
checkpointRevision,
|
||||
};
|
||||
safeWrite(sessionKey, JSON.stringify(payload));
|
||||
}
|
||||
|
||||
function clearSession() {
|
||||
safeRemove(sessionKey);
|
||||
}
|
||||
|
||||
function nextCheckpointRevision() {
|
||||
checkpointRevision += 1;
|
||||
const existing = loadSession();
|
||||
if (existing?.id) saveSession(existing);
|
||||
return checkpointRevision;
|
||||
}
|
||||
|
||||
function seedCheckpointRevision(value) {
|
||||
if (Number.isInteger(value)) checkpointRevision = Math.max(checkpointRevision, value);
|
||||
return checkpointRevision;
|
||||
}
|
||||
|
||||
function currentCheckpointRevision() {
|
||||
return checkpointRevision;
|
||||
}
|
||||
|
||||
function markHandled(id) {
|
||||
if (!id) return;
|
||||
safeWrite(handledKey, id);
|
||||
}
|
||||
|
||||
function isHandled(id) {
|
||||
return !!id && safeRead(handledKey) === id;
|
||||
}
|
||||
|
||||
function clearHandled() {
|
||||
safeRemove(handledKey);
|
||||
}
|
||||
|
||||
function writeScrollY(y) {
|
||||
safeWrite(scrollKey, String(y));
|
||||
}
|
||||
|
||||
function readScrollY() {
|
||||
const raw = safeRead(scrollKey);
|
||||
if (raw == null) return null;
|
||||
const n = parseFloat(raw);
|
||||
return isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
function clearScrollY() {
|
||||
safeRemove(scrollKey);
|
||||
}
|
||||
|
||||
return {
|
||||
owner,
|
||||
sessionKey,
|
||||
handledKey,
|
||||
scrollKey,
|
||||
saveSession,
|
||||
loadSession,
|
||||
clearSession,
|
||||
nextCheckpointRevision,
|
||||
seedCheckpointRevision,
|
||||
currentCheckpointRevision,
|
||||
markHandled,
|
||||
isHandled,
|
||||
clearHandled,
|
||||
writeScrollY,
|
||||
readScrollY,
|
||||
clearScrollY,
|
||||
};
|
||||
}
|
||||
|
||||
root.__IMPECCABLE_LIVE_SESSION__ = { createLiveBrowserSessionState };
|
||||
})(typeof window !== 'undefined' ? window : globalThis);
|
||||
@@ -50,6 +50,16 @@
|
||||
const Z = { highlight: 100001, bar: 100005, picker: 100007, toast: 100010 };
|
||||
const EASE = 'cubic-bezier(0.22, 1, 0.36, 1)'; // ease-out-quint
|
||||
const PREFIX = 'impeccable-live';
|
||||
const sessionState = window.__IMPECCABLE_LIVE_SESSION__?.createLiveBrowserSessionState({
|
||||
prefix: PREFIX,
|
||||
storage: localStorage,
|
||||
idFactory: () => crypto.randomUUID().replace(/-/g, '').slice(0, 8),
|
||||
});
|
||||
if (!sessionState) {
|
||||
console.error('[impeccable] live-browser-session.js was not loaded. Live mode cannot start safely.');
|
||||
window.__IMPECCABLE_LIVE_INIT__ = false;
|
||||
return;
|
||||
}
|
||||
const HIGHLIGHT_TRANSITION =
|
||||
'top 140ms ' + EASE +
|
||||
', left 140ms ' + EASE +
|
||||
@@ -112,6 +122,8 @@
|
||||
let hasProjectContext = false;
|
||||
let selectedAction = 'impeccable';
|
||||
let selectedCount = 3;
|
||||
const browserOwner = sessionState.owner;
|
||||
let checkpointTimer = null;
|
||||
|
||||
// Scroll lock — holds window.scrollY at a fixed value while the session is
|
||||
// active, so HMR DOM patches and variant swaps can't drift the page. See
|
||||
@@ -126,21 +138,9 @@
|
||||
// (Previously: saveSession wrote scrollY alongside state, so every call
|
||||
// during resume overwrote the pre-reload value with whatever the browser
|
||||
// had landed on, typically 0.)
|
||||
const SCROLL_KEY_SUFFIX = '-scroll';
|
||||
function writeScrollY(y) {
|
||||
try { localStorage.setItem(LS_KEY + SCROLL_KEY_SUFFIX, String(y)); } catch {}
|
||||
}
|
||||
function readScrollY() {
|
||||
try {
|
||||
const raw = localStorage.getItem(LS_KEY + SCROLL_KEY_SUFFIX);
|
||||
if (raw == null) return null;
|
||||
const n = parseFloat(raw);
|
||||
return isFinite(n) ? n : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
function clearScrollY() {
|
||||
try { localStorage.removeItem(LS_KEY + SCROLL_KEY_SUFFIX); } catch {}
|
||||
}
|
||||
function writeScrollY(y) { sessionState.writeScrollY(y); }
|
||||
function readScrollY() { return sessionState.readScrollY(); }
|
||||
function clearScrollY() { sessionState.clearScrollY(); }
|
||||
|
||||
// Pre-empt the browser: apply manual scroll restoration and jump to the
|
||||
// saved scrollY at script-parse time. Retries on fonts.ready and load
|
||||
@@ -1585,6 +1585,7 @@
|
||||
paramsCurrentValues[p.id] = v;
|
||||
readout.textContent = formatRangeValue(input);
|
||||
applyParamValue(variantEl, p, v);
|
||||
queueCheckpoint('param_changed');
|
||||
});
|
||||
row.appendChild(input);
|
||||
} else if (p.kind === 'toggle') {
|
||||
@@ -1615,6 +1616,7 @@
|
||||
knob.style.left = next ? '18px' : '2px';
|
||||
readout.textContent = next ? 'On' : 'Off';
|
||||
applyParamValue(variantEl, p, next);
|
||||
queueCheckpoint('param_changed');
|
||||
});
|
||||
row.appendChild(track);
|
||||
} else if (p.kind === 'steps') {
|
||||
@@ -1651,6 +1653,7 @@
|
||||
btn.style.color = on ? 'oklch(98% 0 0)' : P.text;
|
||||
});
|
||||
applyParamValue(variantEl, p, o.value);
|
||||
queueCheckpoint('param_changed');
|
||||
});
|
||||
segRow.appendChild(b);
|
||||
segBtns.push({ btn: b, val: o.value });
|
||||
@@ -1872,19 +1875,26 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const previousVisibleVariant = currentSessionId === sessionId ? visibleVariant : 0;
|
||||
|
||||
// Replace the live element with the full wrapper from source
|
||||
const wrapper = srcWrapper.cloneNode(true);
|
||||
liveEl.parentElement.replaceChild(wrapper, liveEl);
|
||||
|
||||
// Update state: count variants, show the first one
|
||||
// Update state: count variants, preserving the user's current variant
|
||||
// when a late HMR/source reinjection lands after they have cycled.
|
||||
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
|
||||
arrivedVariants = variants.length;
|
||||
expectedVariants = parseInt(wrapper.dataset.impeccableVariantCount || arrivedVariants);
|
||||
visibleVariant = 1;
|
||||
showVariantInDOM(sessionId, 1);
|
||||
const saved = loadSession();
|
||||
const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0;
|
||||
visibleVariant = previousVisibleVariant > 0 && previousVisibleVariant <= arrivedVariants
|
||||
? previousVisibleVariant
|
||||
: (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1);
|
||||
showVariantInDOM(sessionId, visibleVariant);
|
||||
|
||||
// Update selectedElement to the visible variant's content
|
||||
selectedElement = pickVariantContent(wrapper, 1) || wrapper.parentElement;
|
||||
selectedElement = pickVariantContent(wrapper, visibleVariant) || wrapper.parentElement;
|
||||
|
||||
state = 'CYCLING';
|
||||
hideShaderOverlay();
|
||||
@@ -1907,6 +1917,7 @@
|
||||
updateSelectedElement();
|
||||
updateBarContent('cycling');
|
||||
saveSession();
|
||||
queueCheckpoint('variant_changed');
|
||||
}
|
||||
|
||||
function updateSelectedElement() {
|
||||
@@ -1917,6 +1928,18 @@
|
||||
if (visEl) selectedElement = visEl;
|
||||
}
|
||||
|
||||
function readVisibleVariantFromDOM(sessionId) {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return 0;
|
||||
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
|
||||
for (const variant of variants) {
|
||||
if (variant.style.display === 'none') continue;
|
||||
const idx = parseInt(variant.dataset.impeccableVariant || '0', 10);
|
||||
if (idx > 0) return idx;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Resolve the element that represents the variant's visible content.
|
||||
// Contract: each variant div should contain exactly one top-level element
|
||||
// (the full replacement). In practice a model may ship loose siblings or
|
||||
@@ -2107,8 +2130,10 @@
|
||||
updating = true;
|
||||
arrivedVariants = count;
|
||||
if (visibleVariant === 0 && arrivedVariants > 0) {
|
||||
visibleVariant = 1;
|
||||
showVariantInDOM(sessionId, 1);
|
||||
const saved = loadSession();
|
||||
const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0;
|
||||
visibleVariant = savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1;
|
||||
showVariantInDOM(sessionId, visibleVariant);
|
||||
// showVariantInDOM hid the original (display:none); if we were still
|
||||
// anchored to the original's content, its boundingRect is now zero
|
||||
// and the bar snaps to (0,0). Re-point at the visible variant instead.
|
||||
@@ -2128,6 +2153,7 @@
|
||||
updateBarContent('generating');
|
||||
}
|
||||
saveSession();
|
||||
queueCheckpoint(state === 'CYCLING' ? 'variants_ready' : 'variants_progress');
|
||||
updating = false;
|
||||
});
|
||||
|
||||
@@ -2236,6 +2262,7 @@
|
||||
|
||||
/** Server died or became unreachable. Reset UI to a clean state. */
|
||||
function handleServerLost() {
|
||||
const recoveryState = currentSessionId ? state : 'IDLE';
|
||||
if (state === 'GENERATING' || state === 'CYCLING' || state === 'SAVING') {
|
||||
showToast('Live server disconnected. Session ended.', 5000);
|
||||
}
|
||||
@@ -2246,21 +2273,61 @@
|
||||
stopScrollTracking();
|
||||
if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
|
||||
stopScrollLock();
|
||||
clearScrollY();
|
||||
clearSession();
|
||||
// Preserve local session state on server loss. The durable journal is the
|
||||
// source of truth, but localStorage plus the variant wrapper lets the UI
|
||||
// resume after a helper restart or page reload instead of treating a
|
||||
// transient disconnect as an explicit discard.
|
||||
selectedElement = null;
|
||||
currentSessionId = null;
|
||||
selectedAction = 'impeccable';
|
||||
state = 'IDLE';
|
||||
state = recoveryState;
|
||||
if (currentSessionId) saveSession();
|
||||
}
|
||||
|
||||
function sendEvent(msg) {
|
||||
function sendEvent(msg, opts) {
|
||||
msg.token = TOKEN;
|
||||
fetch('http://localhost:' + PORT + '/events', {
|
||||
function handleFailure(err) {
|
||||
console.error('[impeccable] Failed to send event:', err);
|
||||
if (opts && opts.throwOnError) throw err;
|
||||
return null;
|
||||
}
|
||||
return fetch('http://localhost:' + PORT + '/events', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(msg),
|
||||
}).catch(err => console.error('[impeccable] Failed to send event:', err));
|
||||
}).then(res => {
|
||||
if (res.ok) return res;
|
||||
return handleFailure(new Error('HTTP ' + res.status + ' ' + res.statusText));
|
||||
}).catch(handleFailure);
|
||||
}
|
||||
|
||||
function checkpointPayload(reason) {
|
||||
return {
|
||||
type: 'checkpoint',
|
||||
id: currentSessionId,
|
||||
revision: sessionState.nextCheckpointRevision(),
|
||||
owner: browserOwner,
|
||||
phase: String(state || '').toLowerCase(),
|
||||
reason,
|
||||
pageUrl: location.pathname,
|
||||
expectedVariants,
|
||||
arrivedVariants,
|
||||
visibleVariant,
|
||||
paramValues: { ...paramsCurrentValues },
|
||||
};
|
||||
}
|
||||
|
||||
function sendCheckpoint(reason) {
|
||||
if (!currentSessionId) return Promise.resolve(null);
|
||||
return sendEvent(checkpointPayload(reason)).catch(() => null);
|
||||
}
|
||||
|
||||
function queueCheckpoint(reason) {
|
||||
if (!currentSessionId) return;
|
||||
if (checkpointTimer) clearTimeout(checkpointTimer);
|
||||
checkpointTimer = setTimeout(() => {
|
||||
checkpointTimer = null;
|
||||
sendCheckpoint(reason);
|
||||
}, 120);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -2496,6 +2563,7 @@
|
||||
state = 'GENERATING';
|
||||
showBar('generating');
|
||||
saveSession();
|
||||
sendCheckpoint('generate_started');
|
||||
writeScrollY(window.scrollY);
|
||||
if (variantObserver) variantObserver.disconnect();
|
||||
variantObserver = startVariantObserver(currentSessionId);
|
||||
@@ -2920,13 +2988,12 @@ void main() {
|
||||
|
||||
function handleAccept() {
|
||||
if (!currentSessionId || arrivedVariants === 0) return;
|
||||
const domVisibleVariant = readVisibleVariantFromDOM(currentSessionId);
|
||||
if (domVisibleVariant > 0) visibleVariant = domVisibleVariant;
|
||||
const acceptPayload = { type: 'accept', id: currentSessionId, variantId: String(visibleVariant) };
|
||||
if (Object.keys(paramsCurrentValues).length > 0) {
|
||||
acceptPayload.paramValues = { ...paramsCurrentValues };
|
||||
}
|
||||
sendEvent(acceptPayload);
|
||||
markSessionHandled();
|
||||
|
||||
// The accepted variant is already the only visible child of the wrapper
|
||||
// (all other variants are display:none). HMR from the source rewrite will
|
||||
// replace the wrapper imminently. Don't eagerly replaceChild here — React
|
||||
@@ -2936,9 +3003,28 @@ void main() {
|
||||
const acceptedSessionId = currentSessionId;
|
||||
const acceptedVariant = visibleVariant;
|
||||
|
||||
state = 'CONFIRMED';
|
||||
updateBarContent('confirmed');
|
||||
setTimeout(function() {
|
||||
state = 'SAVING';
|
||||
updateBarContent('saving');
|
||||
|
||||
sendEvent(acceptPayload, { throwOnError: true })
|
||||
.then(() => {
|
||||
markSessionHandled();
|
||||
confirmAcceptAfterReceipt();
|
||||
})
|
||||
.catch(() => {
|
||||
state = 'CYCLING';
|
||||
updateBarContent('cycling');
|
||||
showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000);
|
||||
});
|
||||
|
||||
function confirmAcceptAfterReceipt() {
|
||||
state = 'CONFIRMED';
|
||||
updateBarContent('confirmed');
|
||||
scheduleAcceptCleanup();
|
||||
}
|
||||
|
||||
function scheduleAcceptCleanup() {
|
||||
setTimeout(function() {
|
||||
hideBar();
|
||||
hideHighlight();
|
||||
stopScrollTracking();
|
||||
@@ -2967,50 +3053,46 @@ void main() {
|
||||
accepted.style.display = 'contents';
|
||||
parent.replaceChild(accepted, wrapper);
|
||||
}
|
||||
}, 2000);
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDiscard() {
|
||||
if (!currentSessionId) return;
|
||||
sendEvent({ type: 'discard', id: currentSessionId });
|
||||
markSessionHandled();
|
||||
// Instant DOM restore + fire-and-forget (script handles file cleanup)
|
||||
cleanup();
|
||||
sendEvent({ type: 'discard', id: currentSessionId }, { throwOnError: true })
|
||||
.then(() => {
|
||||
markSessionHandled();
|
||||
cleanup();
|
||||
})
|
||||
.catch(() => showToast('Could not confirm discard with the live server. Session kept for recovery.', 5000));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session persistence via localStorage
|
||||
// Session persistence via live-browser-session.js
|
||||
// ---------------------------------------------------------------------------
|
||||
// Survives page reloads, browser close/reopen, HMR, and accidental refreshes.
|
||||
|
||||
const LS_KEY = PREFIX + '-session';
|
||||
|
||||
function saveSession() {
|
||||
if (!currentSessionId) return;
|
||||
// NOTE: scrollY is stored under a separate key (writeScrollY). Storing
|
||||
// it here would overwrite the Go-time value every time state changes.
|
||||
try {
|
||||
localStorage.setItem(LS_KEY, JSON.stringify({
|
||||
id: currentSessionId,
|
||||
state: state,
|
||||
action: selectedAction,
|
||||
count: selectedCount,
|
||||
expected: expectedVariants,
|
||||
arrived: arrivedVariants,
|
||||
visible: visibleVariant,
|
||||
}));
|
||||
} catch { /* quota exceeded or private mode */ }
|
||||
sessionState.saveSession({
|
||||
id: currentSessionId,
|
||||
state,
|
||||
action: selectedAction,
|
||||
count: selectedCount,
|
||||
expected: expectedVariants,
|
||||
arrived: arrivedVariants,
|
||||
visible: visibleVariant,
|
||||
});
|
||||
}
|
||||
|
||||
function loadSession() {
|
||||
try {
|
||||
const raw = localStorage.getItem(LS_KEY);
|
||||
return raw ? JSON.parse(raw) : null;
|
||||
} catch { return null; }
|
||||
return sessionState.loadSession();
|
||||
}
|
||||
|
||||
function clearSession() {
|
||||
try { localStorage.removeItem(LS_KEY); } catch {}
|
||||
sessionState.clearSession();
|
||||
}
|
||||
|
||||
/** Mark session as handled (accepted/discarded). The agent will clean up
|
||||
@@ -3018,19 +3100,15 @@ void main() {
|
||||
* prevents resumeSession from picking it up again after reload. */
|
||||
function markSessionHandled() {
|
||||
if (!currentSessionId) return;
|
||||
try {
|
||||
localStorage.setItem(LS_KEY + '-handled', currentSessionId);
|
||||
} catch {}
|
||||
sessionState.markHandled(currentSessionId);
|
||||
}
|
||||
|
||||
function isSessionHandled(id) {
|
||||
try {
|
||||
return localStorage.getItem(LS_KEY + '-handled') === id;
|
||||
} catch { return false; }
|
||||
return sessionState.isHandled(id);
|
||||
}
|
||||
|
||||
function clearHandled() {
|
||||
try { localStorage.removeItem(LS_KEY + '-handled'); } catch {}
|
||||
sessionState.clearHandled();
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
@@ -3161,6 +3239,7 @@ void main() {
|
||||
// hid. Now that state is CYCLING, re-fire.
|
||||
if (state === 'CYCLING') refreshParamsPanel();
|
||||
saveSession();
|
||||
queueCheckpoint('browser_resumed');
|
||||
|
||||
// Start observing for more variants AFTER initial setup
|
||||
if (variantObserver) variantObserver.disconnect();
|
||||
@@ -3617,7 +3696,7 @@ void main() {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Design System Panel — visualizes the project's DESIGN.json sidecar
|
||||
// Design System Panel — visualizes the project's .impeccable/design.json sidecar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DESIGN_PREFS_KEY = 'impeccable-live-design-panel';
|
||||
@@ -3629,7 +3708,7 @@ void main() {
|
||||
open: false,
|
||||
tab: 'visual', // 'visual' | 'raw'
|
||||
parsed: null, // parseDesignMd output (frontmatter + body sections)
|
||||
sidecar: null, // DESIGN.json v2 payload (extensions + components + narrative)
|
||||
sidecar: null, // .impeccable/design.json v2 payload (extensions + components + narrative)
|
||||
hasMd: false,
|
||||
hasSidecar: false,
|
||||
present: null, // true/false once fetch resolves
|
||||
@@ -4130,7 +4209,7 @@ void main() {
|
||||
box.className = 'stale';
|
||||
box.innerHTML = `
|
||||
<span class="stale-dot"></span>
|
||||
<span class="stale-text"><strong>DESIGN.md is newer than DESIGN.json.</strong> Run <code>/impeccable document</code> to refresh the sidecar.</span>
|
||||
<span class="stale-text"><strong>DESIGN.md is newer than .impeccable/design.json.</strong> Run <code>/impeccable document</code> to refresh the sidecar.</span>
|
||||
`;
|
||||
return box;
|
||||
}
|
||||
@@ -4138,7 +4217,7 @@ void main() {
|
||||
function renderParsedMdCta() {
|
||||
const box = document.createElement('div');
|
||||
box.className = 'parsed-md-cta';
|
||||
box.innerHTML = `<strong>Basic view</strong>This panel reads the tokens in your <code>DESIGN.md</code> frontmatter. Running <code>/impeccable document</code> also generates a <code>DESIGN.json</code> sidecar with your project's actual component snippets (button, input, nav) and tonal ramps, rendered live below the tokens.`;
|
||||
box.innerHTML = `<strong>Basic view</strong>This panel reads the tokens in your <code>DESIGN.md</code> frontmatter. Running <code>/impeccable document</code> also generates a <code>.impeccable/design.json</code> sidecar with your project's actual component snippets (button, input, nav) and tonal ramps, rendered live below the tokens.`;
|
||||
return box;
|
||||
}
|
||||
|
||||
@@ -4598,7 +4677,7 @@ void main() {
|
||||
|
||||
function cssSafe(v) {
|
||||
// Strip anything outside valid CSS value chars to prevent injection via
|
||||
// DESIGN.json values rendered into inline style strings.
|
||||
// .impeccable/design.json values rendered into inline style strings.
|
||||
return String(v).replace(/[<>"'`\n]/g, '');
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Canonical durable completion acknowledgement for Impeccable live sessions.
|
||||
*/
|
||||
|
||||
import { createLiveSessionStore } from './live-session-store.mjs';
|
||||
import { readLiveServerInfo } from './impeccable-paths.mjs';
|
||||
|
||||
function parseArgs(argv) {
|
||||
const out = { status: 'complete' };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
if (arg === '--id') out.id = argv[++i];
|
||||
else if (arg.startsWith('--id=')) out.id = arg.slice('--id='.length);
|
||||
else if (arg === '--discarded' || arg === '--discard') out.status = 'discarded';
|
||||
else if (arg === '--error') { out.status = 'agent_error'; out.message = argv[++i] || 'unknown error'; }
|
||||
else if (arg.startsWith('--error=')) { out.status = 'agent_error'; out.message = arg.slice('--error='.length); }
|
||||
else if (arg === '--help' || arg === '-h') out.help = true;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function completeCli() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (args.help || !args.id) {
|
||||
console.log(`Usage: node live-complete.mjs --id SESSION_ID [--discarded|--error MESSAGE]\n\nAppend the final durable session acknowledgement. Use after accept/discard cleanup is verified.`);
|
||||
process.exit(args.help ? 0 : 1);
|
||||
}
|
||||
|
||||
const serverInfo = readServerInfo();
|
||||
const serverResult = serverInfo ? await completeThroughServer(serverInfo, args) : null;
|
||||
if (serverResult?.ok) {
|
||||
const store = createLiveSessionStore({ cwd: process.cwd(), sessionId: args.id });
|
||||
const snapshot = store.getSnapshot(args.id, { includeCompleted: true });
|
||||
console.log(JSON.stringify({ ok: true, id: args.id, phase: snapshot?.phase || args.status, snapshot }, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
const store = createLiveSessionStore({ cwd: process.cwd(), sessionId: args.id });
|
||||
const event = args.status === 'discarded'
|
||||
? { type: 'discarded', id: args.id }
|
||||
: args.status === 'agent_error'
|
||||
? { type: 'agent_error', id: args.id, message: args.message || 'unknown error' }
|
||||
: { type: 'complete', id: args.id };
|
||||
const snapshot = store.appendEvent(event);
|
||||
console.log(JSON.stringify({ ok: true, id: args.id, phase: snapshot.phase, snapshot }, null, 2));
|
||||
}
|
||||
|
||||
function readServerInfo() {
|
||||
return readLiveServerInfo(process.cwd())?.info || null;
|
||||
}
|
||||
|
||||
async function completeThroughServer(info, args) {
|
||||
const type = args.status === 'discarded'
|
||||
? 'discarded'
|
||||
: args.status === 'agent_error'
|
||||
? 'error'
|
||||
: 'complete';
|
||||
try {
|
||||
const res = await fetch(`http://localhost:${info.port}/poll`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token: info.token, id: args.id, type, message: args.message }),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
return await res.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const _running = process.argv[1];
|
||||
if (_running?.endsWith('live-complete.mjs') || _running?.endsWith('live-complete.mjs/')) {
|
||||
completeCli();
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
export function completionTypeForAcceptResult(eventType, acceptResult) {
|
||||
if (eventType === 'discard') return acceptResult?.handled === true ? 'discarded' : 'error';
|
||||
if (acceptResult?.handled === true && acceptResult?.carbonize === true) return 'agent_done';
|
||||
if (acceptResult?.handled === true) return 'complete';
|
||||
if (acceptResult?.mode === 'error') return 'error';
|
||||
return 'agent_done';
|
||||
}
|
||||
|
||||
export function completionAckForAcceptResult(eventId, completionType, acceptResult) {
|
||||
const ack = { ok: true, type: completionType };
|
||||
if (acceptResult?.handled === true && acceptResult?.carbonize === true) {
|
||||
ack.final = false;
|
||||
ack.requiresComplete = true;
|
||||
ack.nextCommand = `live-complete.mjs --id ${eventId}`;
|
||||
ack.message = 'Carbonize cleanup must be verified, then the session must be completed explicitly before polling again.';
|
||||
}
|
||||
return ack;
|
||||
}
|
||||
@@ -2,23 +2,24 @@
|
||||
* CLI helper: insert/remove the live variant mode script tag in the project's
|
||||
* main HTML entry point.
|
||||
*
|
||||
* On first live run, the agent generates `config.json` in this script's
|
||||
* directory with the project's insertion target (framework-specific). On
|
||||
* On first live run, the agent generates `.impeccable/live/config.json`
|
||||
* with the project's insertion target (framework-specific). On
|
||||
* every subsequent run, this script handles insert/remove deterministically
|
||||
* with zero LLM involvement.
|
||||
*
|
||||
* Usage:
|
||||
* node live-inject.mjs --port PORT # Insert the live script tag
|
||||
* node live-inject.mjs --remove # Remove the live script tag
|
||||
* node live-inject.mjs --check # Check whether config.json exists
|
||||
* node live-inject.mjs --check # Check whether live config exists
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { resolveLiveConfigPath } from './impeccable-paths.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const CONFIG_PATH = process.env.IMPECCABLE_LIVE_CONFIG || path.join(__dirname, 'config.json');
|
||||
const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname });
|
||||
const MARKER_OPEN_TEXT = 'impeccable-live-start';
|
||||
const MARKER_CLOSE_TEXT = 'impeccable-live-end';
|
||||
|
||||
@@ -39,12 +40,12 @@ export async function injectCli() {
|
||||
console.log(`Usage: node live-inject.mjs [options]
|
||||
|
||||
Insert or remove the live mode script tag in the project's HTML entry point.
|
||||
Reads configuration from config.json (in this same directory).
|
||||
Reads configuration from .impeccable/live/config.json.
|
||||
|
||||
Modes:
|
||||
--port PORT Insert script tag pointing at http://localhost:PORT/live.js
|
||||
--remove Remove the script tag (if present)
|
||||
--check Print whether config.json exists and its content
|
||||
--check Print whether .impeccable/live/config.json exists and its content
|
||||
|
||||
Output (JSON):
|
||||
{ ok, file, inserted|removed, config? }`);
|
||||
|
||||
@@ -9,10 +9,10 @@
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { completionAckForAcceptResult, completionTypeForAcceptResult } from './live-completion.mjs';
|
||||
import { readLiveServerInfo } from './impeccable-paths.mjs';
|
||||
|
||||
// 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
|
||||
@@ -20,15 +20,29 @@ import { fileURLToPath } from 'node:url';
|
||||
// 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 {
|
||||
const record = readLiveServerInfo(process.cwd());
|
||||
if (!record) {
|
||||
console.error('No running live server found. Start one with: npx impeccable live');
|
||||
process.exit(1);
|
||||
}
|
||||
return record.info;
|
||||
}
|
||||
|
||||
export function buildPollReplyPayload(token, { id, type, message, file, data }) {
|
||||
return { token, id, type, message, file, data };
|
||||
}
|
||||
|
||||
async function postReply(base, token, reply) {
|
||||
const res = await fetch(`${base}/poll`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(buildPollReplyPayload(token, reply)),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error || res.statusText);
|
||||
}
|
||||
}
|
||||
|
||||
export async function pollCli() {
|
||||
@@ -69,23 +83,7 @@ Options:
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
await postReply(base, info.token, { id, type: status, message, file: filePath });
|
||||
|
||||
// Success — silent exit (agent doesn't need output for replies)
|
||||
} catch (err) {
|
||||
@@ -156,7 +154,23 @@ Options:
|
||||
);
|
||||
event._acceptResult = JSON.parse(out.trim());
|
||||
} catch (err) {
|
||||
event._acceptResult = { handled: false, error: err.message };
|
||||
event._acceptResult = { handled: false, mode: 'error', error: err.message };
|
||||
}
|
||||
|
||||
const completionType = completionTypeForAcceptResult(event.type, event._acceptResult);
|
||||
try {
|
||||
await postReply(base, info.token, {
|
||||
id: event.id,
|
||||
type: completionType,
|
||||
message: event._acceptResult?.error,
|
||||
file: event._acceptResult?.file,
|
||||
data: event._acceptResult?.carbonize === true ? { carbonize: true } : undefined,
|
||||
});
|
||||
} catch (err) {
|
||||
event._completionAck = { ok: false, error: err.message };
|
||||
}
|
||||
if (!event._completionAck) {
|
||||
event._completionAck = completionAckForAcceptResult(event.id, completionType, event._acceptResult);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,7 +178,7 @@ Options:
|
||||
// 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');
|
||||
process.stderr.write('\n⚠ Carbonize cleanup REQUIRED before next poll. After cleanup, run live-complete.mjs --id ' + event.id + '. See reference/live.md "Required after accept".\n\n');
|
||||
}
|
||||
|
||||
// Print the event as JSON — the agent reads this from stdout
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Recover the next agent action from the durable live-session journal.
|
||||
*/
|
||||
|
||||
import { createLiveSessionStore } from './live-session-store.mjs';
|
||||
|
||||
function parseArgs(argv) {
|
||||
const out = { id: null };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
if (arg === '--id') out.id = argv[++i];
|
||||
else if (arg.startsWith('--id=')) out.id = arg.slice('--id='.length);
|
||||
else if (arg === '--help' || arg === '-h') out.help = true;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function resumeCli() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (args.help) {
|
||||
console.log(`Usage: node live-resume.mjs [--id SESSION_ID]\n\nPrint the active durable session checkpoint and the next safe agent action.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const store = createLiveSessionStore({ cwd: process.cwd(), sessionId: args.id || undefined });
|
||||
const snapshot = args.id ? store.getSnapshot(args.id) : store.listActiveSessions()[0] || null;
|
||||
if (!snapshot) {
|
||||
console.log(JSON.stringify({ active: false, nextAction: 'No active durable live session found.' }, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
const pending = snapshot.pendingEvent || null;
|
||||
const nextAction = pending
|
||||
? `Run live-poll.mjs, handle ${pending.type} ${pending.id}, then acknowledge with live-poll.mjs --reply ${pending.id} done.`
|
||||
: snapshot.phase === 'carbonize_required'
|
||||
? `Finish carbonize cleanup${snapshot.sourceFile ? ` in ${snapshot.sourceFile}` : ''}, then run live-complete.mjs --id ${snapshot.id}.`
|
||||
: snapshot.phase === 'accept_requested'
|
||||
? `Run live-complete.mjs --id ${snapshot.id} after verifying the accepted variant is written.`
|
||||
: `Inspect ${snapshot.id}; no pending agent event is currently queued.`;
|
||||
|
||||
console.log(JSON.stringify({ active: true, snapshot, pendingEvent: pending, nextAction }, null, 2));
|
||||
}
|
||||
|
||||
const _running = process.argv[1];
|
||||
if (_running?.endsWith('live-resume.mjs') || _running?.endsWith('live-resume.mjs/')) {
|
||||
resumeCli();
|
||||
}
|
||||
@@ -22,14 +22,20 @@ import net from 'node:net';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseDesignMd } from './design-parser.mjs';
|
||||
import { resolveContextDir } from './load-context.mjs';
|
||||
import { createLiveSessionStore } from './live-session-store.mjs';
|
||||
import {
|
||||
getDesignSidecarPath,
|
||||
getLiveAnnotationsDir,
|
||||
readLiveServerInfo,
|
||||
removeLiveServerInfo,
|
||||
resolveDesignSidecarPath,
|
||||
writeLiveServerInfo,
|
||||
} from './impeccable-paths.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');
|
||||
// PRODUCT.md / DESIGN.md / DESIGN.json live wherever load-context.mjs resolves.
|
||||
// Keeps live-server in sync with the loader when users keep the docs in
|
||||
// .agents/context/, docs/, or a path set via IMPECCABLE_CONTEXT_DIR.
|
||||
// PRODUCT.md / DESIGN.md live wherever load-context.mjs resolves. The generated
|
||||
// DESIGN sidecar is project-local at .impeccable/design.json, with legacy
|
||||
// DESIGN.json fallback for existing projects.
|
||||
const CONTEXT_DIR = resolveContextDir(process.cwd());
|
||||
const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway
|
||||
const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s
|
||||
@@ -57,10 +63,12 @@ const state = {
|
||||
token: null,
|
||||
port: null,
|
||||
sseClients: new Set(), // SSE response objects (server→browser push)
|
||||
pendingEvents: [], // browser events waiting for agent poll
|
||||
pendingEvents: [], // browser events waiting for agent ack ({ event, leaseUntil })
|
||||
pendingPolls: [], // agent poll callbacks waiting for browser events
|
||||
exitTimer: null,
|
||||
sessionDir: null, // per-session tmp dir for annotation screenshots
|
||||
sessionStore: null,
|
||||
leaseTimer: null,
|
||||
};
|
||||
|
||||
// Cap per-annotation upload size. A full 1920×1080 PNG is typically <1 MB;
|
||||
@@ -68,13 +76,72 @@ const state = {
|
||||
const MAX_ANNOTATION_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
function enqueueEvent(event) {
|
||||
if (state.pendingPolls.length > 0) {
|
||||
state.pendingPolls.shift()(event);
|
||||
} else {
|
||||
state.pendingEvents.push(event);
|
||||
if (!event || (event.id && state.pendingEvents.some((entry) => entry.event?.id === event.id && entry.event?.type === event.type))) return;
|
||||
state.pendingEvents.push({ event, leaseUntil: 0 });
|
||||
flushPendingPolls();
|
||||
}
|
||||
|
||||
function restorePendingEventsFromStore() {
|
||||
if (!state.sessionStore) return;
|
||||
for (const snapshot of state.sessionStore.listActiveSessions()) {
|
||||
if (snapshot.pendingEvent) enqueueEvent(snapshot.pendingEvent);
|
||||
}
|
||||
}
|
||||
|
||||
function findAvailablePendingEvent(now = Date.now()) {
|
||||
return state.pendingEvents.find((entry) => !entry.leaseUntil || entry.leaseUntil <= now);
|
||||
}
|
||||
|
||||
function leaseEvent(entry, leaseMs) {
|
||||
if (!entry.event?.id) {
|
||||
const idx = state.pendingEvents.indexOf(entry);
|
||||
if (idx !== -1) state.pendingEvents.splice(idx, 1);
|
||||
return entry.event;
|
||||
}
|
||||
entry.leaseUntil = Date.now() + leaseMs;
|
||||
return entry.event;
|
||||
}
|
||||
|
||||
function acknowledgePendingEvent(id) {
|
||||
if (!id) return false;
|
||||
const idx = state.pendingEvents.findIndex((entry) => entry.event?.id === id);
|
||||
if (idx === -1) return false;
|
||||
state.pendingEvents.splice(idx, 1);
|
||||
scheduleLeaseFlush();
|
||||
return true;
|
||||
}
|
||||
|
||||
function scheduleLeaseFlush() {
|
||||
if (state.leaseTimer) {
|
||||
clearTimeout(state.leaseTimer);
|
||||
state.leaseTimer = null;
|
||||
}
|
||||
if (state.pendingPolls.length === 0) return;
|
||||
const now = Date.now();
|
||||
const nextLeaseUntil = state.pendingEvents
|
||||
.map((entry) => entry.leaseUntil || 0)
|
||||
.filter((leaseUntil) => leaseUntil > now)
|
||||
.sort((a, b) => a - b)[0];
|
||||
if (!nextLeaseUntil) return;
|
||||
state.leaseTimer = setTimeout(() => {
|
||||
state.leaseTimer = null;
|
||||
flushPendingPolls();
|
||||
}, Math.max(0, nextLeaseUntil - now));
|
||||
}
|
||||
|
||||
function flushPendingPolls() {
|
||||
while (state.pendingPolls.length > 0) {
|
||||
const entry = findAvailablePendingEvent();
|
||||
if (!entry) {
|
||||
scheduleLeaseFlush();
|
||||
return;
|
||||
}
|
||||
const poll = state.pendingPolls.shift();
|
||||
poll.resolve(leaseEvent(entry, poll.leaseMs));
|
||||
}
|
||||
scheduleLeaseFlush();
|
||||
}
|
||||
|
||||
/** Push a message to all connected SSE clients. */
|
||||
function broadcast(msg) {
|
||||
const data = 'data: ' + JSON.stringify(msg) + '\n\n';
|
||||
@@ -103,13 +170,16 @@ function loadBrowserScripts() {
|
||||
// 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 sessionPath = path.join(__dirname, 'live-browser-session.js');
|
||||
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);
|
||||
for (const p of [sessionPath, livePath]) {
|
||||
if (!fs.existsSync(p)) {
|
||||
process.stderr.write('Error: live browser script not found at ' + p + '\n');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
return { detectScript, livePath };
|
||||
return { detectScript, sessionPath, livePath };
|
||||
}
|
||||
|
||||
function hasProjectContext() {
|
||||
@@ -170,6 +240,13 @@ function validateEvent(msg) {
|
||||
return null;
|
||||
case 'discard':
|
||||
return isValidId(msg.id) ? null : 'discard: missing or malformed id';
|
||||
case 'checkpoint':
|
||||
if (!isValidId(msg.id)) return 'checkpoint: missing or malformed id';
|
||||
if (!Number.isInteger(msg.revision) || msg.revision < 0) return 'checkpoint: revision must be a non-negative integer';
|
||||
if (msg.paramValues !== undefined && (typeof msg.paramValues !== 'object' || msg.paramValues === null || Array.isArray(msg.paramValues))) {
|
||||
return 'checkpoint: paramValues must be an object';
|
||||
}
|
||||
return null;
|
||||
case 'exit':
|
||||
return null;
|
||||
case 'prefetch':
|
||||
@@ -184,7 +261,7 @@ function validateEvent(msg) {
|
||||
// HTTP request handler
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createRequestHandler({ detectScript, livePath }) {
|
||||
function createRequestHandler({ detectScript, sessionPath, livePath }) {
|
||||
return (req, res) => {
|
||||
const url = new URL(req.url, `http://localhost:${state.port}`);
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
@@ -200,17 +277,20 @@ function createRequestHandler({ detectScript, livePath }) {
|
||||
// the next tab reload. No-store headers prevent browser caching across
|
||||
// sessions — during iteration, a cached old script silently breaks
|
||||
// every subsequent session.
|
||||
let sessionScript;
|
||||
let liveScript;
|
||||
try {
|
||||
sessionScript = fs.readFileSync(sessionPath, 'utf-8');
|
||||
liveScript = fs.readFileSync(livePath, 'utf-8');
|
||||
} catch (err) {
|
||||
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
||||
res.end('Error reading live-browser.js: ' + err.message);
|
||||
res.end('Error reading live browser scripts: ' + err.message);
|
||||
return;
|
||||
}
|
||||
const body =
|
||||
`window.__IMPECCABLE_TOKEN__ = '${state.token}';\n` +
|
||||
`window.__IMPECCABLE_PORT__ = ${state.port};\n` +
|
||||
sessionScript + '\n' +
|
||||
liveScript;
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/javascript',
|
||||
@@ -305,6 +385,26 @@ function createRequestHandler({ detectScript, livePath }) {
|
||||
}
|
||||
|
||||
// --- Health ---
|
||||
if (p === '/status') {
|
||||
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 sessions = state.sessionStore ? state.sessionStore.listActiveSessions() : [];
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
status: 'ok',
|
||||
port: state.port,
|
||||
connectedClients: state.sseClients.size,
|
||||
pendingEvents: state.pendingEvents.map((entry) => ({
|
||||
id: entry.event?.id,
|
||||
type: entry.event?.type,
|
||||
leased: !!(entry.leaseUntil && entry.leaseUntil > Date.now()),
|
||||
leaseUntil: entry.leaseUntil || null,
|
||||
})),
|
||||
activeSessions: sessions,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (p === '/health') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
@@ -316,13 +416,13 @@ function createRequestHandler({ detectScript, livePath }) {
|
||||
}
|
||||
|
||||
// --- Design system (unified v2 response) + raw ---
|
||||
// /design-system.json returns both parsed DESIGN.md and DESIGN.json
|
||||
// /design-system.json returns both parsed DESIGN.md and .impeccable/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.
|
||||
// - sidecar: .impeccable/design.json contents when present.
|
||||
// Expected shape: schemaVersion 2, carrying
|
||||
// extensions + components + narrative.
|
||||
// /design-system/raw returns DESIGN.md markdown verbatim
|
||||
@@ -331,7 +431,7 @@ function createRequestHandler({ detectScript, livePath }) {
|
||||
if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
|
||||
|
||||
const mdPath = path.join(CONTEXT_DIR, 'DESIGN.md');
|
||||
const jsonPath = path.join(CONTEXT_DIR, 'DESIGN.json');
|
||||
const jsonPath = resolveDesignSidecarPath(process.cwd(), CONTEXT_DIR) || getDesignSidecarPath(process.cwd());
|
||||
const mdStat = statOrNull(mdPath);
|
||||
const jsonStat = statOrNull(jsonPath);
|
||||
|
||||
@@ -367,7 +467,7 @@ function createRequestHandler({ detectScript, livePath }) {
|
||||
try {
|
||||
response.sidecar = JSON.parse(fs.readFileSync(jsonPath, 'utf-8'));
|
||||
} catch (err) {
|
||||
response.sidecarError = 'Failed to parse DESIGN.json: ' + err.message;
|
||||
response.sidecarError = 'Failed to parse .impeccable/design.json: ' + err.message;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -449,7 +549,16 @@ function createRequestHandler({ detectScript, livePath }) {
|
||||
res.end(JSON.stringify({ error }));
|
||||
return;
|
||||
}
|
||||
enqueueEvent(msg);
|
||||
if (state.sessionStore && msg.id) {
|
||||
try {
|
||||
state.sessionStore.appendEvent(msg);
|
||||
} catch (err) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'session_store_append_failed', message: err.message }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (msg.type !== 'checkpoint') enqueueEvent(msg);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ ok: true }));
|
||||
});
|
||||
@@ -492,13 +601,16 @@ function handlePollGet(req, res, url) {
|
||||
return;
|
||||
}
|
||||
const timeout = parseInt(url.searchParams.get('timeout') || DEFAULT_POLL_TIMEOUT, 10);
|
||||
if (state.pendingEvents.length > 0) {
|
||||
const leaseMs = parseInt(url.searchParams.get('leaseMs') || '30000', 10);
|
||||
const available = findAvailablePendingEvent();
|
||||
if (available) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(state.pendingEvents.shift()));
|
||||
res.end(JSON.stringify(leaseEvent(available, leaseMs)));
|
||||
return;
|
||||
}
|
||||
const poll = { resolve, leaseMs };
|
||||
const timer = setTimeout(() => {
|
||||
const idx = state.pendingPolls.indexOf(resolve);
|
||||
const idx = state.pendingPolls.indexOf(poll);
|
||||
if (idx !== -1) state.pendingPolls.splice(idx, 1);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ type: 'timeout' }));
|
||||
@@ -508,10 +620,11 @@ function handlePollGet(req, res, url) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(event));
|
||||
}
|
||||
state.pendingPolls.push(resolve);
|
||||
state.pendingPolls.push(poll);
|
||||
scheduleLeaseFlush();
|
||||
req.on('close', () => {
|
||||
clearTimeout(timer);
|
||||
const idx = state.pendingPolls.indexOf(resolve);
|
||||
const idx = state.pendingPolls.indexOf(poll);
|
||||
if (idx !== -1) state.pendingPolls.splice(idx, 1);
|
||||
});
|
||||
}
|
||||
@@ -531,6 +644,26 @@ function handlePollPost(req, res) {
|
||||
res.end(JSON.stringify({ error: 'Unauthorized' }));
|
||||
return;
|
||||
}
|
||||
acknowledgePendingEvent(msg.id);
|
||||
if (state.sessionStore && msg.id) {
|
||||
try {
|
||||
const eventType = msg.type === 'discard' || msg.type === 'discarded'
|
||||
? 'discarded'
|
||||
: msg.type === 'complete'
|
||||
? 'complete'
|
||||
: msg.type === 'error'
|
||||
? 'agent_error'
|
||||
: 'agent_done';
|
||||
state.sessionStore.appendEvent({
|
||||
type: eventType,
|
||||
id: msg.id,
|
||||
file: msg.file,
|
||||
message: msg.message,
|
||||
carbonize: msg.data?.carbonize === true,
|
||||
});
|
||||
} catch { /* keep reply path best-effort; browser still needs SSE */ }
|
||||
}
|
||||
flushPendingPolls();
|
||||
// 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' });
|
||||
@@ -545,13 +678,15 @@ function handlePollPost(req, res) {
|
||||
let httpServer = null;
|
||||
|
||||
function shutdown() {
|
||||
try { fs.unlinkSync(LIVE_PID_FILE); } catch {}
|
||||
removeLiveServerInfo(process.cwd());
|
||||
if (state.leaseTimer) clearTimeout(state.leaseTimer);
|
||||
state.leaseTimer = null;
|
||||
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' });
|
||||
for (const poll of state.pendingPolls) poll.resolve({ type: 'exit' });
|
||||
state.pendingPolls.length = 0;
|
||||
if (httpServer) httpServer.close();
|
||||
process.exit(0);
|
||||
@@ -587,6 +722,7 @@ Endpoints:
|
||||
/events SSE stream (server→browser) + POST (browser→server)
|
||||
/poll Long-poll for agent CLI
|
||||
/source Raw source file reader (no-HMR fallback)
|
||||
/status Durable recovery status (token-protected)
|
||||
/health Health check`);
|
||||
process.exit(0);
|
||||
}
|
||||
@@ -594,7 +730,7 @@ Endpoints:
|
||||
if (args.includes('stop')) {
|
||||
const keepInject = args.includes('--keep-inject');
|
||||
try {
|
||||
const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
|
||||
const { info } = readLiveServerInfo(process.cwd()) || {};
|
||||
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 {
|
||||
@@ -645,7 +781,7 @@ if (args.includes('--background')) {
|
||||
const deadline = Date.now() + 10_000;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
|
||||
const { info } = readLiveServerInfo(process.cwd()) || {};
|
||||
if (info.pid !== process.pid) {
|
||||
// Output JSON so the agent can read port + token from stdout.
|
||||
console.log(JSON.stringify(info));
|
||||
@@ -659,30 +795,36 @@ if (args.includes('--background')) {
|
||||
}
|
||||
|
||||
// Check for existing session
|
||||
try {
|
||||
const existing = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
|
||||
try { process.kill(existing.pid, 0);
|
||||
const existingRecord = readLiveServerInfo(process.cwd());
|
||||
if (existingRecord?.info) {
|
||||
const existing = existingRecord.info;
|
||||
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 {}
|
||||
} catch {
|
||||
try { fs.unlinkSync(existingRecord.path); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
state.token = randomUUID();
|
||||
state.sessionStore = createLiveSessionStore({ cwd: process.cwd() });
|
||||
restorePendingEventsFromStore();
|
||||
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');
|
||||
const annotRoot = getLiveAnnotationsDir(process.cwd());
|
||||
fs.mkdirSync(annotRoot, { recursive: true });
|
||||
state.sessionDir = fs.mkdtempSync(path.join(annotRoot, 'session-'));
|
||||
|
||||
const { detectScript, livePath } = loadBrowserScripts();
|
||||
httpServer = http.createServer(createRequestHandler({ detectScript, livePath }));
|
||||
const { detectScript, sessionPath, livePath } = loadBrowserScripts();
|
||||
httpServer = http.createServer(createRequestHandler({ detectScript, sessionPath, 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 }));
|
||||
writeLiveServerInfo(process.cwd(), { 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`);
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { getLegacyLiveSessionsDir, getLiveSessionsDir } from './impeccable-paths.mjs';
|
||||
|
||||
const COMPLETED_PHASES = new Set(['completed', 'discarded']);
|
||||
|
||||
export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) {
|
||||
const rootDir = getLiveSessionsDir(cwd);
|
||||
const legacyRootDir = getLegacyLiveSessionsDir(cwd);
|
||||
fs.mkdirSync(rootDir, { recursive: true });
|
||||
const snapshotCache = new Map();
|
||||
|
||||
function loadCachedOrRebuild(id) {
|
||||
const cached = snapshotCache.get(id);
|
||||
if (cached) return cached;
|
||||
const journalPath = getReadableJournalPath(id);
|
||||
const rebuilt = rebuildSnapshotFromJournal(journalPath, id);
|
||||
snapshotCache.set(id, rebuilt);
|
||||
return rebuilt;
|
||||
}
|
||||
|
||||
function getReadableJournalPath(id) {
|
||||
const primary = getJournalPath(rootDir, id);
|
||||
if (fs.existsSync(primary)) return primary;
|
||||
const legacy = getJournalPath(legacyRootDir, id);
|
||||
if (fs.existsSync(legacy)) return legacy;
|
||||
return primary;
|
||||
}
|
||||
|
||||
return {
|
||||
rootDir,
|
||||
legacyRootDir,
|
||||
appendEvent(event) {
|
||||
const normalized = normalizeEvent(event, sessionId);
|
||||
const journalPath = getJournalPath(rootDir, normalized.id);
|
||||
const snapshotPath = getSnapshotPath(rootDir, normalized.id);
|
||||
const legacyJournalPath = getJournalPath(legacyRootDir, normalized.id);
|
||||
if (!fs.existsSync(journalPath) && fs.existsSync(legacyJournalPath)) {
|
||||
fs.copyFileSync(legacyJournalPath, journalPath);
|
||||
}
|
||||
const prior = loadCachedOrRebuild(normalized.id);
|
||||
const seq = prior.nextSeq;
|
||||
const entry = {
|
||||
seq,
|
||||
id: normalized.id,
|
||||
type: normalized.type,
|
||||
ts: new Date().toISOString(),
|
||||
event: normalized,
|
||||
};
|
||||
fs.appendFileSync(journalPath, JSON.stringify(entry) + '\n');
|
||||
const next = applyEvent(prior.snapshot, entry, prior.diagnostics);
|
||||
snapshotCache.set(normalized.id, { snapshot: next, diagnostics: next.diagnostics || [], nextSeq: seq + 1 });
|
||||
writeSnapshot(snapshotPath, next);
|
||||
return next;
|
||||
},
|
||||
getSnapshot(id = sessionId, opts = {}) {
|
||||
if (!id) throw new Error('session id required');
|
||||
const journalPath = getReadableJournalPath(id);
|
||||
const snapshotPath = getSnapshotPath(rootDir, id);
|
||||
const rebuilt = rebuildSnapshotFromJournal(journalPath, id);
|
||||
snapshotCache.set(id, rebuilt);
|
||||
writeSnapshot(snapshotPath, rebuilt.snapshot);
|
||||
if (!opts.includeCompleted && COMPLETED_PHASES.has(rebuilt.snapshot.phase)) return null;
|
||||
return rebuilt.snapshot;
|
||||
},
|
||||
listActiveSessions() {
|
||||
const ids = new Set();
|
||||
for (const dir of [legacyRootDir, rootDir]) {
|
||||
if (!fs.existsSync(dir)) continue;
|
||||
for (const name of fs.readdirSync(dir)) {
|
||||
if (name.endsWith('.jsonl')) ids.add(name.slice(0, -'.jsonl'.length));
|
||||
}
|
||||
}
|
||||
return [...ids]
|
||||
.sort()
|
||||
.map((id) => this.getSnapshot(id))
|
||||
.filter(Boolean);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeEvent(event, fallbackId) {
|
||||
if (!event || typeof event !== 'object') throw new Error('event object required');
|
||||
const id = event.id || fallbackId;
|
||||
if (!id || typeof id !== 'string') throw new Error('event id required');
|
||||
if (!event.type || typeof event.type !== 'string') throw new Error('event type required');
|
||||
return { ...event, id };
|
||||
}
|
||||
|
||||
function getJournalPath(rootDir, id) {
|
||||
return path.join(rootDir, safeSessionId(id) + '.jsonl');
|
||||
}
|
||||
|
||||
function getSnapshotPath(rootDir, id) {
|
||||
return path.join(rootDir, safeSessionId(id) + '.snapshot.json');
|
||||
}
|
||||
|
||||
function safeSessionId(id) {
|
||||
if (!/^[A-Za-z0-9_-]{1,128}$/.test(id)) throw new Error('invalid session id: ' + id);
|
||||
return id;
|
||||
}
|
||||
|
||||
function baseSnapshot(id) {
|
||||
return {
|
||||
id,
|
||||
phase: 'new',
|
||||
pageUrl: null,
|
||||
sourceFile: null,
|
||||
expectedVariants: 0,
|
||||
arrivedVariants: 0,
|
||||
visibleVariant: null,
|
||||
paramValues: {},
|
||||
pendingEventSeq: null,
|
||||
pendingEvent: null,
|
||||
deliveryLease: null,
|
||||
checkpointRevision: 0,
|
||||
activeOwner: null,
|
||||
sourceMarkers: {},
|
||||
fallbackMode: null,
|
||||
annotationArtifacts: [],
|
||||
diagnostics: [],
|
||||
updatedAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
function rebuildSnapshotFromJournal(journalPath, id) {
|
||||
let snapshot = baseSnapshot(id);
|
||||
const diagnostics = [];
|
||||
let nextSeq = 1;
|
||||
if (!fs.existsSync(journalPath)) return { snapshot, diagnostics, nextSeq };
|
||||
|
||||
const lines = fs.readFileSync(journalPath, 'utf-8').split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const entry = JSON.parse(line);
|
||||
if (!entry || typeof entry !== 'object') throw new Error('entry is not object');
|
||||
if (Number.isInteger(entry.seq)) nextSeq = Math.max(nextSeq, entry.seq + 1);
|
||||
snapshot = applyEvent(snapshot, entry);
|
||||
} catch (err) {
|
||||
diagnostics.push({
|
||||
error: 'journal_parse_failed',
|
||||
line: i + 1,
|
||||
message: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
snapshot.diagnostics = [...snapshot.diagnostics, ...diagnostics];
|
||||
return { snapshot, diagnostics, nextSeq };
|
||||
}
|
||||
|
||||
function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
|
||||
const event = entry.event || entry;
|
||||
const next = {
|
||||
...snapshot,
|
||||
paramValues: { ...(snapshot.paramValues || {}) },
|
||||
sourceMarkers: { ...(snapshot.sourceMarkers || {}) },
|
||||
annotationArtifacts: [...(snapshot.annotationArtifacts || [])],
|
||||
diagnostics: [...(snapshot.diagnostics || [])],
|
||||
updatedAt: entry.ts || new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (inheritedDiagnostics.length && next.diagnostics.length === 0) {
|
||||
next.diagnostics = [...inheritedDiagnostics];
|
||||
}
|
||||
|
||||
switch (event.type) {
|
||||
case 'generate':
|
||||
next.phase = 'generate_requested';
|
||||
next.pageUrl = event.pageUrl ?? next.pageUrl;
|
||||
next.expectedVariants = event.count ?? next.expectedVariants;
|
||||
next.pendingEventSeq = entry.seq ?? next.pendingEventSeq;
|
||||
next.pendingEvent = toPendingEvent(event);
|
||||
if (event.screenshotPath) upsertArtifact(next.annotationArtifacts, { type: 'screenshot', path: event.screenshotPath });
|
||||
break;
|
||||
case 'variants_ready':
|
||||
case 'agent_done':
|
||||
next.phase = event.carbonize === true ? 'carbonize_required' : 'variants_ready';
|
||||
next.sourceFile = event.file ?? next.sourceFile;
|
||||
next.arrivedVariants = event.arrivedVariants ?? (next.arrivedVariants ?? next.expectedVariants);
|
||||
next.pendingEventSeq = null;
|
||||
next.pendingEvent = null;
|
||||
if (event.carbonize === true) {
|
||||
next.diagnostics.push({
|
||||
error: 'carbonize_cleanup_required',
|
||||
file: event.file || null,
|
||||
message: 'Accepted variant still has carbonize markers that must be folded into source CSS.',
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'checkpoint':
|
||||
if ((event.revision ?? 0) >= (next.checkpointRevision ?? 0)) {
|
||||
next.phase = event.phase ?? next.phase;
|
||||
next.checkpointRevision = event.revision ?? next.checkpointRevision;
|
||||
next.activeOwner = event.owner ?? next.activeOwner;
|
||||
next.arrivedVariants = event.arrivedVariants ?? next.arrivedVariants;
|
||||
next.visibleVariant = event.visibleVariant ?? next.visibleVariant;
|
||||
if (event.paramValues) next.paramValues = { ...event.paramValues };
|
||||
} else {
|
||||
next.diagnostics.push({ error: 'stale_checkpoint_ignored', revision: event.revision });
|
||||
}
|
||||
break;
|
||||
case 'accept':
|
||||
case 'accept_intent':
|
||||
next.phase = 'accept_requested';
|
||||
next.visibleVariant = Number(event.variantId ?? next.visibleVariant);
|
||||
if (event.paramValues) next.paramValues = { ...event.paramValues };
|
||||
next.pendingEventSeq = entry.seq ?? next.pendingEventSeq;
|
||||
next.pendingEvent = toPendingEvent(event);
|
||||
break;
|
||||
case 'discard':
|
||||
next.phase = 'discard_requested';
|
||||
next.pendingEventSeq = entry.seq ?? next.pendingEventSeq;
|
||||
next.pendingEvent = toPendingEvent(event);
|
||||
break;
|
||||
case 'discarded':
|
||||
next.phase = 'discarded';
|
||||
next.pendingEventSeq = null;
|
||||
next.pendingEvent = null;
|
||||
break;
|
||||
case 'complete':
|
||||
next.phase = 'completed';
|
||||
next.pendingEventSeq = null;
|
||||
next.pendingEvent = null;
|
||||
break;
|
||||
case 'agent_error':
|
||||
next.phase = 'agent_error';
|
||||
next.pendingEventSeq = null;
|
||||
next.pendingEvent = null;
|
||||
next.diagnostics.push({ error: 'agent_error', message: event.message || 'unknown agent error' });
|
||||
break;
|
||||
default:
|
||||
next.diagnostics.push({ error: 'unknown_event_type', type: event.type });
|
||||
break;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function toPendingEvent(event) {
|
||||
const pending = { ...event };
|
||||
delete pending.token;
|
||||
return pending;
|
||||
}
|
||||
|
||||
function upsertArtifact(artifacts, artifact) {
|
||||
if (!artifacts.some((existing) => existing.path === artifact.path && existing.type === artifact.type)) {
|
||||
artifacts.push(artifact);
|
||||
}
|
||||
}
|
||||
|
||||
function writeSnapshot(snapshotPath, snapshot) {
|
||||
fs.writeFileSync(snapshotPath, JSON.stringify(snapshot, null, 2) + '\n');
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Print durable recovery status for Impeccable live sessions.
|
||||
*/
|
||||
|
||||
import { createLiveSessionStore } from './live-session-store.mjs';
|
||||
import { readLiveServerInfo } from './impeccable-paths.mjs';
|
||||
|
||||
function readServerInfo() {
|
||||
return readLiveServerInfo(process.cwd())?.info || null;
|
||||
}
|
||||
|
||||
async function fetchServerStatus(info) {
|
||||
if (!info) return null;
|
||||
try {
|
||||
const res = await fetch(`http://localhost:${info.port}/status?token=${info.token}`);
|
||||
if (!res.ok) return null;
|
||||
return await res.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function statusCli() {
|
||||
const info = readServerInfo();
|
||||
const server = await fetchServerStatus(info);
|
||||
const store = createLiveSessionStore({ cwd: process.cwd() });
|
||||
const activeSessions = store.listActiveSessions();
|
||||
const payload = {
|
||||
liveServer: server ? {
|
||||
status: server.status,
|
||||
port: server.port,
|
||||
connectedClients: server.connectedClients,
|
||||
pendingEvents: server.pendingEvents,
|
||||
} : null,
|
||||
activeSessions: server?.activeSessions || activeSessions,
|
||||
recoveryHint: server
|
||||
? 'Run live-poll.mjs to continue pending work, or live-complete.mjs --id <session> after manual cleanup.'
|
||||
: 'Start live-server.mjs to requeue pending durable events, then run live-poll.mjs.',
|
||||
};
|
||||
console.log(JSON.stringify(payload, null, 2));
|
||||
}
|
||||
|
||||
const _running = process.argv[1];
|
||||
if (_running?.endsWith('live-status.mjs') || _running?.endsWith('live-status.mjs/')) {
|
||||
statusCli();
|
||||
}
|
||||
@@ -185,6 +185,7 @@ The agent should insert variant HTML at insertLine.`);
|
||||
|
||||
const { startLine, endLine } = match;
|
||||
const commentSyntax = detectCommentSyntax(targetFile);
|
||||
const styleMode = detectStyleMode(targetFile);
|
||||
const isJsx = commentSyntax.open === '{/*';
|
||||
const indent = lines[startLine].match(/^(\s*)/)[1];
|
||||
|
||||
@@ -269,6 +270,10 @@ The agent should insert variant HTML at insertLine.`);
|
||||
endLine: startLine + wrapperLines.length + (originalLines.length - 1), // 1-indexed
|
||||
insertLine: insertLine + 1, // 1-indexed: where variants go
|
||||
commentSyntax: commentSyntax,
|
||||
styleMode: styleMode.mode,
|
||||
styleTag: styleMode.styleTag,
|
||||
cssSelectorPrefixExamples: buildCssSelectorPrefixExamples(styleMode.mode, count),
|
||||
cssAuthoring: buildCssAuthoring(styleMode, count),
|
||||
originalLineCount: originalLines.length,
|
||||
}));
|
||||
}
|
||||
@@ -335,6 +340,62 @@ function detectCommentSyntax(filePath) {
|
||||
return { open: '<!--', close: '-->' };
|
||||
}
|
||||
|
||||
function detectStyleMode(filePath) {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
if (ext === '.astro') {
|
||||
return {
|
||||
mode: 'astro-global-prefixed',
|
||||
styleTag: '<style is:inline data-impeccable-css="SESSION_ID">',
|
||||
};
|
||||
}
|
||||
return {
|
||||
mode: 'scoped',
|
||||
styleTag: '<style data-impeccable-css="SESSION_ID">',
|
||||
};
|
||||
}
|
||||
|
||||
function buildCssSelectorPrefixExamples(styleMode, count) {
|
||||
if (styleMode !== 'astro-global-prefixed') return [];
|
||||
return Array.from({ length: count }, (_, i) => `[data-impeccable-variant="${i + 1}"]`);
|
||||
}
|
||||
|
||||
function buildCssAuthoring(styleMode, count) {
|
||||
const variantNumbers = Array.from({ length: count }, (_, i) => i + 1);
|
||||
if (styleMode.mode === 'astro-global-prefixed') {
|
||||
return {
|
||||
mode: styleMode.mode,
|
||||
styleTag: styleMode.styleTag,
|
||||
strategy: 'global-prefixed',
|
||||
rulePattern: '[data-impeccable-variant="N"] > .variant-class { ... }',
|
||||
selectorExamples: variantNumbers.map((n) => `[data-impeccable-variant="${n}"] > .variant-class`),
|
||||
requirements: [
|
||||
'Use the styleTag exactly; the is:inline attribute is required for this file.',
|
||||
'Prefix every preview selector with the matching [data-impeccable-variant="N"] selector.',
|
||||
'Keep selectors anchored to the generated variant wrapper; do not rely on component CSS scoping for preview rules.',
|
||||
],
|
||||
forbidden: [
|
||||
'Do not use @scope for this styleMode.',
|
||||
],
|
||||
};
|
||||
}
|
||||
return {
|
||||
mode: styleMode.mode,
|
||||
styleTag: styleMode.styleTag,
|
||||
strategy: 'scope-rule',
|
||||
rulePattern: '@scope ([data-impeccable-variant="N"]) { :scope > .variant-class { ... } }',
|
||||
selectorExamples: variantNumbers.map((n) => `@scope ([data-impeccable-variant="${n}"]) { :scope > .variant-class { ... } }`),
|
||||
requirements: [
|
||||
'Use @scope blocks keyed to each [data-impeccable-variant="N"] wrapper.',
|
||||
'Inside each @scope block, make :scope rules step into the replacement element with a descendant combinator.',
|
||||
'Use the styleTag exactly; do not add framework-specific style attributes unless this object says to.',
|
||||
],
|
||||
forbidden: [
|
||||
'Do not use global [data-impeccable-variant="N"] selector prefixes for this styleMode.',
|
||||
'Do not add is:inline to the style tag for this styleMode.',
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Search project files for the query string (class name, ID, etc.)
|
||||
* Returns the first matching file path, or null.
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
* CLI entry point: prepare everything needed to enter the live variant poll loop.
|
||||
*
|
||||
* Does (all in one command):
|
||||
* 1. Check config.json (returns config_missing if first-ever run)
|
||||
* 1. Check .impeccable/live/config.json (returns config_missing if first-ever run)
|
||||
* 2. Start the live server in the background (or reuse a running one)
|
||||
* 3. Inject the browser script tag into the project's entry file
|
||||
* 4. Read .impeccable.md for design context (if present)
|
||||
* 4. Read PRODUCT.md / DESIGN.md for project context
|
||||
* 5. Print a single JSON blob with everything the agent needs
|
||||
*
|
||||
* After this, the agent's only remaining steps are:
|
||||
@@ -23,9 +23,9 @@ import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { loadContext } from './load-context.mjs';
|
||||
import { resolveFiles } from './live-inject.mjs';
|
||||
import { readLiveServerInfo } from './impeccable-paths.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PID_FILE = path.join(process.cwd(), '.impeccable-live.json');
|
||||
|
||||
async function liveCli() {
|
||||
const args = process.argv.slice(2);
|
||||
@@ -34,10 +34,10 @@ async function liveCli() {
|
||||
console.log(`Usage: node live.mjs
|
||||
|
||||
Prepare everything for live variant mode in a single command:
|
||||
- Checks scripts/config.json (required, created once per project)
|
||||
- Checks .impeccable/live/config.json (required, created once per project)
|
||||
- Starts (or reuses) the live server in the background
|
||||
- Injects the browser script tag
|
||||
- Reads .impeccable.md for design context
|
||||
- Reads PRODUCT.md / DESIGN.md for project context
|
||||
|
||||
On success, prints a JSON blob with:
|
||||
{ ok, serverPort, serverToken, pageFile, hasContext, context }
|
||||
@@ -223,7 +223,7 @@ function safeParse(out) {
|
||||
function ensureServerRunning() {
|
||||
// Try to reuse an existing server
|
||||
try {
|
||||
const existing = JSON.parse(fs.readFileSync(PID_FILE, 'utf-8'));
|
||||
const existing = readLiveServerInfo(process.cwd())?.info;
|
||||
if (existing && existing.pid) {
|
||||
try {
|
||||
process.kill(existing.pid, 0); // throws if dead
|
||||
|
||||
@@ -39,7 +39,7 @@ const LEGACY_NAMES = ['.impeccable.md'];
|
||||
const FALLBACK_DIRS = ['.agents/context', 'docs'];
|
||||
|
||||
/**
|
||||
* Resolve the directory that holds PRODUCT.md / DESIGN.md / DESIGN.json for
|
||||
* Resolve the directory that holds PRODUCT.md / DESIGN.md for
|
||||
* this project. Exported so other scripts (e.g. live-server.mjs) can read the
|
||||
* design files from the same location the loader uses.
|
||||
*/
|
||||
|
||||
+11
-3
@@ -37,12 +37,20 @@ Thumbs.db
|
||||
# Cloudflare
|
||||
.wrangler/
|
||||
|
||||
# Live mode session file + annotation screenshots (cleaned up on server stop)
|
||||
# Impeccable-owned project files are split: generated sidecars/config may be
|
||||
# tracked, but runtime recovery state and local assets should stay local.
|
||||
.impeccable/live/server.json
|
||||
.impeccable/live/sessions/
|
||||
.impeccable/live/annotations/
|
||||
.impeccable/live/cache/
|
||||
.impeccable/history/
|
||||
|
||||
# Legacy live mode session file + annotation screenshots
|
||||
.impeccable-live.json
|
||||
.impeccable-live/
|
||||
|
||||
# Per-project live mode injection config (generated once per project by the
|
||||
# skill; wiped on skill update, regenerated on next live run)
|
||||
# Legacy per-project live mode injection config. New installs use
|
||||
# .impeccable/live/config.json in the project root instead.
|
||||
**/skills/impeccable/scripts/config.json
|
||||
|
||||
# Extension build artifacts
|
||||
|
||||
@@ -237,11 +237,11 @@ Concrete, forceful guardrails. Lead each with "Do" or "Don't". Be specific: incl
|
||||
- **Don't** [...]
|
||||
```
|
||||
|
||||
### Step 4b: Write DESIGN.json sidecar (extensions only)
|
||||
### Step 4b: Write .impeccable/design.json sidecar (extensions only)
|
||||
|
||||
The frontmatter owns token primitives (colors, typography, rounded, spacing, components). The sidecar at `DESIGN.json` carries **what Stitch's schema can't hold**: tonal ramps per color, shadow/elevation tokens, motion tokens, breakpoints, full component HTML/CSS snippets (the panel renders these into a shadow DOM), and narrative (north star, rules, do's/don'ts). It extends the frontmatter, it doesn't duplicate it.
|
||||
The frontmatter owns token primitives (colors, typography, rounded, spacing, components). The sidecar at `.impeccable/design.json` carries **what Stitch's schema can't hold**: tonal ramps per color, shadow/elevation tokens, motion tokens, breakpoints, full component HTML/CSS snippets (the panel renders these into a shadow DOM), and narrative (north star, rules, do's/don'ts). It extends the frontmatter, it doesn't duplicate it.
|
||||
|
||||
Regenerate the sidecar whenever you regenerate DESIGN.md. If the user only asks to refresh the sidecar (e.g., from the live panel's stale-hint), preserve DESIGN.md and write only DESIGN.json.
|
||||
Regenerate the sidecar whenever you regenerate root `DESIGN.md`. If the user only asks to refresh the sidecar (e.g., from the live panel's stale-hint), preserve `DESIGN.md` and write only `.impeccable/design.json`.
|
||||
|
||||
#### Schema
|
||||
|
||||
@@ -310,7 +310,7 @@ Aim for a tight set of **5-10 components** that best represent the visual system
|
||||
- **Signature components (include if distinctive):** hero CTA, featured card, filter pill, any custom pattern the user mentioned as important in PRODUCT.md.
|
||||
- **Skip the rest.** Utility components, form building blocks, wrapper layouts: not worth documenting unless visually distinctive.
|
||||
|
||||
If the project has **no component library yet** (bare landing page, new project), synthesize canonical primitives from the tokens using best-practice defaults consistent with the DESIGN.md's rules. Every DESIGN.json has *something* to render, even on day zero.
|
||||
If the project has **no component library yet** (bare landing page, new project), synthesize canonical primitives from the tokens using best-practice defaults consistent with the DESIGN.md's rules. Every `.impeccable/design.json` has *something* to render, even on day zero.
|
||||
|
||||
#### Tonal ramps
|
||||
|
||||
@@ -331,7 +331,7 @@ Do not reword. The panel shows these as secondary collapsible context; the same
|
||||
### Step 5: Confirm, refine, and refresh session cache
|
||||
|
||||
1. Show the user the full DESIGN.md you wrote. Briefly highlight the non-obvious creative choices (descriptive color names, atmosphere language, named rules).
|
||||
2. Mention that `DESIGN.json` was also written alongside; the live panel will now render this project's actual button/input/nav primitives instead of generic approximations.
|
||||
2. Mention that `.impeccable/design.json` was also written alongside; the live panel will now render this project's actual button/input/nav primitives instead of generic approximations.
|
||||
3. Offer to refine any section: "Want me to revise a section, add component patterns I missed, or adjust the atmosphere language?"
|
||||
4. **Refresh the session cache.** Run `node .kiro/skills/impeccable/scripts/load-context.mjs` one final time so the newly-written DESIGN.md lands in conversation. Subsequent commands in this session will use the fresh version automatically without re-reading.
|
||||
|
||||
@@ -392,7 +392,7 @@ Per-section guidance in seed mode:
|
||||
- **Components**: omit entirely; no components exist yet.
|
||||
- **Do's and Don'ts**: carry PRODUCT.md's anti-references directly plus the anti-reference named in Q5.
|
||||
|
||||
Seed mode writes a minimal frontmatter with `name` and `description` only; no colors, typography, rounded, spacing, or components yet. Real tokens land on the next Scan-mode run. Skip the `DESIGN.json` sidecar in seed mode for the same reason: nothing to render.
|
||||
Seed mode writes a minimal frontmatter with `name` and `description` only; no colors, typography, rounded, spacing, or components yet. Real tokens land on the next Scan-mode run. Skip the `.impeccable/design.json` sidecar in seed mode for the same reason: nothing to render.
|
||||
|
||||
### Step 4: Confirm and refresh session cache
|
||||
|
||||
|
||||
@@ -12,8 +12,9 @@ Execute in order. No step skipped, no step reordered.
|
||||
2. Navigate to the URL that serves `pageFile` (infer from `package.json`, docs, terminal output, or an open tab). If you can't infer it confidently, tell the user once to open their dev/preview URL. Never use `serverPort` as that URL; it's the helper, not the app.
|
||||
3. Poll loop with the default long timeout (600000 ms). After every event or `--reply`, run `live-poll.mjs` again immediately. Never pass a short `--timeout=`.
|
||||
4. On `generate`: read screenshot if present; load the action's reference; plan three distinct directions; write all variants in one edit; `--reply done`; poll again.
|
||||
5. On `accept` / `discard`: the poll script already cleaned up; just poll again.
|
||||
6. On `exit`: run the cleanup at the bottom.
|
||||
5. On `accept` / `discard`: the poll script runs `live-accept.mjs`, acknowledges the delivered event, and prints `_completionAck`. Plain accepts/discards are terminal immediately; carbonize accepts remain recoverable until you finish cleanup, run `live-complete.mjs --id EVENT_ID`, and only then poll again.
|
||||
6. If interrupted, run `live-status.mjs` or `live-resume.mjs` before guessing. The durable journal replays unacknowledged work after helper restart.
|
||||
7. On `exit`: run the cleanup at the bottom.
|
||||
|
||||
Harness policy:
|
||||
- **Claude Code**: run the poll as a **background task** (no short timeout). The harness notifies you when it completes, so the main conversation stays free. Do not block the shell.
|
||||
@@ -43,13 +44,31 @@ LOOP:
|
||||
Read JSON; dispatch on "type"
|
||||
|
||||
"generate" → Handle Generate; reply done; LOOP
|
||||
"accept" → Handle Accept; LOOP
|
||||
"accept" → Handle Accept; complete carbonize cleanup if required; LOOP
|
||||
"discard" → Handle Discard; LOOP
|
||||
"prefetch" → Handle Prefetch; LOOP
|
||||
"timeout" → LOOP
|
||||
"exit" → break → Cleanup
|
||||
```
|
||||
|
||||
## Recovery commands
|
||||
|
||||
The live helper persists an append-only journal under `.impeccable/live/sessions/`. Browser checkpoints are advisory but durable; the journal is canonical. This is local durable recovery state, not project source.
|
||||
|
||||
Use these commands when the chat was interrupted, polling was missed, the helper restarted, or the browser reloaded:
|
||||
|
||||
```bash
|
||||
node .kiro/skills/impeccable/scripts/live-status.mjs
|
||||
node .kiro/skills/impeccable/scripts/live-resume.mjs --id SESSION_ID
|
||||
node .kiro/skills/impeccable/scripts/live-complete.mjs --id SESSION_ID
|
||||
```
|
||||
|
||||
- `live-status.mjs` prints connected helper state, active durable sessions, and queued pending events. It works even when the helper is down by reading the journal directly.
|
||||
- `live-resume.mjs` prints the active snapshot, pending event, checkpoint phase, visible variant, parameter values, and the next safe agent action.
|
||||
- `live-complete.mjs` is the canonical manual final acknowledgement. Use it after carbonize/manual cleanup is verified and no further poll acknowledgement will happen automatically.
|
||||
|
||||
Server restart rule: start `live-server.mjs` again, then poll. Startup requeues unacknowledged pending events from the journal, so do not ask the user to click Go again unless `live-resume.mjs` says no active session exists.
|
||||
|
||||
## Handle `generate`
|
||||
|
||||
Event: `{id, action, freeformPrompt?, count, pageUrl, element, screenshotPath?, comments?, strokes?}`.
|
||||
@@ -88,7 +107,14 @@ The helper searches ID first, then classes, then tag + class combo. If `event.pa
|
||||
|
||||
If `--text` matches multiple candidates equally well, wrap exits with `{ error: "element_ambiguous", candidates: [...] }` and `fallback: "agent-driven"`: read the candidate line ranges, decide which one matches the picked element from page context, and write the wrapper manually per the fallback flow.
|
||||
|
||||
Output on success: `{ file, insertLine, commentSyntax }`.
|
||||
Output on success: `{ file, insertLine, commentSyntax, styleMode, styleTag, cssSelectorPrefixExamples, cssAuthoring }`.
|
||||
|
||||
`styleMode` controls how preview CSS must be authored. Treat it as a detected capability mode, not a framework guess:
|
||||
|
||||
- `scoped`: use `@scope ([data-impeccable-variant="N"])` rules.
|
||||
- `astro-global-prefixed`: use explicit `[data-impeccable-variant="N"]` selector prefixes and the exact `styleTag` returned by the tool.
|
||||
|
||||
Use `cssAuthoring` as the source of truth for the current file. It includes the exact `styleTag`, selector strategy, selector examples, requirements, and forbidden patterns. Do not apply a framework-specific exception unless the returned `styleMode` / `cssAuthoring.mode` says to.
|
||||
|
||||
**Fallback errors.** Wrap only writes into files it judges to be source (tracked by git, not marked GENERATED, not listed in config's `generatedFiles`). If it can't land on a source file, it errors without writing; accepting a variant into a generated file is silent data loss. Three shapes:
|
||||
|
||||
@@ -208,13 +234,14 @@ When the prompt and PRODUCT.md anti-references conflict (the prompt asks for X,
|
||||
|
||||
Complete HTML replacement of the original element for each variant, not a CSS-only patch. Consider the element's context (computed styles, parent structure, CSS variables from `event.element`).
|
||||
|
||||
Write CSS + all variants in ONE edit at the `insertLine` reported by `wrap`. Colocate scoped CSS as a `<style>` tag inside the variant wrapper; `<style>` works anywhere in modern browsers and this ensures CSS and HTML arrive atomically (no FOUC).
|
||||
Write CSS + all variants in ONE edit at the `insertLine` reported by `wrap`. Colocate CSS as a `<style>` tag inside the variant wrapper; `<style>` works anywhere in modern browsers and this ensures CSS and HTML arrive atomically (no FOUC).
|
||||
|
||||
Use the `cssAuthoring` object returned by `live-wrap.mjs` to author the temporary preview CSS. The style opening tag shown below is the common case; replace it with `cssAuthoring.styleTag` when the tool returns a different one. The variant markup shape is otherwise stable:
|
||||
|
||||
```html
|
||||
<!-- Variants: insert below this line -->
|
||||
<style data-impeccable-css="SESSION_ID">
|
||||
@scope ([data-impeccable-variant="1"]) { ... }
|
||||
@scope ([data-impeccable-variant="2"]) { ... }
|
||||
/* rules matching cssAuthoring.rulePattern */
|
||||
</style>
|
||||
<div data-impeccable-variant="1">
|
||||
<!-- variant 1: full element replacement (single top-level element) -->
|
||||
@@ -229,11 +256,11 @@ Write CSS + all variants in ONE edit at the `insertLine` reported by `wrap`. Col
|
||||
|
||||
**Each variant div contains exactly one top-level element: the full replacement for the original.** Use the same tag as the original (e.g. `<section>` if the user picked a `<section>`). 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 `<style>` tag entirely. Use `@scope` for CSS isolation (Chrome 118+ / Firefox 128+ / Safari 17.4+).
|
||||
The first variant has no `display: none` (visible by default). All others do. If variants use only inline styles and no preview CSS, omit the `<style>` tag entirely.
|
||||
|
||||
One edit, all variants; the browser's MutationObserver picks everything up in one pass.
|
||||
|
||||
**Author every `:scope` rule with a descendant combinator.** The `@scope` boundary is the **variant wrapper `<div data-impeccable-variant="N">`**, not the element you're designing. A bare `:scope { background: cream; }` styles the wrapper, not the inner replacement, so the cream lands on a `display: contents` shell while the actual element keeps page defaults. Always step in: `:scope > .card`, `:scope > section`, `:scope .hero-title`, etc. The fake test agent's CSS in `tests/live-e2e/agent.mjs` is a faithful template; every rule starts `:scope > ...`.
|
||||
For `styleMode: "scoped"`, author every `:scope` rule with a descendant combinator. The `@scope` boundary is the **variant wrapper `<div data-impeccable-variant="N">`**, not the element you're designing. A bare `:scope { background: cream; }` styles the wrapper, not the inner replacement, so the cream lands on a `display: contents` shell while the actual element keeps page defaults. Always step in: `:scope > .card`, `:scope > section`, `:scope .hero-title`, etc. The fake test agent's CSS in `tests/live-e2e/agent.mjs` is a faithful template; every scoped rule starts `:scope > ...`.
|
||||
|
||||
**JSX / TSX target files.** Wrap `<style>` content in a template literal so the CSS `{` / `}` aren't parsed as JSX expressions, and use `className=` / `style={{…}}` on every variant element. Keep `data-impeccable-*` attributes as-is; they're plain strings:
|
||||
|
||||
@@ -369,10 +396,11 @@ Remove the wrapper you inserted in Step 2. Nothing else to do.
|
||||
|
||||
## Handle `accept`
|
||||
|
||||
Event: `{id, variantId, _acceptResult}`. The poll script already ran `live-accept.mjs` to handle the file operation deterministically; the browser DOM is already updated.
|
||||
Event: `{id, variantId, _acceptResult, _completionAck}`. The poll script already ran `live-accept.mjs` to handle the file operation deterministically, then acknowledged event delivery to the helper. The browser DOM is already updated.
|
||||
|
||||
- `_completionAck.ok !== true`: do not poll yet. Run `live-status.mjs` / `live-resume.mjs`, complete the cleanup manually if needed, then run `live-complete.mjs --id EVENT_ID`.
|
||||
- `_acceptResult.handled: true` and `carbonize: false`: nothing to do. Poll again.
|
||||
- `_acceptResult.handled: true` and `carbonize: true`: **post-accept cleanup is required before the next poll.** See the "Required after accept (carbonize)" section below. The `event._acceptResult.todo` field and a stderr banner both list the steps explicitly; neither is decorative.
|
||||
- `_acceptResult.handled: true` and `carbonize: true`: **post-accept cleanup is required before the next poll.** See the "Required after accept (carbonize)" section below. The `event._acceptResult.todo` field, `_completionAck.requiresComplete`, and a stderr banner all point at this required follow-up; none are decorative. After cleanup, run `live-complete.mjs --id EVENT_ID`, then poll again.
|
||||
- `_acceptResult.handled: false, mode: "fallback"`: the session lived in a generated file and the script refused to persist there. You've already written the accepted variant into true source during Handle fallback Step 3; just clean up the temporary wrapper in the served file if any, and poll again.
|
||||
- `_acceptResult.handled: false` without `mode`: manual cleanup: read file, find markers, edit.
|
||||
|
||||
@@ -388,13 +416,13 @@ Do these five steps in the current thread, synchronously, before the next poll.
|
||||
4. **Unwrap the accepted content.** Delete the `<div data-impeccable-variant="N" style="display: contents">` that wraps it. Drop `data-impeccable-params` and any `data-p-*` attributes from it; those are live-mode plumbing, not source.
|
||||
5. **Delete the inline `<style>` block, the `<!-- impeccable-param-values -->` comment if present, and both `<!-- impeccable-carbonize-start/end -->` markers.** Also drop any `@scope` rules for variants other than the accepted one; those are dead code now.
|
||||
|
||||
Then poll again.
|
||||
After the file is clean, run `live-complete.mjs --id SESSION_ID`, verify it reports `phase: "completed"`, then poll again.
|
||||
|
||||
A background agent may be used for the rewrite, but the current thread is responsible for verifying the five steps are complete before issuing the next poll. In practice, inline is usually faster and less error-prone.
|
||||
|
||||
## Handle `discard`
|
||||
|
||||
Event: `{id, _acceptResult}`. The poll script already restored the original and removed all variant markers. Nothing to do. Poll again.
|
||||
Event: `{id, _acceptResult, _completionAck}`. The poll script already restored the original, removed all variant markers, and acknowledged `discarded` durable completion. Nothing to do unless `_completionAck.ok !== true`; in that case run `live-complete.mjs --id EVENT_ID --discarded`, then poll again.
|
||||
|
||||
## Handle `prefetch`
|
||||
|
||||
@@ -424,7 +452,7 @@ When the poll returns `exit`, proceed to cleanup. If the poll is still running a
|
||||
node .kiro/skills/impeccable/scripts/live-server.mjs stop
|
||||
```
|
||||
|
||||
Stops the HTTP server and runs `live-inject.mjs --remove` to strip `localhost:…/live.js` from the HTML entry. To stop the server but keep the inject tag (for a quick restart), use `stop --keep-inject`. `config.json` persists for future sessions.
|
||||
Stops the HTTP server and runs `live-inject.mjs --remove` to strip `localhost:…/live.js` from the HTML entry. To stop the server but keep the inject tag (for a quick restart), use `stop --keep-inject`. `.impeccable/live/config.json` persists as project config for future sessions.
|
||||
|
||||
Then:
|
||||
- Remove any leftover variant wrappers (search for `impeccable-variants-start` markers).
|
||||
@@ -432,7 +460,7 @@ Then:
|
||||
|
||||
## First-time setup (config missing or invalid)
|
||||
|
||||
If `live.mjs` outputs `{ ok: false, error: "config_missing" | "config_invalid", path }`, write `config.json` at the reported path.
|
||||
If `live.mjs` outputs `{ ok: false, error: "config_missing" | "config_invalid", path }`, write the live config at the reported path. By default this is `.impeccable/live/config.json`.
|
||||
|
||||
Schema:
|
||||
|
||||
@@ -512,7 +540,7 @@ node .kiro/skills/impeccable/scripts/detect-csp.mjs
|
||||
|
||||
Output: `{ shape, signals }` where `shape` is one of `append-arrays`, `append-string`, `middleware`, `meta-tag`, or `null`. The shape is named by *patch mechanism*, so one template covers many frameworks.
|
||||
|
||||
- **`null`**: no CSP; skip to writing `config.json` with `cspChecked: true`.
|
||||
- **`null`**: no CSP; skip to writing `.impeccable/live/config.json` with `cspChecked: true`.
|
||||
- **`append-arrays`**: CSP defined as structured directive arrays. Auto-patchable. See *append-arrays* below. Covers:
|
||||
- Monorepo helpers with `additionalScriptSrc` / `additionalConnectSrc` options (Next.js + shared config package)
|
||||
- SvelteKit `kit.csp.directives`
|
||||
@@ -589,6 +617,6 @@ Reference outputs:
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
If a user says "no" to the CSP patch at setup time and later complains that live doesn't work: their dev CSP blocks `http://localhost:8400`. Fix: delete `cspChecked` from `config.json` and re-run `live.mjs`: setup will ask again.
|
||||
If a user says "no" to the CSP patch at setup time and later complains that live doesn't work: their dev CSP blocks `http://localhost:8400`. Fix: delete `cspChecked` from `.impeccable/live/config.json` and re-run `live.mjs`: setup will ask again.
|
||||
|
||||
Then re-run `live.mjs`.
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
Gathers design context for a project and writes two complementary files at the project root:
|
||||
|
||||
- **PRODUCT.md** (strategic): register, target users, product purpose, brand personality, anti-references, strategic design principles. Answers "who/what/why".
|
||||
- **DESIGN.md** (visual): visual theme, color palette, typography, components, layout. Follows the [Google Stitch DESIGN.md format](https://stitch.withgoogle.com/docs/design-md/format/). Answers "how it looks".
|
||||
- **PRODUCT.md** (strategic): root project file for register, target users, product purpose, brand personality, anti-references, strategic design principles. Answers "who/what/why".
|
||||
- **DESIGN.md** (visual): root project file for visual theme, color palette, typography, components, layout. Follows the [Google Stitch DESIGN.md format](https://stitch.withgoogle.com/docs/design-md/format/). Answers "how it looks".
|
||||
|
||||
Every other impeccable command reads these files before doing any work.
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
export const IMPECCABLE_DIR = '.impeccable';
|
||||
export const LIVE_DIR = 'live';
|
||||
|
||||
export function getImpeccableDir(cwd = process.cwd()) {
|
||||
return path.join(cwd, IMPECCABLE_DIR);
|
||||
}
|
||||
|
||||
export function getDesignSidecarPath(cwd = process.cwd()) {
|
||||
return path.join(getImpeccableDir(cwd), 'design.json');
|
||||
}
|
||||
|
||||
export function getDesignSidecarCandidates(cwd = process.cwd(), contextDir = cwd) {
|
||||
const candidates = [
|
||||
getDesignSidecarPath(cwd),
|
||||
path.join(cwd, 'DESIGN.json'),
|
||||
];
|
||||
const contextLegacy = path.join(contextDir, 'DESIGN.json');
|
||||
if (!candidates.includes(contextLegacy)) candidates.push(contextLegacy);
|
||||
return candidates;
|
||||
}
|
||||
|
||||
export function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd) {
|
||||
return firstExisting(getDesignSidecarCandidates(cwd, contextDir));
|
||||
}
|
||||
|
||||
export function getLiveDir(cwd = process.cwd()) {
|
||||
return path.join(getImpeccableDir(cwd), LIVE_DIR);
|
||||
}
|
||||
|
||||
export function getLiveConfigPath(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'config.json');
|
||||
}
|
||||
|
||||
export function getLegacyLiveConfigPath(scriptsDir) {
|
||||
return path.join(scriptsDir, 'config.json');
|
||||
}
|
||||
|
||||
export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = process.env } = {}) {
|
||||
if (env.IMPECCABLE_LIVE_CONFIG && env.IMPECCABLE_LIVE_CONFIG.trim()) {
|
||||
const configured = env.IMPECCABLE_LIVE_CONFIG.trim();
|
||||
return path.isAbsolute(configured) ? configured : path.resolve(cwd, configured);
|
||||
}
|
||||
const primary = getLiveConfigPath(cwd);
|
||||
if (fs.existsSync(primary)) return primary;
|
||||
if (scriptsDir) {
|
||||
const legacy = getLegacyLiveConfigPath(scriptsDir);
|
||||
if (fs.existsSync(legacy)) return legacy;
|
||||
}
|
||||
return primary;
|
||||
}
|
||||
|
||||
export function getLiveServerPath(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'server.json');
|
||||
}
|
||||
|
||||
export function getLegacyLiveServerPath(cwd = process.cwd()) {
|
||||
return path.join(cwd, '.impeccable-live.json');
|
||||
}
|
||||
|
||||
export function readLiveServerInfo(cwd = process.cwd()) {
|
||||
for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) {
|
||||
try {
|
||||
return { info: JSON.parse(fs.readFileSync(filePath, 'utf-8')), path: filePath };
|
||||
} catch {
|
||||
/* try next */
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function writeLiveServerInfo(cwd = process.cwd(), info) {
|
||||
const filePath = getLiveServerPath(cwd);
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, JSON.stringify(info));
|
||||
return filePath;
|
||||
}
|
||||
|
||||
export function removeLiveServerInfo(cwd = process.cwd()) {
|
||||
for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) {
|
||||
try { fs.unlinkSync(filePath); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
export function getLiveSessionsDir(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'sessions');
|
||||
}
|
||||
|
||||
export function getLegacyLiveSessionsDir(cwd = process.cwd()) {
|
||||
return path.join(cwd, '.impeccable-live', 'sessions');
|
||||
}
|
||||
|
||||
export function getLiveAnnotationsDir(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'annotations');
|
||||
}
|
||||
|
||||
export function getLegacyLiveAnnotationsDir(cwd = process.cwd()) {
|
||||
return path.join(cwd, '.impeccable-live', 'annotations');
|
||||
}
|
||||
|
||||
function firstExisting(paths) {
|
||||
return paths.find((filePath) => fs.existsSync(filePath)) || null;
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Browser-side durable session helpers for Impeccable live mode.
|
||||
*
|
||||
* Kept separate from live-browser.js so recovery state can be tested without
|
||||
* booting the full overlay UI. Served before live-browser.js and attached to
|
||||
* window.__IMPECCABLE_LIVE_SESSION__.
|
||||
*/
|
||||
(function (root) {
|
||||
'use strict';
|
||||
|
||||
function createLiveBrowserSessionState({ prefix, storage, idFactory }) {
|
||||
if (!prefix) throw new Error('prefix required');
|
||||
const store = storage || root.localStorage;
|
||||
const makeId = idFactory || function () { return Math.random().toString(16).slice(2, 10); };
|
||||
const sessionKey = prefix + '-session';
|
||||
const handledKey = sessionKey + '-handled';
|
||||
const scrollKey = sessionKey + '-scroll';
|
||||
let checkpointRevision = 0;
|
||||
const owner = makeId();
|
||||
|
||||
function safeRead(key) {
|
||||
try { return store.getItem(key); } catch { return null; }
|
||||
}
|
||||
|
||||
function safeWrite(key, value) {
|
||||
try { store.setItem(key, value); } catch { /* quota exceeded or private mode */ }
|
||||
}
|
||||
|
||||
function safeRemove(key) {
|
||||
try { store.removeItem(key); } catch { /* unavailable storage */ }
|
||||
}
|
||||
|
||||
function loadSession() {
|
||||
try {
|
||||
const raw = safeRead(sessionKey);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Number.isInteger(parsed.checkpointRevision)) {
|
||||
checkpointRevision = Math.max(checkpointRevision, parsed.checkpointRevision);
|
||||
}
|
||||
return parsed;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
function saveSession(session) {
|
||||
if (!session || !session.id) return;
|
||||
const payload = {
|
||||
...session,
|
||||
checkpointRevision,
|
||||
};
|
||||
safeWrite(sessionKey, JSON.stringify(payload));
|
||||
}
|
||||
|
||||
function clearSession() {
|
||||
safeRemove(sessionKey);
|
||||
}
|
||||
|
||||
function nextCheckpointRevision() {
|
||||
checkpointRevision += 1;
|
||||
const existing = loadSession();
|
||||
if (existing?.id) saveSession(existing);
|
||||
return checkpointRevision;
|
||||
}
|
||||
|
||||
function seedCheckpointRevision(value) {
|
||||
if (Number.isInteger(value)) checkpointRevision = Math.max(checkpointRevision, value);
|
||||
return checkpointRevision;
|
||||
}
|
||||
|
||||
function currentCheckpointRevision() {
|
||||
return checkpointRevision;
|
||||
}
|
||||
|
||||
function markHandled(id) {
|
||||
if (!id) return;
|
||||
safeWrite(handledKey, id);
|
||||
}
|
||||
|
||||
function isHandled(id) {
|
||||
return !!id && safeRead(handledKey) === id;
|
||||
}
|
||||
|
||||
function clearHandled() {
|
||||
safeRemove(handledKey);
|
||||
}
|
||||
|
||||
function writeScrollY(y) {
|
||||
safeWrite(scrollKey, String(y));
|
||||
}
|
||||
|
||||
function readScrollY() {
|
||||
const raw = safeRead(scrollKey);
|
||||
if (raw == null) return null;
|
||||
const n = parseFloat(raw);
|
||||
return isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
function clearScrollY() {
|
||||
safeRemove(scrollKey);
|
||||
}
|
||||
|
||||
return {
|
||||
owner,
|
||||
sessionKey,
|
||||
handledKey,
|
||||
scrollKey,
|
||||
saveSession,
|
||||
loadSession,
|
||||
clearSession,
|
||||
nextCheckpointRevision,
|
||||
seedCheckpointRevision,
|
||||
currentCheckpointRevision,
|
||||
markHandled,
|
||||
isHandled,
|
||||
clearHandled,
|
||||
writeScrollY,
|
||||
readScrollY,
|
||||
clearScrollY,
|
||||
};
|
||||
}
|
||||
|
||||
root.__IMPECCABLE_LIVE_SESSION__ = { createLiveBrowserSessionState };
|
||||
})(typeof window !== 'undefined' ? window : globalThis);
|
||||
@@ -50,6 +50,16 @@
|
||||
const Z = { highlight: 100001, bar: 100005, picker: 100007, toast: 100010 };
|
||||
const EASE = 'cubic-bezier(0.22, 1, 0.36, 1)'; // ease-out-quint
|
||||
const PREFIX = 'impeccable-live';
|
||||
const sessionState = window.__IMPECCABLE_LIVE_SESSION__?.createLiveBrowserSessionState({
|
||||
prefix: PREFIX,
|
||||
storage: localStorage,
|
||||
idFactory: () => crypto.randomUUID().replace(/-/g, '').slice(0, 8),
|
||||
});
|
||||
if (!sessionState) {
|
||||
console.error('[impeccable] live-browser-session.js was not loaded. Live mode cannot start safely.');
|
||||
window.__IMPECCABLE_LIVE_INIT__ = false;
|
||||
return;
|
||||
}
|
||||
const HIGHLIGHT_TRANSITION =
|
||||
'top 140ms ' + EASE +
|
||||
', left 140ms ' + EASE +
|
||||
@@ -112,6 +122,8 @@
|
||||
let hasProjectContext = false;
|
||||
let selectedAction = 'impeccable';
|
||||
let selectedCount = 3;
|
||||
const browserOwner = sessionState.owner;
|
||||
let checkpointTimer = null;
|
||||
|
||||
// Scroll lock — holds window.scrollY at a fixed value while the session is
|
||||
// active, so HMR DOM patches and variant swaps can't drift the page. See
|
||||
@@ -126,21 +138,9 @@
|
||||
// (Previously: saveSession wrote scrollY alongside state, so every call
|
||||
// during resume overwrote the pre-reload value with whatever the browser
|
||||
// had landed on, typically 0.)
|
||||
const SCROLL_KEY_SUFFIX = '-scroll';
|
||||
function writeScrollY(y) {
|
||||
try { localStorage.setItem(LS_KEY + SCROLL_KEY_SUFFIX, String(y)); } catch {}
|
||||
}
|
||||
function readScrollY() {
|
||||
try {
|
||||
const raw = localStorage.getItem(LS_KEY + SCROLL_KEY_SUFFIX);
|
||||
if (raw == null) return null;
|
||||
const n = parseFloat(raw);
|
||||
return isFinite(n) ? n : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
function clearScrollY() {
|
||||
try { localStorage.removeItem(LS_KEY + SCROLL_KEY_SUFFIX); } catch {}
|
||||
}
|
||||
function writeScrollY(y) { sessionState.writeScrollY(y); }
|
||||
function readScrollY() { return sessionState.readScrollY(); }
|
||||
function clearScrollY() { sessionState.clearScrollY(); }
|
||||
|
||||
// Pre-empt the browser: apply manual scroll restoration and jump to the
|
||||
// saved scrollY at script-parse time. Retries on fonts.ready and load
|
||||
@@ -1585,6 +1585,7 @@
|
||||
paramsCurrentValues[p.id] = v;
|
||||
readout.textContent = formatRangeValue(input);
|
||||
applyParamValue(variantEl, p, v);
|
||||
queueCheckpoint('param_changed');
|
||||
});
|
||||
row.appendChild(input);
|
||||
} else if (p.kind === 'toggle') {
|
||||
@@ -1615,6 +1616,7 @@
|
||||
knob.style.left = next ? '18px' : '2px';
|
||||
readout.textContent = next ? 'On' : 'Off';
|
||||
applyParamValue(variantEl, p, next);
|
||||
queueCheckpoint('param_changed');
|
||||
});
|
||||
row.appendChild(track);
|
||||
} else if (p.kind === 'steps') {
|
||||
@@ -1651,6 +1653,7 @@
|
||||
btn.style.color = on ? 'oklch(98% 0 0)' : P.text;
|
||||
});
|
||||
applyParamValue(variantEl, p, o.value);
|
||||
queueCheckpoint('param_changed');
|
||||
});
|
||||
segRow.appendChild(b);
|
||||
segBtns.push({ btn: b, val: o.value });
|
||||
@@ -1872,19 +1875,26 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const previousVisibleVariant = currentSessionId === sessionId ? visibleVariant : 0;
|
||||
|
||||
// Replace the live element with the full wrapper from source
|
||||
const wrapper = srcWrapper.cloneNode(true);
|
||||
liveEl.parentElement.replaceChild(wrapper, liveEl);
|
||||
|
||||
// Update state: count variants, show the first one
|
||||
// Update state: count variants, preserving the user's current variant
|
||||
// when a late HMR/source reinjection lands after they have cycled.
|
||||
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
|
||||
arrivedVariants = variants.length;
|
||||
expectedVariants = parseInt(wrapper.dataset.impeccableVariantCount || arrivedVariants);
|
||||
visibleVariant = 1;
|
||||
showVariantInDOM(sessionId, 1);
|
||||
const saved = loadSession();
|
||||
const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0;
|
||||
visibleVariant = previousVisibleVariant > 0 && previousVisibleVariant <= arrivedVariants
|
||||
? previousVisibleVariant
|
||||
: (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1);
|
||||
showVariantInDOM(sessionId, visibleVariant);
|
||||
|
||||
// Update selectedElement to the visible variant's content
|
||||
selectedElement = pickVariantContent(wrapper, 1) || wrapper.parentElement;
|
||||
selectedElement = pickVariantContent(wrapper, visibleVariant) || wrapper.parentElement;
|
||||
|
||||
state = 'CYCLING';
|
||||
hideShaderOverlay();
|
||||
@@ -1907,6 +1917,7 @@
|
||||
updateSelectedElement();
|
||||
updateBarContent('cycling');
|
||||
saveSession();
|
||||
queueCheckpoint('variant_changed');
|
||||
}
|
||||
|
||||
function updateSelectedElement() {
|
||||
@@ -1917,6 +1928,18 @@
|
||||
if (visEl) selectedElement = visEl;
|
||||
}
|
||||
|
||||
function readVisibleVariantFromDOM(sessionId) {
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return 0;
|
||||
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
|
||||
for (const variant of variants) {
|
||||
if (variant.style.display === 'none') continue;
|
||||
const idx = parseInt(variant.dataset.impeccableVariant || '0', 10);
|
||||
if (idx > 0) return idx;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Resolve the element that represents the variant's visible content.
|
||||
// Contract: each variant div should contain exactly one top-level element
|
||||
// (the full replacement). In practice a model may ship loose siblings or
|
||||
@@ -2107,8 +2130,10 @@
|
||||
updating = true;
|
||||
arrivedVariants = count;
|
||||
if (visibleVariant === 0 && arrivedVariants > 0) {
|
||||
visibleVariant = 1;
|
||||
showVariantInDOM(sessionId, 1);
|
||||
const saved = loadSession();
|
||||
const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0;
|
||||
visibleVariant = savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1;
|
||||
showVariantInDOM(sessionId, visibleVariant);
|
||||
// showVariantInDOM hid the original (display:none); if we were still
|
||||
// anchored to the original's content, its boundingRect is now zero
|
||||
// and the bar snaps to (0,0). Re-point at the visible variant instead.
|
||||
@@ -2128,6 +2153,7 @@
|
||||
updateBarContent('generating');
|
||||
}
|
||||
saveSession();
|
||||
queueCheckpoint(state === 'CYCLING' ? 'variants_ready' : 'variants_progress');
|
||||
updating = false;
|
||||
});
|
||||
|
||||
@@ -2236,6 +2262,7 @@
|
||||
|
||||
/** Server died or became unreachable. Reset UI to a clean state. */
|
||||
function handleServerLost() {
|
||||
const recoveryState = currentSessionId ? state : 'IDLE';
|
||||
if (state === 'GENERATING' || state === 'CYCLING' || state === 'SAVING') {
|
||||
showToast('Live server disconnected. Session ended.', 5000);
|
||||
}
|
||||
@@ -2246,21 +2273,61 @@
|
||||
stopScrollTracking();
|
||||
if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
|
||||
stopScrollLock();
|
||||
clearScrollY();
|
||||
clearSession();
|
||||
// Preserve local session state on server loss. The durable journal is the
|
||||
// source of truth, but localStorage plus the variant wrapper lets the UI
|
||||
// resume after a helper restart or page reload instead of treating a
|
||||
// transient disconnect as an explicit discard.
|
||||
selectedElement = null;
|
||||
currentSessionId = null;
|
||||
selectedAction = 'impeccable';
|
||||
state = 'IDLE';
|
||||
state = recoveryState;
|
||||
if (currentSessionId) saveSession();
|
||||
}
|
||||
|
||||
function sendEvent(msg) {
|
||||
function sendEvent(msg, opts) {
|
||||
msg.token = TOKEN;
|
||||
fetch('http://localhost:' + PORT + '/events', {
|
||||
function handleFailure(err) {
|
||||
console.error('[impeccable] Failed to send event:', err);
|
||||
if (opts && opts.throwOnError) throw err;
|
||||
return null;
|
||||
}
|
||||
return fetch('http://localhost:' + PORT + '/events', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(msg),
|
||||
}).catch(err => console.error('[impeccable] Failed to send event:', err));
|
||||
}).then(res => {
|
||||
if (res.ok) return res;
|
||||
return handleFailure(new Error('HTTP ' + res.status + ' ' + res.statusText));
|
||||
}).catch(handleFailure);
|
||||
}
|
||||
|
||||
function checkpointPayload(reason) {
|
||||
return {
|
||||
type: 'checkpoint',
|
||||
id: currentSessionId,
|
||||
revision: sessionState.nextCheckpointRevision(),
|
||||
owner: browserOwner,
|
||||
phase: String(state || '').toLowerCase(),
|
||||
reason,
|
||||
pageUrl: location.pathname,
|
||||
expectedVariants,
|
||||
arrivedVariants,
|
||||
visibleVariant,
|
||||
paramValues: { ...paramsCurrentValues },
|
||||
};
|
||||
}
|
||||
|
||||
function sendCheckpoint(reason) {
|
||||
if (!currentSessionId) return Promise.resolve(null);
|
||||
return sendEvent(checkpointPayload(reason)).catch(() => null);
|
||||
}
|
||||
|
||||
function queueCheckpoint(reason) {
|
||||
if (!currentSessionId) return;
|
||||
if (checkpointTimer) clearTimeout(checkpointTimer);
|
||||
checkpointTimer = setTimeout(() => {
|
||||
checkpointTimer = null;
|
||||
sendCheckpoint(reason);
|
||||
}, 120);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -2496,6 +2563,7 @@
|
||||
state = 'GENERATING';
|
||||
showBar('generating');
|
||||
saveSession();
|
||||
sendCheckpoint('generate_started');
|
||||
writeScrollY(window.scrollY);
|
||||
if (variantObserver) variantObserver.disconnect();
|
||||
variantObserver = startVariantObserver(currentSessionId);
|
||||
@@ -2920,13 +2988,12 @@ void main() {
|
||||
|
||||
function handleAccept() {
|
||||
if (!currentSessionId || arrivedVariants === 0) return;
|
||||
const domVisibleVariant = readVisibleVariantFromDOM(currentSessionId);
|
||||
if (domVisibleVariant > 0) visibleVariant = domVisibleVariant;
|
||||
const acceptPayload = { type: 'accept', id: currentSessionId, variantId: String(visibleVariant) };
|
||||
if (Object.keys(paramsCurrentValues).length > 0) {
|
||||
acceptPayload.paramValues = { ...paramsCurrentValues };
|
||||
}
|
||||
sendEvent(acceptPayload);
|
||||
markSessionHandled();
|
||||
|
||||
// The accepted variant is already the only visible child of the wrapper
|
||||
// (all other variants are display:none). HMR from the source rewrite will
|
||||
// replace the wrapper imminently. Don't eagerly replaceChild here — React
|
||||
@@ -2936,9 +3003,28 @@ void main() {
|
||||
const acceptedSessionId = currentSessionId;
|
||||
const acceptedVariant = visibleVariant;
|
||||
|
||||
state = 'CONFIRMED';
|
||||
updateBarContent('confirmed');
|
||||
setTimeout(function() {
|
||||
state = 'SAVING';
|
||||
updateBarContent('saving');
|
||||
|
||||
sendEvent(acceptPayload, { throwOnError: true })
|
||||
.then(() => {
|
||||
markSessionHandled();
|
||||
confirmAcceptAfterReceipt();
|
||||
})
|
||||
.catch(() => {
|
||||
state = 'CYCLING';
|
||||
updateBarContent('cycling');
|
||||
showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000);
|
||||
});
|
||||
|
||||
function confirmAcceptAfterReceipt() {
|
||||
state = 'CONFIRMED';
|
||||
updateBarContent('confirmed');
|
||||
scheduleAcceptCleanup();
|
||||
}
|
||||
|
||||
function scheduleAcceptCleanup() {
|
||||
setTimeout(function() {
|
||||
hideBar();
|
||||
hideHighlight();
|
||||
stopScrollTracking();
|
||||
@@ -2967,50 +3053,46 @@ void main() {
|
||||
accepted.style.display = 'contents';
|
||||
parent.replaceChild(accepted, wrapper);
|
||||
}
|
||||
}, 2000);
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDiscard() {
|
||||
if (!currentSessionId) return;
|
||||
sendEvent({ type: 'discard', id: currentSessionId });
|
||||
markSessionHandled();
|
||||
// Instant DOM restore + fire-and-forget (script handles file cleanup)
|
||||
cleanup();
|
||||
sendEvent({ type: 'discard', id: currentSessionId }, { throwOnError: true })
|
||||
.then(() => {
|
||||
markSessionHandled();
|
||||
cleanup();
|
||||
})
|
||||
.catch(() => showToast('Could not confirm discard with the live server. Session kept for recovery.', 5000));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session persistence via localStorage
|
||||
// Session persistence via live-browser-session.js
|
||||
// ---------------------------------------------------------------------------
|
||||
// Survives page reloads, browser close/reopen, HMR, and accidental refreshes.
|
||||
|
||||
const LS_KEY = PREFIX + '-session';
|
||||
|
||||
function saveSession() {
|
||||
if (!currentSessionId) return;
|
||||
// NOTE: scrollY is stored under a separate key (writeScrollY). Storing
|
||||
// it here would overwrite the Go-time value every time state changes.
|
||||
try {
|
||||
localStorage.setItem(LS_KEY, JSON.stringify({
|
||||
id: currentSessionId,
|
||||
state: state,
|
||||
action: selectedAction,
|
||||
count: selectedCount,
|
||||
expected: expectedVariants,
|
||||
arrived: arrivedVariants,
|
||||
visible: visibleVariant,
|
||||
}));
|
||||
} catch { /* quota exceeded or private mode */ }
|
||||
sessionState.saveSession({
|
||||
id: currentSessionId,
|
||||
state,
|
||||
action: selectedAction,
|
||||
count: selectedCount,
|
||||
expected: expectedVariants,
|
||||
arrived: arrivedVariants,
|
||||
visible: visibleVariant,
|
||||
});
|
||||
}
|
||||
|
||||
function loadSession() {
|
||||
try {
|
||||
const raw = localStorage.getItem(LS_KEY);
|
||||
return raw ? JSON.parse(raw) : null;
|
||||
} catch { return null; }
|
||||
return sessionState.loadSession();
|
||||
}
|
||||
|
||||
function clearSession() {
|
||||
try { localStorage.removeItem(LS_KEY); } catch {}
|
||||
sessionState.clearSession();
|
||||
}
|
||||
|
||||
/** Mark session as handled (accepted/discarded). The agent will clean up
|
||||
@@ -3018,19 +3100,15 @@ void main() {
|
||||
* prevents resumeSession from picking it up again after reload. */
|
||||
function markSessionHandled() {
|
||||
if (!currentSessionId) return;
|
||||
try {
|
||||
localStorage.setItem(LS_KEY + '-handled', currentSessionId);
|
||||
} catch {}
|
||||
sessionState.markHandled(currentSessionId);
|
||||
}
|
||||
|
||||
function isSessionHandled(id) {
|
||||
try {
|
||||
return localStorage.getItem(LS_KEY + '-handled') === id;
|
||||
} catch { return false; }
|
||||
return sessionState.isHandled(id);
|
||||
}
|
||||
|
||||
function clearHandled() {
|
||||
try { localStorage.removeItem(LS_KEY + '-handled'); } catch {}
|
||||
sessionState.clearHandled();
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
@@ -3161,6 +3239,7 @@ void main() {
|
||||
// hid. Now that state is CYCLING, re-fire.
|
||||
if (state === 'CYCLING') refreshParamsPanel();
|
||||
saveSession();
|
||||
queueCheckpoint('browser_resumed');
|
||||
|
||||
// Start observing for more variants AFTER initial setup
|
||||
if (variantObserver) variantObserver.disconnect();
|
||||
@@ -3617,7 +3696,7 @@ void main() {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Design System Panel — visualizes the project's DESIGN.json sidecar
|
||||
// Design System Panel — visualizes the project's .impeccable/design.json sidecar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DESIGN_PREFS_KEY = 'impeccable-live-design-panel';
|
||||
@@ -3629,7 +3708,7 @@ void main() {
|
||||
open: false,
|
||||
tab: 'visual', // 'visual' | 'raw'
|
||||
parsed: null, // parseDesignMd output (frontmatter + body sections)
|
||||
sidecar: null, // DESIGN.json v2 payload (extensions + components + narrative)
|
||||
sidecar: null, // .impeccable/design.json v2 payload (extensions + components + narrative)
|
||||
hasMd: false,
|
||||
hasSidecar: false,
|
||||
present: null, // true/false once fetch resolves
|
||||
@@ -4130,7 +4209,7 @@ void main() {
|
||||
box.className = 'stale';
|
||||
box.innerHTML = `
|
||||
<span class="stale-dot"></span>
|
||||
<span class="stale-text"><strong>DESIGN.md is newer than DESIGN.json.</strong> Run <code>/impeccable document</code> to refresh the sidecar.</span>
|
||||
<span class="stale-text"><strong>DESIGN.md is newer than .impeccable/design.json.</strong> Run <code>/impeccable document</code> to refresh the sidecar.</span>
|
||||
`;
|
||||
return box;
|
||||
}
|
||||
@@ -4138,7 +4217,7 @@ void main() {
|
||||
function renderParsedMdCta() {
|
||||
const box = document.createElement('div');
|
||||
box.className = 'parsed-md-cta';
|
||||
box.innerHTML = `<strong>Basic view</strong>This panel reads the tokens in your <code>DESIGN.md</code> frontmatter. Running <code>/impeccable document</code> also generates a <code>DESIGN.json</code> sidecar with your project's actual component snippets (button, input, nav) and tonal ramps, rendered live below the tokens.`;
|
||||
box.innerHTML = `<strong>Basic view</strong>This panel reads the tokens in your <code>DESIGN.md</code> frontmatter. Running <code>/impeccable document</code> also generates a <code>.impeccable/design.json</code> sidecar with your project's actual component snippets (button, input, nav) and tonal ramps, rendered live below the tokens.`;
|
||||
return box;
|
||||
}
|
||||
|
||||
@@ -4598,7 +4677,7 @@ void main() {
|
||||
|
||||
function cssSafe(v) {
|
||||
// Strip anything outside valid CSS value chars to prevent injection via
|
||||
// DESIGN.json values rendered into inline style strings.
|
||||
// .impeccable/design.json values rendered into inline style strings.
|
||||
return String(v).replace(/[<>"'`\n]/g, '');
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Canonical durable completion acknowledgement for Impeccable live sessions.
|
||||
*/
|
||||
|
||||
import { createLiveSessionStore } from './live-session-store.mjs';
|
||||
import { readLiveServerInfo } from './impeccable-paths.mjs';
|
||||
|
||||
function parseArgs(argv) {
|
||||
const out = { status: 'complete' };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
if (arg === '--id') out.id = argv[++i];
|
||||
else if (arg.startsWith('--id=')) out.id = arg.slice('--id='.length);
|
||||
else if (arg === '--discarded' || arg === '--discard') out.status = 'discarded';
|
||||
else if (arg === '--error') { out.status = 'agent_error'; out.message = argv[++i] || 'unknown error'; }
|
||||
else if (arg.startsWith('--error=')) { out.status = 'agent_error'; out.message = arg.slice('--error='.length); }
|
||||
else if (arg === '--help' || arg === '-h') out.help = true;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function completeCli() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (args.help || !args.id) {
|
||||
console.log(`Usage: node live-complete.mjs --id SESSION_ID [--discarded|--error MESSAGE]\n\nAppend the final durable session acknowledgement. Use after accept/discard cleanup is verified.`);
|
||||
process.exit(args.help ? 0 : 1);
|
||||
}
|
||||
|
||||
const serverInfo = readServerInfo();
|
||||
const serverResult = serverInfo ? await completeThroughServer(serverInfo, args) : null;
|
||||
if (serverResult?.ok) {
|
||||
const store = createLiveSessionStore({ cwd: process.cwd(), sessionId: args.id });
|
||||
const snapshot = store.getSnapshot(args.id, { includeCompleted: true });
|
||||
console.log(JSON.stringify({ ok: true, id: args.id, phase: snapshot?.phase || args.status, snapshot }, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
const store = createLiveSessionStore({ cwd: process.cwd(), sessionId: args.id });
|
||||
const event = args.status === 'discarded'
|
||||
? { type: 'discarded', id: args.id }
|
||||
: args.status === 'agent_error'
|
||||
? { type: 'agent_error', id: args.id, message: args.message || 'unknown error' }
|
||||
: { type: 'complete', id: args.id };
|
||||
const snapshot = store.appendEvent(event);
|
||||
console.log(JSON.stringify({ ok: true, id: args.id, phase: snapshot.phase, snapshot }, null, 2));
|
||||
}
|
||||
|
||||
function readServerInfo() {
|
||||
return readLiveServerInfo(process.cwd())?.info || null;
|
||||
}
|
||||
|
||||
async function completeThroughServer(info, args) {
|
||||
const type = args.status === 'discarded'
|
||||
? 'discarded'
|
||||
: args.status === 'agent_error'
|
||||
? 'error'
|
||||
: 'complete';
|
||||
try {
|
||||
const res = await fetch(`http://localhost:${info.port}/poll`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token: info.token, id: args.id, type, message: args.message }),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
return await res.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const _running = process.argv[1];
|
||||
if (_running?.endsWith('live-complete.mjs') || _running?.endsWith('live-complete.mjs/')) {
|
||||
completeCli();
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
export function completionTypeForAcceptResult(eventType, acceptResult) {
|
||||
if (eventType === 'discard') return acceptResult?.handled === true ? 'discarded' : 'error';
|
||||
if (acceptResult?.handled === true && acceptResult?.carbonize === true) return 'agent_done';
|
||||
if (acceptResult?.handled === true) return 'complete';
|
||||
if (acceptResult?.mode === 'error') return 'error';
|
||||
return 'agent_done';
|
||||
}
|
||||
|
||||
export function completionAckForAcceptResult(eventId, completionType, acceptResult) {
|
||||
const ack = { ok: true, type: completionType };
|
||||
if (acceptResult?.handled === true && acceptResult?.carbonize === true) {
|
||||
ack.final = false;
|
||||
ack.requiresComplete = true;
|
||||
ack.nextCommand = `live-complete.mjs --id ${eventId}`;
|
||||
ack.message = 'Carbonize cleanup must be verified, then the session must be completed explicitly before polling again.';
|
||||
}
|
||||
return ack;
|
||||
}
|
||||
@@ -2,23 +2,24 @@
|
||||
* CLI helper: insert/remove the live variant mode script tag in the project's
|
||||
* main HTML entry point.
|
||||
*
|
||||
* On first live run, the agent generates `config.json` in this script's
|
||||
* directory with the project's insertion target (framework-specific). On
|
||||
* On first live run, the agent generates `.impeccable/live/config.json`
|
||||
* with the project's insertion target (framework-specific). On
|
||||
* every subsequent run, this script handles insert/remove deterministically
|
||||
* with zero LLM involvement.
|
||||
*
|
||||
* Usage:
|
||||
* node live-inject.mjs --port PORT # Insert the live script tag
|
||||
* node live-inject.mjs --remove # Remove the live script tag
|
||||
* node live-inject.mjs --check # Check whether config.json exists
|
||||
* node live-inject.mjs --check # Check whether live config exists
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { resolveLiveConfigPath } from './impeccable-paths.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const CONFIG_PATH = process.env.IMPECCABLE_LIVE_CONFIG || path.join(__dirname, 'config.json');
|
||||
const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname });
|
||||
const MARKER_OPEN_TEXT = 'impeccable-live-start';
|
||||
const MARKER_CLOSE_TEXT = 'impeccable-live-end';
|
||||
|
||||
@@ -39,12 +40,12 @@ export async function injectCli() {
|
||||
console.log(`Usage: node live-inject.mjs [options]
|
||||
|
||||
Insert or remove the live mode script tag in the project's HTML entry point.
|
||||
Reads configuration from config.json (in this same directory).
|
||||
Reads configuration from .impeccable/live/config.json.
|
||||
|
||||
Modes:
|
||||
--port PORT Insert script tag pointing at http://localhost:PORT/live.js
|
||||
--remove Remove the script tag (if present)
|
||||
--check Print whether config.json exists and its content
|
||||
--check Print whether .impeccable/live/config.json exists and its content
|
||||
|
||||
Output (JSON):
|
||||
{ ok, file, inserted|removed, config? }`);
|
||||
|
||||
@@ -9,10 +9,10 @@
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { completionAckForAcceptResult, completionTypeForAcceptResult } from './live-completion.mjs';
|
||||
import { readLiveServerInfo } from './impeccable-paths.mjs';
|
||||
|
||||
// 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
|
||||
@@ -20,15 +20,29 @@ import { fileURLToPath } from 'node:url';
|
||||
// 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 {
|
||||
const record = readLiveServerInfo(process.cwd());
|
||||
if (!record) {
|
||||
console.error('No running live server found. Start one with: npx impeccable live');
|
||||
process.exit(1);
|
||||
}
|
||||
return record.info;
|
||||
}
|
||||
|
||||
export function buildPollReplyPayload(token, { id, type, message, file, data }) {
|
||||
return { token, id, type, message, file, data };
|
||||
}
|
||||
|
||||
async function postReply(base, token, reply) {
|
||||
const res = await fetch(`${base}/poll`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(buildPollReplyPayload(token, reply)),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error || res.statusText);
|
||||
}
|
||||
}
|
||||
|
||||
export async function pollCli() {
|
||||
@@ -69,23 +83,7 @@ Options:
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
await postReply(base, info.token, { id, type: status, message, file: filePath });
|
||||
|
||||
// Success — silent exit (agent doesn't need output for replies)
|
||||
} catch (err) {
|
||||
@@ -156,7 +154,23 @@ Options:
|
||||
);
|
||||
event._acceptResult = JSON.parse(out.trim());
|
||||
} catch (err) {
|
||||
event._acceptResult = { handled: false, error: err.message };
|
||||
event._acceptResult = { handled: false, mode: 'error', error: err.message };
|
||||
}
|
||||
|
||||
const completionType = completionTypeForAcceptResult(event.type, event._acceptResult);
|
||||
try {
|
||||
await postReply(base, info.token, {
|
||||
id: event.id,
|
||||
type: completionType,
|
||||
message: event._acceptResult?.error,
|
||||
file: event._acceptResult?.file,
|
||||
data: event._acceptResult?.carbonize === true ? { carbonize: true } : undefined,
|
||||
});
|
||||
} catch (err) {
|
||||
event._completionAck = { ok: false, error: err.message };
|
||||
}
|
||||
if (!event._completionAck) {
|
||||
event._completionAck = completionAckForAcceptResult(event.id, completionType, event._acceptResult);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,7 +178,7 @@ Options:
|
||||
// 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');
|
||||
process.stderr.write('\n⚠ Carbonize cleanup REQUIRED before next poll. After cleanup, run live-complete.mjs --id ' + event.id + '. See reference/live.md "Required after accept".\n\n');
|
||||
}
|
||||
|
||||
// Print the event as JSON — the agent reads this from stdout
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Recover the next agent action from the durable live-session journal.
|
||||
*/
|
||||
|
||||
import { createLiveSessionStore } from './live-session-store.mjs';
|
||||
|
||||
function parseArgs(argv) {
|
||||
const out = { id: null };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
if (arg === '--id') out.id = argv[++i];
|
||||
else if (arg.startsWith('--id=')) out.id = arg.slice('--id='.length);
|
||||
else if (arg === '--help' || arg === '-h') out.help = true;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function resumeCli() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (args.help) {
|
||||
console.log(`Usage: node live-resume.mjs [--id SESSION_ID]\n\nPrint the active durable session checkpoint and the next safe agent action.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const store = createLiveSessionStore({ cwd: process.cwd(), sessionId: args.id || undefined });
|
||||
const snapshot = args.id ? store.getSnapshot(args.id) : store.listActiveSessions()[0] || null;
|
||||
if (!snapshot) {
|
||||
console.log(JSON.stringify({ active: false, nextAction: 'No active durable live session found.' }, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
const pending = snapshot.pendingEvent || null;
|
||||
const nextAction = pending
|
||||
? `Run live-poll.mjs, handle ${pending.type} ${pending.id}, then acknowledge with live-poll.mjs --reply ${pending.id} done.`
|
||||
: snapshot.phase === 'carbonize_required'
|
||||
? `Finish carbonize cleanup${snapshot.sourceFile ? ` in ${snapshot.sourceFile}` : ''}, then run live-complete.mjs --id ${snapshot.id}.`
|
||||
: snapshot.phase === 'accept_requested'
|
||||
? `Run live-complete.mjs --id ${snapshot.id} after verifying the accepted variant is written.`
|
||||
: `Inspect ${snapshot.id}; no pending agent event is currently queued.`;
|
||||
|
||||
console.log(JSON.stringify({ active: true, snapshot, pendingEvent: pending, nextAction }, null, 2));
|
||||
}
|
||||
|
||||
const _running = process.argv[1];
|
||||
if (_running?.endsWith('live-resume.mjs') || _running?.endsWith('live-resume.mjs/')) {
|
||||
resumeCli();
|
||||
}
|
||||
@@ -22,14 +22,20 @@ import net from 'node:net';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseDesignMd } from './design-parser.mjs';
|
||||
import { resolveContextDir } from './load-context.mjs';
|
||||
import { createLiveSessionStore } from './live-session-store.mjs';
|
||||
import {
|
||||
getDesignSidecarPath,
|
||||
getLiveAnnotationsDir,
|
||||
readLiveServerInfo,
|
||||
removeLiveServerInfo,
|
||||
resolveDesignSidecarPath,
|
||||
writeLiveServerInfo,
|
||||
} from './impeccable-paths.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');
|
||||
// PRODUCT.md / DESIGN.md / DESIGN.json live wherever load-context.mjs resolves.
|
||||
// Keeps live-server in sync with the loader when users keep the docs in
|
||||
// .agents/context/, docs/, or a path set via IMPECCABLE_CONTEXT_DIR.
|
||||
// PRODUCT.md / DESIGN.md live wherever load-context.mjs resolves. The generated
|
||||
// DESIGN sidecar is project-local at .impeccable/design.json, with legacy
|
||||
// DESIGN.json fallback for existing projects.
|
||||
const CONTEXT_DIR = resolveContextDir(process.cwd());
|
||||
const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway
|
||||
const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s
|
||||
@@ -57,10 +63,12 @@ const state = {
|
||||
token: null,
|
||||
port: null,
|
||||
sseClients: new Set(), // SSE response objects (server→browser push)
|
||||
pendingEvents: [], // browser events waiting for agent poll
|
||||
pendingEvents: [], // browser events waiting for agent ack ({ event, leaseUntil })
|
||||
pendingPolls: [], // agent poll callbacks waiting for browser events
|
||||
exitTimer: null,
|
||||
sessionDir: null, // per-session tmp dir for annotation screenshots
|
||||
sessionStore: null,
|
||||
leaseTimer: null,
|
||||
};
|
||||
|
||||
// Cap per-annotation upload size. A full 1920×1080 PNG is typically <1 MB;
|
||||
@@ -68,13 +76,72 @@ const state = {
|
||||
const MAX_ANNOTATION_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
function enqueueEvent(event) {
|
||||
if (state.pendingPolls.length > 0) {
|
||||
state.pendingPolls.shift()(event);
|
||||
} else {
|
||||
state.pendingEvents.push(event);
|
||||
if (!event || (event.id && state.pendingEvents.some((entry) => entry.event?.id === event.id && entry.event?.type === event.type))) return;
|
||||
state.pendingEvents.push({ event, leaseUntil: 0 });
|
||||
flushPendingPolls();
|
||||
}
|
||||
|
||||
function restorePendingEventsFromStore() {
|
||||
if (!state.sessionStore) return;
|
||||
for (const snapshot of state.sessionStore.listActiveSessions()) {
|
||||
if (snapshot.pendingEvent) enqueueEvent(snapshot.pendingEvent);
|
||||
}
|
||||
}
|
||||
|
||||
function findAvailablePendingEvent(now = Date.now()) {
|
||||
return state.pendingEvents.find((entry) => !entry.leaseUntil || entry.leaseUntil <= now);
|
||||
}
|
||||
|
||||
function leaseEvent(entry, leaseMs) {
|
||||
if (!entry.event?.id) {
|
||||
const idx = state.pendingEvents.indexOf(entry);
|
||||
if (idx !== -1) state.pendingEvents.splice(idx, 1);
|
||||
return entry.event;
|
||||
}
|
||||
entry.leaseUntil = Date.now() + leaseMs;
|
||||
return entry.event;
|
||||
}
|
||||
|
||||
function acknowledgePendingEvent(id) {
|
||||
if (!id) return false;
|
||||
const idx = state.pendingEvents.findIndex((entry) => entry.event?.id === id);
|
||||
if (idx === -1) return false;
|
||||
state.pendingEvents.splice(idx, 1);
|
||||
scheduleLeaseFlush();
|
||||
return true;
|
||||
}
|
||||
|
||||
function scheduleLeaseFlush() {
|
||||
if (state.leaseTimer) {
|
||||
clearTimeout(state.leaseTimer);
|
||||
state.leaseTimer = null;
|
||||
}
|
||||
if (state.pendingPolls.length === 0) return;
|
||||
const now = Date.now();
|
||||
const nextLeaseUntil = state.pendingEvents
|
||||
.map((entry) => entry.leaseUntil || 0)
|
||||
.filter((leaseUntil) => leaseUntil > now)
|
||||
.sort((a, b) => a - b)[0];
|
||||
if (!nextLeaseUntil) return;
|
||||
state.leaseTimer = setTimeout(() => {
|
||||
state.leaseTimer = null;
|
||||
flushPendingPolls();
|
||||
}, Math.max(0, nextLeaseUntil - now));
|
||||
}
|
||||
|
||||
function flushPendingPolls() {
|
||||
while (state.pendingPolls.length > 0) {
|
||||
const entry = findAvailablePendingEvent();
|
||||
if (!entry) {
|
||||
scheduleLeaseFlush();
|
||||
return;
|
||||
}
|
||||
const poll = state.pendingPolls.shift();
|
||||
poll.resolve(leaseEvent(entry, poll.leaseMs));
|
||||
}
|
||||
scheduleLeaseFlush();
|
||||
}
|
||||
|
||||
/** Push a message to all connected SSE clients. */
|
||||
function broadcast(msg) {
|
||||
const data = 'data: ' + JSON.stringify(msg) + '\n\n';
|
||||
@@ -103,13 +170,16 @@ function loadBrowserScripts() {
|
||||
// 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 sessionPath = path.join(__dirname, 'live-browser-session.js');
|
||||
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);
|
||||
for (const p of [sessionPath, livePath]) {
|
||||
if (!fs.existsSync(p)) {
|
||||
process.stderr.write('Error: live browser script not found at ' + p + '\n');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
return { detectScript, livePath };
|
||||
return { detectScript, sessionPath, livePath };
|
||||
}
|
||||
|
||||
function hasProjectContext() {
|
||||
@@ -170,6 +240,13 @@ function validateEvent(msg) {
|
||||
return null;
|
||||
case 'discard':
|
||||
return isValidId(msg.id) ? null : 'discard: missing or malformed id';
|
||||
case 'checkpoint':
|
||||
if (!isValidId(msg.id)) return 'checkpoint: missing or malformed id';
|
||||
if (!Number.isInteger(msg.revision) || msg.revision < 0) return 'checkpoint: revision must be a non-negative integer';
|
||||
if (msg.paramValues !== undefined && (typeof msg.paramValues !== 'object' || msg.paramValues === null || Array.isArray(msg.paramValues))) {
|
||||
return 'checkpoint: paramValues must be an object';
|
||||
}
|
||||
return null;
|
||||
case 'exit':
|
||||
return null;
|
||||
case 'prefetch':
|
||||
@@ -184,7 +261,7 @@ function validateEvent(msg) {
|
||||
// HTTP request handler
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createRequestHandler({ detectScript, livePath }) {
|
||||
function createRequestHandler({ detectScript, sessionPath, livePath }) {
|
||||
return (req, res) => {
|
||||
const url = new URL(req.url, `http://localhost:${state.port}`);
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
@@ -200,17 +277,20 @@ function createRequestHandler({ detectScript, livePath }) {
|
||||
// the next tab reload. No-store headers prevent browser caching across
|
||||
// sessions — during iteration, a cached old script silently breaks
|
||||
// every subsequent session.
|
||||
let sessionScript;
|
||||
let liveScript;
|
||||
try {
|
||||
sessionScript = fs.readFileSync(sessionPath, 'utf-8');
|
||||
liveScript = fs.readFileSync(livePath, 'utf-8');
|
||||
} catch (err) {
|
||||
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
||||
res.end('Error reading live-browser.js: ' + err.message);
|
||||
res.end('Error reading live browser scripts: ' + err.message);
|
||||
return;
|
||||
}
|
||||
const body =
|
||||
`window.__IMPECCABLE_TOKEN__ = '${state.token}';\n` +
|
||||
`window.__IMPECCABLE_PORT__ = ${state.port};\n` +
|
||||
sessionScript + '\n' +
|
||||
liveScript;
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/javascript',
|
||||
@@ -305,6 +385,26 @@ function createRequestHandler({ detectScript, livePath }) {
|
||||
}
|
||||
|
||||
// --- Health ---
|
||||
if (p === '/status') {
|
||||
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 sessions = state.sessionStore ? state.sessionStore.listActiveSessions() : [];
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
status: 'ok',
|
||||
port: state.port,
|
||||
connectedClients: state.sseClients.size,
|
||||
pendingEvents: state.pendingEvents.map((entry) => ({
|
||||
id: entry.event?.id,
|
||||
type: entry.event?.type,
|
||||
leased: !!(entry.leaseUntil && entry.leaseUntil > Date.now()),
|
||||
leaseUntil: entry.leaseUntil || null,
|
||||
})),
|
||||
activeSessions: sessions,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (p === '/health') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
@@ -316,13 +416,13 @@ function createRequestHandler({ detectScript, livePath }) {
|
||||
}
|
||||
|
||||
// --- Design system (unified v2 response) + raw ---
|
||||
// /design-system.json returns both parsed DESIGN.md and DESIGN.json
|
||||
// /design-system.json returns both parsed DESIGN.md and .impeccable/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.
|
||||
// - sidecar: .impeccable/design.json contents when present.
|
||||
// Expected shape: schemaVersion 2, carrying
|
||||
// extensions + components + narrative.
|
||||
// /design-system/raw returns DESIGN.md markdown verbatim
|
||||
@@ -331,7 +431,7 @@ function createRequestHandler({ detectScript, livePath }) {
|
||||
if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
|
||||
|
||||
const mdPath = path.join(CONTEXT_DIR, 'DESIGN.md');
|
||||
const jsonPath = path.join(CONTEXT_DIR, 'DESIGN.json');
|
||||
const jsonPath = resolveDesignSidecarPath(process.cwd(), CONTEXT_DIR) || getDesignSidecarPath(process.cwd());
|
||||
const mdStat = statOrNull(mdPath);
|
||||
const jsonStat = statOrNull(jsonPath);
|
||||
|
||||
@@ -367,7 +467,7 @@ function createRequestHandler({ detectScript, livePath }) {
|
||||
try {
|
||||
response.sidecar = JSON.parse(fs.readFileSync(jsonPath, 'utf-8'));
|
||||
} catch (err) {
|
||||
response.sidecarError = 'Failed to parse DESIGN.json: ' + err.message;
|
||||
response.sidecarError = 'Failed to parse .impeccable/design.json: ' + err.message;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -449,7 +549,16 @@ function createRequestHandler({ detectScript, livePath }) {
|
||||
res.end(JSON.stringify({ error }));
|
||||
return;
|
||||
}
|
||||
enqueueEvent(msg);
|
||||
if (state.sessionStore && msg.id) {
|
||||
try {
|
||||
state.sessionStore.appendEvent(msg);
|
||||
} catch (err) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'session_store_append_failed', message: err.message }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (msg.type !== 'checkpoint') enqueueEvent(msg);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ ok: true }));
|
||||
});
|
||||
@@ -492,13 +601,16 @@ function handlePollGet(req, res, url) {
|
||||
return;
|
||||
}
|
||||
const timeout = parseInt(url.searchParams.get('timeout') || DEFAULT_POLL_TIMEOUT, 10);
|
||||
if (state.pendingEvents.length > 0) {
|
||||
const leaseMs = parseInt(url.searchParams.get('leaseMs') || '30000', 10);
|
||||
const available = findAvailablePendingEvent();
|
||||
if (available) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(state.pendingEvents.shift()));
|
||||
res.end(JSON.stringify(leaseEvent(available, leaseMs)));
|
||||
return;
|
||||
}
|
||||
const poll = { resolve, leaseMs };
|
||||
const timer = setTimeout(() => {
|
||||
const idx = state.pendingPolls.indexOf(resolve);
|
||||
const idx = state.pendingPolls.indexOf(poll);
|
||||
if (idx !== -1) state.pendingPolls.splice(idx, 1);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ type: 'timeout' }));
|
||||
@@ -508,10 +620,11 @@ function handlePollGet(req, res, url) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(event));
|
||||
}
|
||||
state.pendingPolls.push(resolve);
|
||||
state.pendingPolls.push(poll);
|
||||
scheduleLeaseFlush();
|
||||
req.on('close', () => {
|
||||
clearTimeout(timer);
|
||||
const idx = state.pendingPolls.indexOf(resolve);
|
||||
const idx = state.pendingPolls.indexOf(poll);
|
||||
if (idx !== -1) state.pendingPolls.splice(idx, 1);
|
||||
});
|
||||
}
|
||||
@@ -531,6 +644,26 @@ function handlePollPost(req, res) {
|
||||
res.end(JSON.stringify({ error: 'Unauthorized' }));
|
||||
return;
|
||||
}
|
||||
acknowledgePendingEvent(msg.id);
|
||||
if (state.sessionStore && msg.id) {
|
||||
try {
|
||||
const eventType = msg.type === 'discard' || msg.type === 'discarded'
|
||||
? 'discarded'
|
||||
: msg.type === 'complete'
|
||||
? 'complete'
|
||||
: msg.type === 'error'
|
||||
? 'agent_error'
|
||||
: 'agent_done';
|
||||
state.sessionStore.appendEvent({
|
||||
type: eventType,
|
||||
id: msg.id,
|
||||
file: msg.file,
|
||||
message: msg.message,
|
||||
carbonize: msg.data?.carbonize === true,
|
||||
});
|
||||
} catch { /* keep reply path best-effort; browser still needs SSE */ }
|
||||
}
|
||||
flushPendingPolls();
|
||||
// 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' });
|
||||
@@ -545,13 +678,15 @@ function handlePollPost(req, res) {
|
||||
let httpServer = null;
|
||||
|
||||
function shutdown() {
|
||||
try { fs.unlinkSync(LIVE_PID_FILE); } catch {}
|
||||
removeLiveServerInfo(process.cwd());
|
||||
if (state.leaseTimer) clearTimeout(state.leaseTimer);
|
||||
state.leaseTimer = null;
|
||||
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' });
|
||||
for (const poll of state.pendingPolls) poll.resolve({ type: 'exit' });
|
||||
state.pendingPolls.length = 0;
|
||||
if (httpServer) httpServer.close();
|
||||
process.exit(0);
|
||||
@@ -587,6 +722,7 @@ Endpoints:
|
||||
/events SSE stream (server→browser) + POST (browser→server)
|
||||
/poll Long-poll for agent CLI
|
||||
/source Raw source file reader (no-HMR fallback)
|
||||
/status Durable recovery status (token-protected)
|
||||
/health Health check`);
|
||||
process.exit(0);
|
||||
}
|
||||
@@ -594,7 +730,7 @@ Endpoints:
|
||||
if (args.includes('stop')) {
|
||||
const keepInject = args.includes('--keep-inject');
|
||||
try {
|
||||
const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
|
||||
const { info } = readLiveServerInfo(process.cwd()) || {};
|
||||
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 {
|
||||
@@ -645,7 +781,7 @@ if (args.includes('--background')) {
|
||||
const deadline = Date.now() + 10_000;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
|
||||
const { info } = readLiveServerInfo(process.cwd()) || {};
|
||||
if (info.pid !== process.pid) {
|
||||
// Output JSON so the agent can read port + token from stdout.
|
||||
console.log(JSON.stringify(info));
|
||||
@@ -659,30 +795,36 @@ if (args.includes('--background')) {
|
||||
}
|
||||
|
||||
// Check for existing session
|
||||
try {
|
||||
const existing = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
|
||||
try { process.kill(existing.pid, 0);
|
||||
const existingRecord = readLiveServerInfo(process.cwd());
|
||||
if (existingRecord?.info) {
|
||||
const existing = existingRecord.info;
|
||||
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 {}
|
||||
} catch {
|
||||
try { fs.unlinkSync(existingRecord.path); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
state.token = randomUUID();
|
||||
state.sessionStore = createLiveSessionStore({ cwd: process.cwd() });
|
||||
restorePendingEventsFromStore();
|
||||
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');
|
||||
const annotRoot = getLiveAnnotationsDir(process.cwd());
|
||||
fs.mkdirSync(annotRoot, { recursive: true });
|
||||
state.sessionDir = fs.mkdtempSync(path.join(annotRoot, 'session-'));
|
||||
|
||||
const { detectScript, livePath } = loadBrowserScripts();
|
||||
httpServer = http.createServer(createRequestHandler({ detectScript, livePath }));
|
||||
const { detectScript, sessionPath, livePath } = loadBrowserScripts();
|
||||
httpServer = http.createServer(createRequestHandler({ detectScript, sessionPath, 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 }));
|
||||
writeLiveServerInfo(process.cwd(), { 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`);
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { getLegacyLiveSessionsDir, getLiveSessionsDir } from './impeccable-paths.mjs';
|
||||
|
||||
const COMPLETED_PHASES = new Set(['completed', 'discarded']);
|
||||
|
||||
export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) {
|
||||
const rootDir = getLiveSessionsDir(cwd);
|
||||
const legacyRootDir = getLegacyLiveSessionsDir(cwd);
|
||||
fs.mkdirSync(rootDir, { recursive: true });
|
||||
const snapshotCache = new Map();
|
||||
|
||||
function loadCachedOrRebuild(id) {
|
||||
const cached = snapshotCache.get(id);
|
||||
if (cached) return cached;
|
||||
const journalPath = getReadableJournalPath(id);
|
||||
const rebuilt = rebuildSnapshotFromJournal(journalPath, id);
|
||||
snapshotCache.set(id, rebuilt);
|
||||
return rebuilt;
|
||||
}
|
||||
|
||||
function getReadableJournalPath(id) {
|
||||
const primary = getJournalPath(rootDir, id);
|
||||
if (fs.existsSync(primary)) return primary;
|
||||
const legacy = getJournalPath(legacyRootDir, id);
|
||||
if (fs.existsSync(legacy)) return legacy;
|
||||
return primary;
|
||||
}
|
||||
|
||||
return {
|
||||
rootDir,
|
||||
legacyRootDir,
|
||||
appendEvent(event) {
|
||||
const normalized = normalizeEvent(event, sessionId);
|
||||
const journalPath = getJournalPath(rootDir, normalized.id);
|
||||
const snapshotPath = getSnapshotPath(rootDir, normalized.id);
|
||||
const legacyJournalPath = getJournalPath(legacyRootDir, normalized.id);
|
||||
if (!fs.existsSync(journalPath) && fs.existsSync(legacyJournalPath)) {
|
||||
fs.copyFileSync(legacyJournalPath, journalPath);
|
||||
}
|
||||
const prior = loadCachedOrRebuild(normalized.id);
|
||||
const seq = prior.nextSeq;
|
||||
const entry = {
|
||||
seq,
|
||||
id: normalized.id,
|
||||
type: normalized.type,
|
||||
ts: new Date().toISOString(),
|
||||
event: normalized,
|
||||
};
|
||||
fs.appendFileSync(journalPath, JSON.stringify(entry) + '\n');
|
||||
const next = applyEvent(prior.snapshot, entry, prior.diagnostics);
|
||||
snapshotCache.set(normalized.id, { snapshot: next, diagnostics: next.diagnostics || [], nextSeq: seq + 1 });
|
||||
writeSnapshot(snapshotPath, next);
|
||||
return next;
|
||||
},
|
||||
getSnapshot(id = sessionId, opts = {}) {
|
||||
if (!id) throw new Error('session id required');
|
||||
const journalPath = getReadableJournalPath(id);
|
||||
const snapshotPath = getSnapshotPath(rootDir, id);
|
||||
const rebuilt = rebuildSnapshotFromJournal(journalPath, id);
|
||||
snapshotCache.set(id, rebuilt);
|
||||
writeSnapshot(snapshotPath, rebuilt.snapshot);
|
||||
if (!opts.includeCompleted && COMPLETED_PHASES.has(rebuilt.snapshot.phase)) return null;
|
||||
return rebuilt.snapshot;
|
||||
},
|
||||
listActiveSessions() {
|
||||
const ids = new Set();
|
||||
for (const dir of [legacyRootDir, rootDir]) {
|
||||
if (!fs.existsSync(dir)) continue;
|
||||
for (const name of fs.readdirSync(dir)) {
|
||||
if (name.endsWith('.jsonl')) ids.add(name.slice(0, -'.jsonl'.length));
|
||||
}
|
||||
}
|
||||
return [...ids]
|
||||
.sort()
|
||||
.map((id) => this.getSnapshot(id))
|
||||
.filter(Boolean);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeEvent(event, fallbackId) {
|
||||
if (!event || typeof event !== 'object') throw new Error('event object required');
|
||||
const id = event.id || fallbackId;
|
||||
if (!id || typeof id !== 'string') throw new Error('event id required');
|
||||
if (!event.type || typeof event.type !== 'string') throw new Error('event type required');
|
||||
return { ...event, id };
|
||||
}
|
||||
|
||||
function getJournalPath(rootDir, id) {
|
||||
return path.join(rootDir, safeSessionId(id) + '.jsonl');
|
||||
}
|
||||
|
||||
function getSnapshotPath(rootDir, id) {
|
||||
return path.join(rootDir, safeSessionId(id) + '.snapshot.json');
|
||||
}
|
||||
|
||||
function safeSessionId(id) {
|
||||
if (!/^[A-Za-z0-9_-]{1,128}$/.test(id)) throw new Error('invalid session id: ' + id);
|
||||
return id;
|
||||
}
|
||||
|
||||
function baseSnapshot(id) {
|
||||
return {
|
||||
id,
|
||||
phase: 'new',
|
||||
pageUrl: null,
|
||||
sourceFile: null,
|
||||
expectedVariants: 0,
|
||||
arrivedVariants: 0,
|
||||
visibleVariant: null,
|
||||
paramValues: {},
|
||||
pendingEventSeq: null,
|
||||
pendingEvent: null,
|
||||
deliveryLease: null,
|
||||
checkpointRevision: 0,
|
||||
activeOwner: null,
|
||||
sourceMarkers: {},
|
||||
fallbackMode: null,
|
||||
annotationArtifacts: [],
|
||||
diagnostics: [],
|
||||
updatedAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
function rebuildSnapshotFromJournal(journalPath, id) {
|
||||
let snapshot = baseSnapshot(id);
|
||||
const diagnostics = [];
|
||||
let nextSeq = 1;
|
||||
if (!fs.existsSync(journalPath)) return { snapshot, diagnostics, nextSeq };
|
||||
|
||||
const lines = fs.readFileSync(journalPath, 'utf-8').split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const entry = JSON.parse(line);
|
||||
if (!entry || typeof entry !== 'object') throw new Error('entry is not object');
|
||||
if (Number.isInteger(entry.seq)) nextSeq = Math.max(nextSeq, entry.seq + 1);
|
||||
snapshot = applyEvent(snapshot, entry);
|
||||
} catch (err) {
|
||||
diagnostics.push({
|
||||
error: 'journal_parse_failed',
|
||||
line: i + 1,
|
||||
message: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
snapshot.diagnostics = [...snapshot.diagnostics, ...diagnostics];
|
||||
return { snapshot, diagnostics, nextSeq };
|
||||
}
|
||||
|
||||
function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
|
||||
const event = entry.event || entry;
|
||||
const next = {
|
||||
...snapshot,
|
||||
paramValues: { ...(snapshot.paramValues || {}) },
|
||||
sourceMarkers: { ...(snapshot.sourceMarkers || {}) },
|
||||
annotationArtifacts: [...(snapshot.annotationArtifacts || [])],
|
||||
diagnostics: [...(snapshot.diagnostics || [])],
|
||||
updatedAt: entry.ts || new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (inheritedDiagnostics.length && next.diagnostics.length === 0) {
|
||||
next.diagnostics = [...inheritedDiagnostics];
|
||||
}
|
||||
|
||||
switch (event.type) {
|
||||
case 'generate':
|
||||
next.phase = 'generate_requested';
|
||||
next.pageUrl = event.pageUrl ?? next.pageUrl;
|
||||
next.expectedVariants = event.count ?? next.expectedVariants;
|
||||
next.pendingEventSeq = entry.seq ?? next.pendingEventSeq;
|
||||
next.pendingEvent = toPendingEvent(event);
|
||||
if (event.screenshotPath) upsertArtifact(next.annotationArtifacts, { type: 'screenshot', path: event.screenshotPath });
|
||||
break;
|
||||
case 'variants_ready':
|
||||
case 'agent_done':
|
||||
next.phase = event.carbonize === true ? 'carbonize_required' : 'variants_ready';
|
||||
next.sourceFile = event.file ?? next.sourceFile;
|
||||
next.arrivedVariants = event.arrivedVariants ?? (next.arrivedVariants ?? next.expectedVariants);
|
||||
next.pendingEventSeq = null;
|
||||
next.pendingEvent = null;
|
||||
if (event.carbonize === true) {
|
||||
next.diagnostics.push({
|
||||
error: 'carbonize_cleanup_required',
|
||||
file: event.file || null,
|
||||
message: 'Accepted variant still has carbonize markers that must be folded into source CSS.',
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'checkpoint':
|
||||
if ((event.revision ?? 0) >= (next.checkpointRevision ?? 0)) {
|
||||
next.phase = event.phase ?? next.phase;
|
||||
next.checkpointRevision = event.revision ?? next.checkpointRevision;
|
||||
next.activeOwner = event.owner ?? next.activeOwner;
|
||||
next.arrivedVariants = event.arrivedVariants ?? next.arrivedVariants;
|
||||
next.visibleVariant = event.visibleVariant ?? next.visibleVariant;
|
||||
if (event.paramValues) next.paramValues = { ...event.paramValues };
|
||||
} else {
|
||||
next.diagnostics.push({ error: 'stale_checkpoint_ignored', revision: event.revision });
|
||||
}
|
||||
break;
|
||||
case 'accept':
|
||||
case 'accept_intent':
|
||||
next.phase = 'accept_requested';
|
||||
next.visibleVariant = Number(event.variantId ?? next.visibleVariant);
|
||||
if (event.paramValues) next.paramValues = { ...event.paramValues };
|
||||
next.pendingEventSeq = entry.seq ?? next.pendingEventSeq;
|
||||
next.pendingEvent = toPendingEvent(event);
|
||||
break;
|
||||
case 'discard':
|
||||
next.phase = 'discard_requested';
|
||||
next.pendingEventSeq = entry.seq ?? next.pendingEventSeq;
|
||||
next.pendingEvent = toPendingEvent(event);
|
||||
break;
|
||||
case 'discarded':
|
||||
next.phase = 'discarded';
|
||||
next.pendingEventSeq = null;
|
||||
next.pendingEvent = null;
|
||||
break;
|
||||
case 'complete':
|
||||
next.phase = 'completed';
|
||||
next.pendingEventSeq = null;
|
||||
next.pendingEvent = null;
|
||||
break;
|
||||
case 'agent_error':
|
||||
next.phase = 'agent_error';
|
||||
next.pendingEventSeq = null;
|
||||
next.pendingEvent = null;
|
||||
next.diagnostics.push({ error: 'agent_error', message: event.message || 'unknown agent error' });
|
||||
break;
|
||||
default:
|
||||
next.diagnostics.push({ error: 'unknown_event_type', type: event.type });
|
||||
break;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function toPendingEvent(event) {
|
||||
const pending = { ...event };
|
||||
delete pending.token;
|
||||
return pending;
|
||||
}
|
||||
|
||||
function upsertArtifact(artifacts, artifact) {
|
||||
if (!artifacts.some((existing) => existing.path === artifact.path && existing.type === artifact.type)) {
|
||||
artifacts.push(artifact);
|
||||
}
|
||||
}
|
||||
|
||||
function writeSnapshot(snapshotPath, snapshot) {
|
||||
fs.writeFileSync(snapshotPath, JSON.stringify(snapshot, null, 2) + '\n');
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user