quiver-cli 0.8.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (200) hide show
  1. package/README.md +110 -45
  2. package/bin/quiver-cli.mjs +3 -1
  3. package/dist/cli.js +546 -458
  4. package/package.json +2 -2
  5. package/template/.agents/AGENTS.md +4 -3
  6. package/template/.agents/config.json +7 -0
  7. package/template/.agents/plugins/opencode/rtk.ts +34 -0
  8. package/template/.agents/skills/agent-browser/SKILL.md +1 -0
  9. package/template/.agents/skills/apps/skybridge/SKILL.md +4 -0
  10. package/template/.agents/skills/design/impeccable/SKILL.md +36 -118
  11. package/template/.agents/skills/design/impeccable/reference/adapt.md +1 -0
  12. package/template/.agents/skills/design/impeccable/reference/adapt.native.md +58 -0
  13. package/template/.agents/skills/design/impeccable/reference/android.md +40 -0
  14. package/template/.agents/skills/design/impeccable/reference/animate.md +73 -188
  15. package/template/.agents/skills/design/impeccable/reference/audit.md +12 -9
  16. package/template/.agents/skills/design/impeccable/reference/audit.native.md +139 -0
  17. package/template/.agents/skills/design/impeccable/reference/bolder.md +19 -101
  18. package/template/.agents/skills/design/impeccable/reference/clarify.md +59 -253
  19. package/template/.agents/skills/design/impeccable/reference/colorize.md +51 -222
  20. package/template/.agents/skills/design/impeccable/reference/craft-floor.md +42 -0
  21. package/template/.agents/skills/design/impeccable/reference/craft.md +3 -121
  22. package/template/.agents/skills/design/impeccable/reference/critique.md +44 -23
  23. package/template/.agents/skills/design/impeccable/reference/degraded/asset-producer.md +90 -0
  24. package/template/.agents/skills/design/impeccable/reference/degraded/documenter.md +24 -0
  25. package/template/.agents/skills/design/impeccable/reference/degraded/finish-reviewer.md +37 -0
  26. package/template/.agents/skills/design/impeccable/reference/degraded/manual-edit-applier.md +92 -0
  27. package/template/.agents/skills/design/impeccable/reference/delight.md +47 -279
  28. package/template/.agents/skills/design/impeccable/reference/distill.md +2 -2
  29. package/template/.agents/skills/design/impeccable/reference/doctor.md +53 -0
  30. package/template/.agents/skills/design/impeccable/reference/document.md +60 -73
  31. package/template/.agents/skills/design/impeccable/reference/harden.md +1 -12
  32. package/template/.agents/skills/design/impeccable/reference/hooks.md +20 -5
  33. package/template/.agents/skills/design/impeccable/reference/init.md +72 -119
  34. package/template/.agents/skills/design/impeccable/reference/ios.md +45 -0
  35. package/template/.agents/skills/design/impeccable/reference/layout.md +54 -131
  36. package/template/.agents/skills/design/impeccable/reference/live-setup.md +102 -0
  37. package/template/.agents/skills/design/impeccable/reference/live.md +116 -511
  38. package/template/.agents/skills/design/impeccable/reference/new-work.md +105 -0
  39. package/template/.agents/skills/design/impeccable/reference/{product.md → operate.md} +6 -5
  40. package/template/.agents/skills/design/impeccable/reference/optimize.md +4 -4
  41. package/template/.agents/skills/design/impeccable/reference/overdrive.md +1 -4
  42. package/template/.agents/skills/design/impeccable/reference/polish.md +68 -212
  43. package/template/.agents/skills/design/impeccable/reference/quieter.md +3 -3
  44. package/template/.agents/skills/design/impeccable/reference/routing.md +18 -0
  45. package/template/.agents/skills/design/impeccable/reference/shape.md +38 -144
  46. package/template/.agents/skills/design/impeccable/reference/typeset.md +51 -250
  47. package/template/.agents/skills/design/impeccable/reference/visualize.md +47 -0
  48. package/template/.agents/skills/design/impeccable/scripts/command-metadata.json +1 -1
  49. package/template/.agents/skills/design/impeccable/scripts/concept-seed.mjs +558 -0
  50. package/template/.agents/skills/design/impeccable/scripts/context-signals.mjs +119 -10
  51. package/template/.agents/skills/design/impeccable/scripts/context.mjs +534 -45
  52. package/template/.agents/skills/design/impeccable/scripts/critique-storage.mjs +18 -47
  53. package/template/.agents/skills/design/impeccable/scripts/detector/browser/injected/index.mjs +96 -10
  54. package/template/.agents/skills/design/impeccable/scripts/detector/cli/main.mjs +174 -26
  55. package/template/.agents/skills/design/impeccable/scripts/detector/design-system.mjs +233 -0
  56. package/template/.agents/skills/design/impeccable/scripts/detector/detect-antipatterns-browser.js +3348 -203
  57. package/template/.agents/skills/design/impeccable/scripts/detector/engines/browser/detect-url.mjs +102 -7
  58. package/template/.agents/skills/design/impeccable/scripts/detector/engines/regex/detect-text.mjs +297 -97
  59. package/template/.agents/skills/design/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +187 -16
  60. package/template/.agents/skills/design/impeccable/scripts/detector/engines/static-html/detect-html.mjs +41 -11
  61. package/template/.agents/skills/design/impeccable/scripts/detector/findings.mjs +7 -1
  62. package/template/.agents/skills/design/impeccable/scripts/detector/node/file-system.mjs +16 -2
  63. package/template/.agents/skills/design/impeccable/scripts/detector/registry/antipatterns.mjs +207 -38
  64. package/template/.agents/skills/design/impeccable/scripts/detector/rules/checks.mjs +3082 -173
  65. package/template/.agents/skills/design/impeccable/scripts/detector/shared/constants.mjs +11 -0
  66. package/template/.agents/skills/design/impeccable/scripts/detector/shared/fonts.mjs +30 -0
  67. package/template/.agents/skills/design/impeccable/scripts/doctor.mjs +336 -0
  68. package/template/.agents/skills/design/impeccable/scripts/embed-prompt.mjs +133 -0
  69. package/template/.agents/skills/design/impeccable/scripts/generate-image.mjs +240 -0
  70. package/template/.agents/skills/design/impeccable/scripts/hook-admin.mjs +98 -18
  71. package/template/.agents/skills/design/impeccable/scripts/hook-before-edit.mjs +46 -6
  72. package/template/.agents/skills/design/impeccable/scripts/hook-lib.mjs +518 -50
  73. package/template/.agents/skills/design/impeccable/scripts/hook.mjs +25 -8
  74. package/template/.agents/skills/design/impeccable/scripts/lib/artifact-schema.mjs +93 -0
  75. package/template/.agents/skills/design/impeccable/scripts/lib/composition-catalog.mjs +200 -0
  76. package/template/.agents/skills/design/impeccable/scripts/lib/concept-catalog.mjs +357 -0
  77. package/template/.agents/skills/design/impeccable/scripts/lib/impeccable-config.mjs +27 -7
  78. package/template/.agents/skills/design/impeccable/scripts/lib/impeccable-paths.mjs +17 -8
  79. package/template/.agents/skills/design/impeccable/scripts/lib/provider.mjs +5 -0
  80. package/template/.agents/skills/design/impeccable/scripts/lib/roll-selection.mjs +362 -0
  81. package/template/.agents/skills/design/impeccable/scripts/lib/staleness-deep.mjs +457 -0
  82. package/template/.agents/skills/design/impeccable/scripts/lib/staleness-notice.mjs +169 -0
  83. package/template/.agents/skills/design/impeccable/scripts/lib/staleness.mjs +457 -0
  84. package/template/.agents/skills/design/impeccable/scripts/lib/surface-briefs.mjs +151 -0
  85. package/template/.agents/skills/design/impeccable/scripts/lib/target-slug.mjs +33 -0
  86. package/template/.agents/skills/design/impeccable/scripts/lib/template-extensions.mjs +146 -0
  87. package/template/.agents/skills/design/impeccable/scripts/live/accept-css.mjs +617 -0
  88. package/template/.agents/skills/design/impeccable/scripts/live/accept-verify.mjs +60 -0
  89. package/template/.agents/skills/design/impeccable/scripts/live/browser-script-parts.mjs +7 -1
  90. package/template/.agents/skills/design/impeccable/scripts/live/completion.mjs +10 -1
  91. package/template/.agents/skills/design/impeccable/scripts/live/event-validation.mjs +67 -5
  92. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/astro.mjs +47 -0
  93. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/detect-utils.mjs +73 -0
  94. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/index.mjs +143 -0
  95. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/journal.mjs +197 -0
  96. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/nextjs.mjs +49 -0
  97. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/nuxt.mjs +161 -0
  98. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/script-src.mjs +17 -0
  99. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/static-html.mjs +26 -0
  100. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/sveltekit.mjs +71 -0
  101. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/tag-strategy.mjs +247 -0
  102. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/tanstack-start.mjs +70 -0
  103. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/vite-generic.mjs +42 -0
  104. package/template/.agents/skills/design/impeccable/scripts/live/generation-preflight.mjs +149 -0
  105. package/template/.agents/skills/design/impeccable/scripts/live/instructions.mjs +142 -0
  106. package/template/.agents/skills/design/impeccable/scripts/live/poll-lanes.mjs +14 -0
  107. package/template/.agents/skills/design/impeccable/scripts/live/roots.mjs +508 -0
  108. package/template/.agents/skills/design/impeccable/scripts/live/session-store.mjs +324 -50
  109. package/template/.agents/skills/design/impeccable/scripts/live/source-lock.mjs +105 -0
  110. package/template/.agents/skills/design/impeccable/scripts/live/source-search.mjs +105 -0
  111. package/template/.agents/skills/design/impeccable/scripts/live/svelte-ast.mjs +961 -0
  112. package/template/.agents/skills/design/impeccable/scripts/live/svelte-component.mjs +588 -72
  113. package/template/.agents/skills/design/impeccable/scripts/live/sveltekit-adapter.mjs +59 -17
  114. package/template/.agents/skills/design/impeccable/scripts/live/tanstack-adapter.mjs +280 -0
  115. package/template/.agents/skills/design/impeccable/scripts/live/vocabulary.mjs +135 -0
  116. package/template/.agents/skills/design/impeccable/scripts/live-accept.mjs +210 -68
  117. package/template/.agents/skills/design/impeccable/scripts/live-browser.js +1562 -223
  118. package/template/.agents/skills/design/impeccable/scripts/live-commit-manual-edits.mjs +3 -0
  119. package/template/.agents/skills/design/impeccable/scripts/live-complete.mjs +33 -1
  120. package/template/.agents/skills/design/impeccable/scripts/live-inject.mjs +175 -255
  121. package/template/.agents/skills/design/impeccable/scripts/live-insert.mjs +26 -6
  122. package/template/.agents/skills/design/impeccable/scripts/live-manual-edit-evidence.mjs +6 -1
  123. package/template/.agents/skills/design/impeccable/scripts/live-poll.mjs +61 -16
  124. package/template/.agents/skills/design/impeccable/scripts/live-resume.mjs +39 -10
  125. package/template/.agents/skills/design/impeccable/scripts/live-server.mjs +573 -47
  126. package/template/.agents/skills/design/impeccable/scripts/live-status.mjs +17 -7
  127. package/template/.agents/skills/design/impeccable/scripts/live-wrap.mjs +124 -91
  128. package/template/.agents/skills/design/impeccable/scripts/live.mjs +88 -26
  129. package/template/.agents/skills/design/impeccable/scripts/palette.mjs +76 -81
  130. package/template/.agents/skills/design/impeccable/scripts/pin.mjs +18 -11
  131. package/template/.agents/skills/design/impeccable/scripts/serve-question.mjs +932 -0
  132. package/template/.agents/skills/design/impeccable/scripts/surface-brief.mjs +74 -0
  133. package/template/.agents/skills/design/shadcn/SKILL.md +46 -11
  134. package/template/.agents/skills/design/shadcn/cli.md +49 -16
  135. package/template/.agents/skills/design/shadcn/customization.md +14 -7
  136. package/template/.agents/skills/design/shadcn/evals/evals.json +30 -0
  137. package/template/.agents/skills/design/shadcn/mcp.md +27 -16
  138. package/template/.agents/skills/design/shadcn/registry.md +277 -0
  139. package/template/.agents/skills/design/shadcn/rules/chat.md +224 -0
  140. package/template/.agents/skills/design/shadcn/rules/composition.md +20 -2
  141. package/template/.agents/skills/design/shadcn/rules/styling.md +23 -0
  142. package/template/.agents/skills/find-skills/SKILL.md +2 -3
  143. package/template/.agents/skills/hono/SKILL.md +579 -0
  144. package/template/.agents/skills/integrations/langfuse/SKILL.md +10 -6
  145. package/template/.agents/skills/integrations/langfuse/references/ci-cd.md +41 -0
  146. package/template/.agents/skills/integrations/langfuse/references/cli.md +8 -0
  147. package/template/.agents/skills/integrations/langfuse/references/error-analysis.md +15 -27
  148. package/template/.agents/skills/integrations/langfuse/references/instrumentation.md +39 -52
  149. package/template/.agents/skills/integrations/langfuse/references/judge-calibration.md +5 -2
  150. package/template/.agents/skills/integrations/langfuse/references/prompt-engineering.md +35 -0
  151. package/template/.agents/skills/integrations/langfuse/references/prompt-migration.md +41 -196
  152. package/template/.agents/skills/integrations/langfuse/references/skill-feedback.md +3 -0
  153. package/template/.agents/skills/integrations/langfuse/references/trace-evaluator-upgrade.md +76 -0
  154. package/template/.agents/skills/integrations/langfuse/references/user-feedback.md +4 -0
  155. package/template/.agents/skills/integrations/langfuse/references/v4-project-migration.md +73 -0
  156. package/template/.agents/skills/supabase/CHANGELOG.md +71 -0
  157. package/template/.agents/skills/supabase/SKILL.md +145 -0
  158. package/template/.agents/skills/supabase/assets/feedback-issue-template.md +17 -0
  159. package/template/.agents/skills/supabase/references/skill-feedback.md +17 -0
  160. package/template/.agents/skills/supabase-postgres-best-practices/CHANGELOG.md +73 -0
  161. package/template/.agents/skills/supabase-postgres-best-practices/SKILL.md +64 -0
  162. package/template/.agents/skills/supabase-postgres-best-practices/references/_contributing.md +170 -0
  163. package/template/.agents/skills/supabase-postgres-best-practices/references/_sections.md +39 -0
  164. package/template/.agents/skills/supabase-postgres-best-practices/references/_template.md +34 -0
  165. package/template/.agents/skills/supabase-postgres-best-practices/references/advanced-full-text-search.md +55 -0
  166. package/template/.agents/skills/supabase-postgres-best-practices/references/advanced-jsonb-indexing.md +49 -0
  167. package/template/.agents/skills/supabase-postgres-best-practices/references/conn-idle-timeout.md +46 -0
  168. package/template/.agents/skills/supabase-postgres-best-practices/references/conn-limits.md +44 -0
  169. package/template/.agents/skills/supabase-postgres-best-practices/references/conn-pooling.md +41 -0
  170. package/template/.agents/skills/supabase-postgres-best-practices/references/conn-prepared-statements.md +46 -0
  171. package/template/.agents/skills/supabase-postgres-best-practices/references/data-batch-inserts.md +54 -0
  172. package/template/.agents/skills/supabase-postgres-best-practices/references/data-n-plus-one.md +53 -0
  173. package/template/.agents/skills/supabase-postgres-best-practices/references/data-pagination.md +50 -0
  174. package/template/.agents/skills/supabase-postgres-best-practices/references/data-upsert.md +50 -0
  175. package/template/.agents/skills/supabase-postgres-best-practices/references/lock-advisory.md +56 -0
  176. package/template/.agents/skills/supabase-postgres-best-practices/references/lock-deadlock-prevention.md +68 -0
  177. package/template/.agents/skills/supabase-postgres-best-practices/references/lock-short-transactions.md +50 -0
  178. package/template/.agents/skills/supabase-postgres-best-practices/references/lock-skip-locked.md +54 -0
  179. package/template/.agents/skills/supabase-postgres-best-practices/references/monitor-explain-analyze.md +45 -0
  180. package/template/.agents/skills/supabase-postgres-best-practices/references/monitor-pg-stat-statements.md +55 -0
  181. package/template/.agents/skills/supabase-postgres-best-practices/references/monitor-vacuum-analyze.md +55 -0
  182. package/template/.agents/skills/supabase-postgres-best-practices/references/query-composite-indexes.md +44 -0
  183. package/template/.agents/skills/supabase-postgres-best-practices/references/query-covering-indexes.md +40 -0
  184. package/template/.agents/skills/supabase-postgres-best-practices/references/query-index-types.md +48 -0
  185. package/template/.agents/skills/supabase-postgres-best-practices/references/query-missing-indexes.md +43 -0
  186. package/template/.agents/skills/supabase-postgres-best-practices/references/query-partial-indexes.md +45 -0
  187. package/template/.agents/skills/supabase-postgres-best-practices/references/schema-constraints.md +80 -0
  188. package/template/.agents/skills/supabase-postgres-best-practices/references/schema-data-types.md +46 -0
  189. package/template/.agents/skills/supabase-postgres-best-practices/references/schema-foreign-key-indexes.md +59 -0
  190. package/template/.agents/skills/supabase-postgres-best-practices/references/schema-lowercase-identifiers.md +55 -0
  191. package/template/.agents/skills/supabase-postgres-best-practices/references/schema-partitioning.md +55 -0
  192. package/template/.agents/skills/supabase-postgres-best-practices/references/schema-primary-keys.md +61 -0
  193. package/template/.agents/skills/supabase-postgres-best-practices/references/security-privileges.md +54 -0
  194. package/template/.agents/skills/supabase-postgres-best-practices/references/security-rls-basics.md +50 -0
  195. package/template/.agents/skills/supabase-postgres-best-practices/references/security-rls-performance.md +63 -0
  196. package/template/.agents/upstreams.json +34 -12
  197. package/template/.agents/skills/design/impeccable/reference/brand.md +0 -108
  198. package/template/.agents/skills/design/impeccable/reference/codex.md +0 -105
  199. package/template/.agents/skills/design/impeccable/reference/interaction-design.md +0 -189
  200. package/template/.agents/skills/integrations/langfuse/references/sdk-upgrade.md +0 -175
@@ -28,14 +28,20 @@ import {
28
28
  readLiveBrowserScriptParts,
29
29
  resolveLiveBrowserScriptParts,
30
30
  } from './live/browser-script-parts.mjs';
31
- import { createLiveSessionStore } from './live/session-store.mjs';
31
+ import { createLiveSessionStore, GENERATION_FENCED_PHASES } from './live/session-store.mjs';
32
+ import { runGenerationPreflight } from './live/generation-preflight.mjs';
32
33
  import { validateEvent } from './live/event-validation.mjs';
34
+ import { selectAvailablePendingEvent } from './live/poll-lanes.mjs';
33
35
  import { createManualEditRoutes } from './live/manual-edit-routes.mjs';
34
- import { LIVE_COMMANDS } from './live/vocabulary.mjs';
36
+ import {
37
+ LIVE_COMMANDS,
38
+ VARIANT_PROGRESS_CHECKPOINT_REASONS as VARIANT_PROGRESS_CHECKPOINT_REASON_LIST,
39
+ } from './live/vocabulary.mjs';
35
40
  import {
36
41
  getDesignSidecarPath,
37
42
  getLiveDir,
38
43
  getLiveAnnotationsDir,
44
+ IMPECCABLE_COMMAND_PREFIX,
39
45
  readLiveServerInfo,
40
46
  removeLiveServerInfo,
41
47
  resolveDesignSidecarPath,
@@ -48,21 +54,54 @@ import {
48
54
  } from './live/manual-apply.mjs';
49
55
  import {
50
56
  applyDeferredSvelteComponentAccepts,
57
+ bumpSvelteComponentPreviewRevision,
58
+ compileCheckVariants,
51
59
  removeAllSvelteComponentSessions,
60
+ sweepInactiveSvelteComponentSessions,
52
61
  } from './live/svelte-component.mjs';
62
+ import { enterLiveRoot } from './live/roots.mjs';
53
63
 
54
64
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
55
- // PRODUCT.md / DESIGN.md live wherever context.mjs resolves. The generated
56
- // DESIGN sidecar is project-local at .impeccable/design.json, with legacy
57
- // DESIGN.json fallback for existing projects.
58
- const PROJECT_CONTEXT = loadContext(process.cwd());
59
- const CONTEXT_DIR = PROJECT_CONTEXT.contextDir;
60
- const DESIGN_MD_PATH = PROJECT_CONTEXT.designPath
61
- ? path.resolve(process.cwd(), PROJECT_CONTEXT.designPath)
62
- : null;
65
+ // Anchor the whole process on the live roots manifest before anything derives
66
+ // a path from cwd. A server started from the wrong directory re-roots itself
67
+ // onto the appRoot the boot decided on instead of minting a second project.
68
+ const LIVE_ROOTS = enterLiveRoot(process.cwd());
69
+
70
+ // PRODUCT.md / DESIGN.md context, resolved lazily and per request so a server
71
+ // that outlives an `impeccable document` run (or a context file created after
72
+ // boot) reports current truth instead of a boot-time snapshot. The roots
73
+ // manifest wins when the ambient resolution misses (nested app inheriting
74
+ // repo-level context files).
75
+ function resolveProjectContext() {
76
+ const ctx = loadContext(process.cwd());
77
+ const designPath = ctx.designPath
78
+ ? path.resolve(process.cwd(), ctx.designPath)
79
+ : (LIVE_ROOTS?.designPath && fs.existsSync(LIVE_ROOTS.designPath) ? LIVE_ROOTS.designPath : null);
80
+ const hasProduct = ctx.hasProduct
81
+ || !!(LIVE_ROOTS?.productPath && fs.existsSync(LIVE_ROOTS.productPath));
82
+ return {
83
+ ...ctx,
84
+ hasProduct,
85
+ hasDesign: !!designPath,
86
+ resolvedDesignPath: designPath,
87
+ contextDir: ctx.contextDir || LIVE_ROOTS?.contextRoot || process.cwd(),
88
+ designContextDir: ctx.designContextDir
89
+ || (designPath ? path.dirname(designPath) : null),
90
+ };
91
+ }
63
92
  const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway
64
93
  const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s
65
94
 
95
+ // The browser events allowed to mint a NEW session journal. `generate` starts
96
+ // a variant session at Go; `steer` mints its own request id. Every other
97
+ // id-carrying event must land on an existing session (see the unknown_session
98
+ // gate in the /events handler).
99
+ const SESSION_CREATING_EVENT_TYPES = new Set(['generate', 'steer']);
100
+ // The browser checkpoints for several unrelated reasons (see checkpointPayload
101
+ // in live-browser.js). Only these two report that variant availability changed,
102
+ // and only they may drive variant_progress / the *_reviewable phases.
103
+ const VARIANT_PROGRESS_CHECKPOINT_REASONS = new Set(VARIANT_PROGRESS_CHECKPOINT_REASON_LIST);
104
+
66
105
  // ---------------------------------------------------------------------------
67
106
  // Port detection
68
107
  // ---------------------------------------------------------------------------
@@ -143,7 +182,16 @@ function chatAgentLikelyActive() {
143
182
  const MAX_ANNOTATION_BYTES = 10 * 1024 * 1024;
144
183
 
145
184
  function enqueueEvent(event) {
146
- if (!event || (event.id && state.pendingEvents.some((entry) => entry.event?.id === event.id && entry.event?.type === event.type))) return;
185
+ if (!event) return;
186
+ // Dedupe by (session, type), except mount failures, which are per-variant:
187
+ // variant 2 failing must not be swallowed because variant 1's failure is
188
+ // still queued.
189
+ const duplicate = event.id && state.pendingEvents.some((entry) => (
190
+ entry.event?.id === event.id
191
+ && entry.event?.type === event.type
192
+ && (event.type !== 'variant_mount_failed' || entry.event?.variant === event.variant)
193
+ ));
194
+ if (duplicate) return;
147
195
  state.pendingEvents.push({ event, leaseUntil: 0, seq: state.nextEventSeq++ });
148
196
  flushPendingPolls();
149
197
  }
@@ -155,29 +203,207 @@ function restorePendingEventsFromStore() {
155
203
  }
156
204
  }
157
205
 
158
- function findAvailablePendingEvent(now = Date.now()) {
159
- for (const entry of state.pendingEvents) {
160
- if (entry.leaseUntil && entry.leaseUntil > now) continue;
161
- return entry;
162
- }
163
- return null;
206
+ function findAvailablePendingEvent(now = Date.now(), types = null) {
207
+ return selectAvailablePendingEvent(state.pendingEvents, { now, types });
164
208
  }
165
209
 
166
- function leaseEvent(entry, leaseMs) {
210
+ async function leaseEvent(entry, leaseMs) {
211
+ // Claim the entry before awaiting anything. prepareGenerateEventForLease
212
+ // yields to the event loop, and selectAvailablePendingEvent only skips
213
+ // entries whose lease is in the future — an unclaimed entry would be handed
214
+ // to a second poll in that window and generated twice.
215
+ entry.leaseUntil = Date.now() + leaseMs;
216
+ await prepareGenerateEventForLease(entry);
167
217
  if (!entry.event?.id) {
168
218
  const idx = state.pendingEvents.indexOf(entry);
169
219
  if (idx !== -1) state.pendingEvents.splice(idx, 1);
170
220
  return entry.event;
171
221
  }
222
+ // Re-stamp so the lease window starts when the agent actually receives the
223
+ // work, not when scaffolding began.
172
224
  entry.leaseUntil = Date.now() + leaseMs;
225
+ recordGenerateDelivery(entry);
173
226
  scheduleLeaseFlush();
174
227
  broadcastAgentPollingIfChanged();
175
228
  return entry.event;
176
229
  }
177
230
 
178
- function acknowledgePendingEvent(id) {
231
+ function recordGenerateDelivery(entry) {
232
+ const event = entry?.event;
233
+ if (!event || event.type !== 'generate' || event.generationReadyAt) return;
234
+ const at = Date.now();
235
+ entry.event = { ...event, generationReadyAt: at };
236
+ state.sessionStore?.appendEvent(entry.event);
237
+ recordAgentPhase(event.id, 'generation_ready', { at });
238
+ }
239
+
240
+ async function prepareGenerateEventForLease(entry) {
241
+ const event = entry?.event;
242
+ if (!event || event.type !== 'generate' || event.scaffoldAttempted) return;
243
+
244
+ recordAgentPhase(event.id, 'picked_up');
245
+ recordAgentPhase(event.id, 'scaffolding');
246
+ const result = await runGenerationPreflight(event, {
247
+ cwd: process.cwd(),
248
+ scriptsDir: __dirname,
249
+ });
250
+ entry.event = {
251
+ ...event,
252
+ scaffoldAttempted: true,
253
+ scaffoldDurationMs: result.durationMs ?? null,
254
+ ...(result.ok ? { scaffold: result.scaffold } : { scaffoldError: result.error || result.reason }),
255
+ };
256
+ state.sessionStore?.appendEvent(entry.event);
257
+ recordAgentPhase(event.id, result.ok ? 'source_ready' : 'scaffold_fallback', {
258
+ durationMs: result.durationMs ?? null,
259
+ previewMode: result.scaffold?.previewMode || 'source',
260
+ });
261
+ }
262
+
263
+ function recordAgentPhase(id, phase, details = {}) {
264
+ if (!id) return;
265
+ const event = {
266
+ type: 'agent_phase',
267
+ id,
268
+ phase,
269
+ at: Date.now(),
270
+ ...details,
271
+ };
272
+ state.sessionStore?.appendEvent(event);
273
+ broadcast(event);
274
+ }
275
+
276
+ /**
277
+ * Detect a browser that missed the generation `done` broadcast.
278
+ *
279
+ * The preflight no longer writes the scaffold into source for source-preview
280
+ * targets (the agent writes wrapper + variants in one atomic edit), so the old
281
+ * scaffold-write full-reload that opened the "stranded at 0/N" race is gone.
282
+ * This recovery stays as defense in depth: any framework reload that drops the
283
+ * agent's variant write + `done` while the browser is mid-reload leaves the new
284
+ * page in GENERATING at 0/N. That resumed page always checkpoints
285
+ * (`browser_resumed`), so a checkpoint claiming "still generating, variants
286
+ * missing" for a session whose generation already completed is direct
287
+ * evidence of the miss. Rebuild the `done` payload from the snapshot so the
288
+ * caller can re-broadcast it; the browser's done handler is idempotent and
289
+ * falls back to injecting variants from source.
290
+ *
291
+ * Keys on the store's monotone `generationCompletedAt`, not `phase` — the
292
+ * behind checkpoint itself regresses `phase` to `generating`, and a browser
293
+ * that misses the redelivered `done` too (another reload) must still trigger
294
+ * redelivery from its next checkpoint.
295
+ */
296
+ function detectMissedGenerationCompletion(event) {
297
+ if (!event?.id || event.type !== 'checkpoint') return null;
298
+ if (event.phase !== 'generating') return null;
299
+ if (!variantCountLooksBehind(event.arrivedVariants, event.expectedVariants)) return null;
300
+ if (!state.sessionStore) return null;
301
+ let snapshot = null;
302
+ try {
303
+ snapshot = state.sessionStore.getSnapshot(event.id);
304
+ } catch {
305
+ return null;
306
+ }
307
+ return missedCompletionFromSnapshot(snapshot);
308
+ }
309
+
310
+ function variantCountLooksBehind(arrivedValue, expectedValue) {
311
+ const arrived = Number(arrivedValue) || 0;
312
+ const expected = Number(expectedValue) || 0;
313
+ return arrived <= 0 || (expected > 0 && arrived < expected);
314
+ }
315
+
316
+ function missedCompletionFromSnapshot(snapshot) {
317
+ if (!snapshot?.id || !snapshot.generationCompletedAt) return null;
318
+ if (snapshot.generationCanceled) return null;
319
+ // Accept/discard already underway: the browser is no longer waiting on
320
+ // generation, and a late `done` there would collide with teardown.
321
+ if (GENERATION_FENCED_PHASES.has(snapshot.phase)) return null;
322
+ const file = snapshot.sourceFile || snapshot.previewFile;
323
+ if (!file) return null;
324
+ return {
325
+ type: 'done',
326
+ id: snapshot.id,
327
+ file,
328
+ sourceFile: snapshot.sourceFile || undefined,
329
+ previewFile: snapshot.previewFile || undefined,
330
+ previewMode: snapshot.previewMode || undefined,
331
+ redelivered: true,
332
+ };
333
+ }
334
+
335
+ function recordGenerationCheckpoint(event) {
336
+ if (!event?.id || event.type !== 'checkpoint') return;
337
+ if (generationIsFenced(event.id)) return;
338
+ // Only checkpoints that report a change in variant availability are
339
+ // generation progress. The browser also checkpoints for durability on Tune
340
+ // slider drags, resumes, and anchor recovery; treating those as progress
341
+ // echoed `variant_progress` straight back to the browser that sent it, which
342
+ // remounts the component preview mid-drag (reverting the user's live param
343
+ // edit and detaching the popover's element), and permanently latched the
344
+ // *_reviewable phases from the wrong trigger, corrupting generation timings.
345
+ if (!VARIANT_PROGRESS_CHECKPOINT_REASONS.has(event.reason)) return;
346
+ const arrived = Number(event.arrivedVariants) || 0;
347
+ const expected = Number(event.expectedVariants) || 0;
348
+ if (arrived <= 0 || expected <= 0) return;
349
+ const previewMode = event.previewMode || 'source';
350
+ const previewFile = event.previewFile || event.file;
351
+ if (previewFile) {
352
+ broadcast({
353
+ type: 'variant_progress',
354
+ id: event.id,
355
+ file: previewFile,
356
+ sourceFile: event.sourceFile || (previewMode === 'source' ? previewFile : undefined),
357
+ previewFile,
358
+ previewMode,
359
+ arrivedVariants: arrived,
360
+ expectedVariants: expected,
361
+ publicationKind: event.publicationKind || 'variants',
362
+ });
363
+ }
364
+ const details = {
365
+ arrivedVariants: arrived,
366
+ expectedVariants: expected,
367
+ checkpointReason: event.reason || null,
368
+ };
369
+ const at = Date.now();
370
+ if (!generationPhaseAlreadyRecorded(event.id, 'first_reviewable')) {
371
+ recordAgentPhase(event.id, 'first_reviewable', { ...details, at });
372
+ }
373
+ if (arrived >= 2 && expected >= 3 && !generationPhaseAlreadyRecorded(event.id, 'second_reviewable')) {
374
+ recordAgentPhase(event.id, 'second_reviewable', { ...details, at });
375
+ }
376
+ if (arrived >= expected && !generationPhaseAlreadyRecorded(event.id, 'all_variants_ready')) {
377
+ recordAgentPhase(event.id, 'all_variants_ready', { ...details, at });
378
+ }
379
+ }
380
+
381
+ function generationIsFenced(id) {
382
+ if (!state.sessionStore || !id) return false;
383
+ try {
384
+ const snapshot = state.sessionStore.getSnapshot(id, { includeCompleted: true });
385
+ return snapshot?.generationCanceled === true;
386
+ } catch {
387
+ return false;
388
+ }
389
+ }
390
+
391
+ function generationPhaseAlreadyRecorded(id, phase) {
392
+ if (!state.sessionStore) return false;
393
+ try {
394
+ const snapshot = state.sessionStore.getSnapshot(id, { includeCompleted: true });
395
+ return !!snapshot?.generationTimings?.[phase];
396
+ } catch {
397
+ return false;
398
+ }
399
+ }
400
+
401
+ function acknowledgePendingEvent(id, sourceEventType) {
179
402
  if (!id) return false;
180
- const idx = state.pendingEvents.findIndex((entry) => entry.event?.id === id);
403
+ const idx = state.pendingEvents.findIndex((entry) => (
404
+ entry.event?.id === id
405
+ && (!sourceEventType || entry.event?.type === sourceEventType)
406
+ ));
181
407
  if (idx === -1) return false;
182
408
  const acknowledged = state.pendingEvents[idx].event;
183
409
  state.pendingEvents.splice(idx, 1);
@@ -186,9 +412,39 @@ function acknowledgePendingEvent(id) {
186
412
  return acknowledged;
187
413
  }
188
414
 
189
- function findPendingEventById(id) {
415
+ function releasePendingEvent(id, sourceEventType) {
416
+ const entry = state.pendingEvents.find((item) => (
417
+ item.event?.id === id
418
+ && (!sourceEventType || item.event?.type === sourceEventType)
419
+ ));
420
+ if (!entry) return null;
421
+ entry.leaseUntil = 0;
422
+ scheduleLeaseFlush();
423
+ return entry.event;
424
+ }
425
+
426
+ function retirePendingGeneration(id) {
427
+ if (!id) return 0;
428
+ let retired = 0;
429
+ for (let index = state.pendingEvents.length - 1; index >= 0; index -= 1) {
430
+ const event = state.pendingEvents[index]?.event;
431
+ if (event?.id !== id || event.type !== 'generate') continue;
432
+ state.pendingEvents.splice(index, 1);
433
+ retired += 1;
434
+ }
435
+ if (retired > 0) {
436
+ scheduleLeaseFlush();
437
+ broadcastAgentPollingIfChanged();
438
+ }
439
+ return retired;
440
+ }
441
+
442
+ function findPendingEventById(id, sourceEventType) {
190
443
  if (!id) return null;
191
- const entry = state.pendingEvents.find((item) => item.event?.id === id);
444
+ const entry = state.pendingEvents.find((item) => (
445
+ item.event?.id === id
446
+ && (!sourceEventType || item.event?.type === sourceEventType)
447
+ ));
192
448
  return entry?.event || null;
193
449
  }
194
450
 
@@ -197,7 +453,7 @@ function summarizePendingEventForStatus(entry) {
197
453
  const summary = {
198
454
  id: event.id,
199
455
  type: event.type,
200
- leased: !!(entry.leaseUntil && entry.leaseUntil > Date.now()),
456
+ leased: isLeased(entry),
201
457
  leaseUntil: entry.leaseUntil || null,
202
458
  };
203
459
  if (event.type === 'manual_edit_apply') {
@@ -223,7 +479,18 @@ function summarizeActiveSessionForClient(snapshot = {}) {
223
479
  arrivedVariants: snapshot.arrivedVariants ?? 0,
224
480
  visibleVariant: snapshot.visibleVariant ?? null,
225
481
  checkpointRevision: snapshot.checkpointRevision ?? 0,
482
+ browserCheckpointRevision: snapshot.browserCheckpointRevision ?? snapshot.checkpointRevision ?? 0,
483
+ publicationCheckpointRevision: snapshot.publicationCheckpointRevision ?? 0,
226
484
  paramValues: snapshot.paramValues || {},
485
+ generationPhase: snapshot.generationPhase ?? null,
486
+ generationCompletedAt: snapshot.generationCompletedAt ?? null,
487
+ generationCanceled: snapshot.generationCanceled === true,
488
+ cancelReason: snapshot.cancelReason ?? null,
489
+ // Render truth, so a browser with no localStorage can rehydrate to the
490
+ // same comparison the server already knows about.
491
+ mountedVariants: Array.isArray(snapshot.mountedVariants) ? snapshot.mountedVariants : [],
492
+ mountFailures: Array.isArray(snapshot.mountFailures) ? snapshot.mountFailures : [],
493
+ renderState: snapshot.renderState ?? null,
227
494
  };
228
495
  }
229
496
 
@@ -268,24 +535,46 @@ function scheduleLeaseFlush() {
268
535
  function flushPendingPolls() {
269
536
  let changed = false;
270
537
  while (state.pendingPolls.length > 0) {
271
- const entry = findAvailablePendingEvent();
538
+ let pollIndex = -1;
539
+ let entry = null;
540
+ for (let index = 0; index < state.pendingPolls.length; index += 1) {
541
+ const candidate = findAvailablePendingEvent(Date.now(), state.pendingPolls[index].types);
542
+ if (!candidate) continue;
543
+ pollIndex = index;
544
+ entry = candidate;
545
+ break;
546
+ }
272
547
  if (!entry) {
273
548
  scheduleLeaseFlush();
274
549
  broadcastAgentPollingIfChanged();
275
550
  return;
276
551
  }
277
- const poll = state.pendingPolls.shift();
278
- poll.resolve(leaseEvent(entry, poll.leaseMs));
552
+ const [poll] = state.pendingPolls.splice(pollIndex, 1);
553
+ // leaseEvent is async (it may scaffold source), but it claims the entry
554
+ // synchronously, so the next loop iteration will not re-select it. Resolve
555
+ // the poll when the lease settles rather than awaiting here, so one slow
556
+ // scaffold never delays the other parked polls. On the exceptional failure
557
+ // path, answer `timeout` so the agent re-polls; the claim stays until the
558
+ // lease expires, which keeps a deterministic failure from hot-looping.
559
+ leaseEvent(entry, poll.leaseMs).then(poll.resolve, (error) => {
560
+ console.error('[live] lease failed for ' + (entry.event?.id || 'unknown') + ': ' + (error?.message || error));
561
+ poll.resolve({ type: 'timeout' });
562
+ });
279
563
  changed = true;
280
564
  }
281
565
  scheduleLeaseFlush();
282
566
  if (changed) broadcastAgentPollingIfChanged();
283
567
  }
284
568
 
569
+ function isLeased(entry) {
570
+ return !!(entry?.leaseUntil && entry.leaseUntil > Date.now());
571
+ }
572
+
285
573
  function agentPollingConnected() {
286
- const now = Date.now();
287
- return state.pendingPolls.length > 0
288
- || state.pendingEvents.some((entry) => entry.leaseUntil && entry.leaseUntil > now);
574
+ // A leased event only proves that a poll returned once. The foreground task
575
+ // may have ended immediately afterward, so only an actively waiting poll is
576
+ // evidence that steering can wake the task right now.
577
+ return state.pendingPolls.length > 0;
289
578
  }
290
579
 
291
580
  function broadcastAgentPollingIfChanged() {
@@ -375,20 +664,44 @@ function hasProjectContext() {
375
664
  // PRODUCT.md carries brand voice / anti-references — that's what determines
376
665
  // whether variants are brand-aware. DESIGN.md (visual tokens) is a separate
377
666
  // concern, surfaced by the design panel's own empty state.
378
- return !!PROJECT_CONTEXT.hasProduct;
667
+ return !!resolveProjectContext().hasProduct;
379
668
  }
380
669
 
381
670
  function statOrNull(filePath) {
382
671
  try { return fs.statSync(filePath); } catch { return null; }
383
672
  }
384
673
 
674
+ // Strict loopback-origin test for CORS. Parses the Origin as a URL (never a
675
+ // substring match, so `http://localhost.evil.com` and `http://127.0.0.1.evil.com`
676
+ // fail) and accepts only http/https on localhost, 127.0.0.1, or the IPv6 loopback.
677
+ function isLoopbackOrigin(origin) {
678
+ if (typeof origin !== 'string' || origin.length === 0) return false;
679
+ let parsed;
680
+ try { parsed = new URL(origin); } catch { return false; }
681
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false;
682
+ const host = parsed.hostname.toLowerCase();
683
+ return host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]';
684
+ }
685
+
385
686
  // HTTP request handler
386
687
  // ---------------------------------------------------------------------------
387
688
 
388
689
  function createRequestHandler({ detectScript, liveScriptParts }) {
389
690
  return (req, res) => {
390
691
  const url = new URL(req.url, `http://localhost:${state.port}`);
391
- res.setHeader('Access-Control-Allow-Origin', '*');
692
+ // Loopback-restricted CORS. Reflect the caller's Origin only when it is a
693
+ // loopback origin, always paired with `Vary: Origin` so an intermediary
694
+ // cache never serves a response authorized for one origin to another. A
695
+ // remote page (e.g. https://evil.example probing the port from a tab open
696
+ // on the same machine) gets no Access-Control-Allow-Origin, so its
697
+ // JS-initiated fetch cannot read any response. Requests with no Origin
698
+ // header (script tags, curl, the agent's own fetches) are not subject to
699
+ // CORS and keep working; no ACAO header is needed for them.
700
+ const origin = req.headers.origin;
701
+ if (origin && isLoopbackOrigin(origin)) {
702
+ res.setHeader('Access-Control-Allow-Origin', origin);
703
+ res.setHeader('Vary', 'Origin');
704
+ }
392
705
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
393
706
  res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
394
707
  if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
@@ -397,6 +710,15 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
397
710
 
398
711
  // --- Scripts ---
399
712
  if (p === '/live.js') {
713
+ // Token-gated: the script body embeds state.token, which unlocks every
714
+ // token-guarded route. Serving it unauthenticated let any local page read
715
+ // the token and drive the session. The injected <script src> carries
716
+ // `?token=...` (see live-inject.mjs). A missing/wrong token → 401.
717
+ if (url.searchParams.get('token') !== state.token) {
718
+ res.writeHead(401, { 'Content-Type': 'text/plain' });
719
+ res.end('Unauthorized');
720
+ return;
721
+ }
400
722
  // Re-read from disk each request so edits to live-browser.js land on
401
723
  // the next tab reload. No-store headers prevent browser caching across
402
724
  // sessions — during iteration, a cached old script silently breaks
@@ -413,6 +735,8 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
413
735
  token: state.token,
414
736
  port: state.port,
415
737
  vocabulary: LIVE_COMMANDS,
738
+ commandPrefix: IMPECCABLE_COMMAND_PREFIX,
739
+ appRoot: process.cwd(),
416
740
  parts,
417
741
  });
418
742
  res.writeHead(200, {
@@ -550,8 +874,9 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
550
874
  const token = url.searchParams.get('token');
551
875
  if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
552
876
 
553
- const mdPath = DESIGN_MD_PATH;
554
- const jsonPath = resolveDesignSidecarPath(process.cwd(), PROJECT_CONTEXT.designContextDir || CONTEXT_DIR) || getDesignSidecarPath(process.cwd());
877
+ const projectContext = resolveProjectContext();
878
+ const mdPath = projectContext.resolvedDesignPath;
879
+ const jsonPath = resolveDesignSidecarPath(process.cwd(), projectContext.designContextDir || projectContext.contextDir) || getDesignSidecarPath(process.cwd());
555
880
  const mdStat = statOrNull(mdPath);
556
881
  const jsonStat = statOrNull(jsonPath);
557
882
 
@@ -603,7 +928,13 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
603
928
  const filePath = url.searchParams.get('path');
604
929
  if (!filePath || filePath.includes('..')) { res.writeHead(400); res.end('Bad path'); return; }
605
930
  const absPath = path.resolve(process.cwd(), filePath);
606
- if (!absPath.startsWith(process.cwd())) { res.writeHead(403); res.end('Forbidden'); return; }
931
+ // Confine to the project root. A bare `startsWith(cwd)` string check lets a
932
+ // sibling dir whose name extends the root name (projeto -> projeto-backup)
933
+ // slip through; compare on the relative path instead (same pattern as
934
+ // sessionFileMetadataFromPollReply below). An empty rel means the request
935
+ // resolved to the root directory itself, which this file route never serves.
936
+ const rel = path.relative(process.cwd(), absPath);
937
+ if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) { res.writeHead(403); res.end('Forbidden'); return; }
607
938
  let content;
608
939
  try { content = fs.readFileSync(absPath, 'utf-8'); }
609
940
  catch { res.writeHead(404); res.end('File not found'); return; }
@@ -687,6 +1018,30 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
687
1018
  res.end(JSON.stringify({ error }));
688
1019
  return;
689
1020
  }
1021
+ if (msg.type === 'agent_phase') {
1022
+ recordAgentPhase(msg.id, msg.phase, {
1023
+ ...(Number.isFinite(msg.durationMs) ? { durationMs: msg.durationMs } : {}),
1024
+ owner: typeof msg.owner === 'string' ? msg.owner : undefined,
1025
+ });
1026
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1027
+ res.end(JSON.stringify({ ok: true }));
1028
+ return;
1029
+ }
1030
+ // Only the events that START a session may create its journal.
1031
+ // Everything else (checkpoints, mount acks, accept/discard) must
1032
+ // reference a session THIS store already knows: appendEvent creates a
1033
+ // journal for any id it is handed, so without this gate a browser
1034
+ // resuming another project's session from per-origin storage (two
1035
+ // apps sharing a localhost port) materializes a ghost session here
1036
+ // that keeps reattaching after every discard.
1037
+ if (msg.id && state.sessionStore
1038
+ && !SESSION_CREATING_EVENT_TYPES.has(msg.type)
1039
+ && !state.sessionStore.has(msg.id)) {
1040
+ res.writeHead(404, { 'Content-Type': 'application/json' });
1041
+ res.end(JSON.stringify({ error: 'unknown_session', id: msg.id }));
1042
+ return;
1043
+ }
1044
+ const missedCompletion = detectMissedGenerationCompletion(msg);
690
1045
  if (state.sessionStore && msg.id) {
691
1046
  try {
692
1047
  state.sessionStore.appendEvent(msg);
@@ -696,10 +1051,33 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
696
1051
  return;
697
1052
  }
698
1053
  }
1054
+ if (msg.type === 'accept' || msg.type === 'discard') {
1055
+ retirePendingGeneration(msg.id);
1056
+ }
1057
+ recordGenerationCheckpoint(msg);
1058
+ if (missedCompletion) broadcast(missedCompletion);
699
1059
  if (msg.type === 'exit') {
700
1060
  cleanupSvelteComponentSessionsBeforeExit();
701
1061
  }
702
- if (msg.type !== 'checkpoint') {
1062
+ // An ORPHANED discard is the browser reporting that the session's
1063
+ // wrapper no longer exists in source (edited or regenerated away).
1064
+ // There is no cleanup for an agent to perform, and asking one to run
1065
+ // the normal discard flow would just fail against the missing
1066
+ // scaffolding, so the server terminalizes the session itself and the
1067
+ // event stays out of the poll queue.
1068
+ const orphanedDiscard = msg.type === 'discard' && msg.orphaned === true;
1069
+ if (orphanedDiscard && state.sessionStore && msg.id) {
1070
+ try {
1071
+ state.sessionStore.appendEvent({ type: 'discarded', id: msg.id, orphaned: true });
1072
+ } catch { /* the discard_requested phase already left the resumable set */ }
1073
+ }
1074
+ // `variant_mounted` is the happy path: it is journaled above so the
1075
+ // snapshot carries render truth, but there is nothing for the agent to
1076
+ // do about it, so it stays out of the poll queue and off the SSE bus.
1077
+ // `variant_mount_failed` is the opposite: the agent published something
1078
+ // the browser could not render, and only the agent can fix it, so it
1079
+ // goes to the queue as a first-class event.
1080
+ if (msg.type !== 'checkpoint' && msg.type !== 'variant_mounted' && !orphanedDiscard) {
703
1081
  enqueueEvent(msg);
704
1082
  }
705
1083
  res.writeHead(200, { 'Content-Type': 'application/json' });
@@ -736,6 +1114,12 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
736
1114
  // Agent poll endpoints (unchanged from WS version)
737
1115
  // ---------------------------------------------------------------------------
738
1116
 
1117
+ function parsePollTypes(value) {
1118
+ if (!value) return null;
1119
+ const types = String(value).split(',').map((type) => type.trim()).filter(Boolean);
1120
+ return types.length > 0 ? new Set(types) : null;
1121
+ }
1122
+
739
1123
  function handlePollGet(req, res, url) {
740
1124
  const token = url.searchParams.get('token');
741
1125
  if (token !== state.token) {
@@ -746,13 +1130,25 @@ function handlePollGet(req, res, url) {
746
1130
  state.lastPollAt = Date.now();
747
1131
  const timeout = parseInt(url.searchParams.get('timeout') || DEFAULT_POLL_TIMEOUT, 10);
748
1132
  const leaseMs = parseInt(url.searchParams.get('leaseMs') || '30000', 10);
749
- const available = findAvailablePendingEvent();
1133
+ const types = parsePollTypes(url.searchParams.get('types'));
1134
+ const available = findAvailablePendingEvent(Date.now(), types);
750
1135
  if (available) {
751
- res.writeHead(200, { 'Content-Type': 'application/json' });
752
- res.end(JSON.stringify(leaseEvent(available, leaseMs)));
1136
+ // Do not await inline: leaseEvent may scaffold source, and this handler runs
1137
+ // on the server's only thread. The client can disconnect during that window,
1138
+ // so check the socket before replying.
1139
+ leaseEvent(available, leaseMs).then((event) => {
1140
+ if (res.writableEnded || res.destroyed) return;
1141
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1142
+ res.end(JSON.stringify(event));
1143
+ }, (error) => {
1144
+ console.error('[live] lease failed for ' + (available.event?.id || 'unknown') + ': ' + (error?.message || error));
1145
+ if (res.writableEnded || res.destroyed) return;
1146
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1147
+ res.end(JSON.stringify({ type: 'timeout' }));
1148
+ });
753
1149
  return;
754
1150
  }
755
- const poll = { resolve, leaseMs };
1151
+ const poll = { resolve, leaseMs, types };
756
1152
  const timer = setTimeout(() => {
757
1153
  const idx = state.pendingPolls.indexOf(poll);
758
1154
  if (idx !== -1) state.pendingPolls.splice(idx, 1);
@@ -781,12 +1177,16 @@ function sessionFileMetadataFromPollReply(file) {
781
1177
  if (!file || typeof file !== 'string') return { file };
782
1178
  const normalized = file.split(path.sep).join('/');
783
1179
  const base = { file: normalized };
784
- if (!normalized.endsWith('/manifest.json') && normalized !== 'manifest.json') return base;
785
- if (!normalized.includes('node_modules/.impeccable-live/') && !normalized.includes('src/lib/impeccable/')) return base;
1180
+ const metadataFile = normalized;
1181
+ if (!metadataFile.endsWith('/manifest.json') && metadataFile !== 'manifest.json') return base;
1182
+ if (!metadataFile.includes('.impeccable/live/previews/')
1183
+ && !metadataFile.includes('node_modules/.impeccable-live/')
1184
+ && !metadataFile.includes('src/lib/impeccable/')
1185
+ && !metadataFile.includes('/.impeccable-live/')) return base;
786
1186
 
787
1187
  let full;
788
1188
  try {
789
- full = path.resolve(process.cwd(), normalized);
1189
+ full = path.resolve(process.cwd(), metadataFile);
790
1190
  const rel = path.relative(process.cwd(), full);
791
1191
  if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return base;
792
1192
  } catch {
@@ -795,18 +1195,54 @@ function sessionFileMetadataFromPollReply(file) {
795
1195
 
796
1196
  try {
797
1197
  const manifest = JSON.parse(fs.readFileSync(full, 'utf-8'));
798
- if (manifest?.previewMode !== 'svelte-component' || !manifest.sourceFile) return base;
1198
+ if (manifest?.previewMode !== 'svelte-component'
1199
+ || !manifest.sourceFile) return base;
799
1200
  return {
800
1201
  file: String(manifest.sourceFile).split(path.sep).join('/'),
801
1202
  sourceFile: String(manifest.sourceFile).split(path.sep).join('/'),
802
1203
  previewFile: normalized,
803
- previewMode: 'svelte-component',
1204
+ previewMode: manifest.previewMode,
804
1205
  };
805
1206
  } catch {
806
1207
  return base;
807
1208
  }
808
1209
  }
809
1210
 
1211
+ function inferSourceEventType(msg = {}, pendingEvents = state.pendingEvents) {
1212
+ const entriesForId = pendingEvents.filter((entry) => entry.event?.id === msg.id);
1213
+ const pendingTypes = new Set(entriesForId.map((entry) => entry.event?.type));
1214
+ if (msg.type === 'discarded' || msg.type === 'discard') return 'discard';
1215
+ if (msg.type === 'complete') {
1216
+ if (pendingTypes.has('carbonize_cleanup')) return 'carbonize_cleanup';
1217
+ return pendingTypes.has('accept') ? 'accept' : (pendingTypes.has('generate') ? 'generate' : undefined);
1218
+ }
1219
+ if (msg.type === 'steer_done') return 'steer';
1220
+ // `agent_done` can be the automatic acknowledgement for a carbonize Accept.
1221
+ // New pollers send sourceEventType explicitly; default to generate only for
1222
+ // older callers so a late worker cannot acknowledge a queued Accept.
1223
+ if (msg.type === 'agent_done' || msg.type === 'done') {
1224
+ // A `done` reply to a mount failure is the republish that unblocks the
1225
+ // browser. Without this the ack would look for a `generate` that was
1226
+ // already retired, the mount-failure event would stay queued, and the next
1227
+ // poll would hand the same failure back to the agent forever.
1228
+ if (!pendingTypes.has('generate') && pendingTypes.has('variant_mount_failed')) return 'variant_mount_failed';
1229
+ return 'generate';
1230
+ }
1231
+ // `error` is reference/live.md's documented failure reply, and parseReplyArgs
1232
+ // never sets sourceEventType on it (the poller is a fresh process that cannot
1233
+ // know what it leased). Returning undefined here makes acknowledgePendingEvent
1234
+ // match *any* event for this id: a stale generate worker's failure silently
1235
+ // consumed the user's queued Accept, which was then never delivered to any
1236
+ // agent and left the browser in SAVING forever. Attribute the failure to the
1237
+ // event this agent actually holds a lease on, and otherwise to `generate` —
1238
+ // never to a wildcard. If that generate was already retired by an Accept, the
1239
+ // ack simply finds no match, which is the correct outcome for a stale reply.
1240
+ if (msg.type === 'error') {
1241
+ return entriesForId.find(isLeased)?.event?.type || 'generate';
1242
+ }
1243
+ return undefined;
1244
+ }
1245
+
810
1246
  function handlePollPost(req, res) {
811
1247
  let body = '';
812
1248
  req.on('data', (c) => { body += c; });
@@ -867,7 +1303,23 @@ function handlePollPost(req, res) {
867
1303
  res.end(JSON.stringify({ error: 'stale_manual_edit_apply_reply', ...rollback }));
868
1304
  return;
869
1305
  }
870
- const pendingEventBeforeAck = findPendingEventById(msg.id);
1306
+ const sourceEventType = msg.sourceEventType || inferSourceEventType(msg);
1307
+ if (msg.type === 'retry') {
1308
+ const releasedEvent = releasePendingEvent(msg.id, sourceEventType);
1309
+ if (!releasedEvent) {
1310
+ res.writeHead(msg.id ? 404 : 400, { 'Content-Type': 'application/json' });
1311
+ res.end(JSON.stringify({
1312
+ error: msg.id ? 'unknown_poll_retry_id' : 'missing_poll_retry_id',
1313
+ id: msg.id,
1314
+ }));
1315
+ return;
1316
+ }
1317
+ flushPendingPolls();
1318
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1319
+ res.end(JSON.stringify({ ok: true, released: true }));
1320
+ return;
1321
+ }
1322
+ const pendingEventBeforeAck = findPendingEventById(msg.id, sourceEventType);
871
1323
  if (pendingEventBeforeAck?.type === 'steer' && msg.type === 'steer_done'
872
1324
  && !msg.file && !(typeof msg.message === 'string' && msg.message.trim())) {
873
1325
  res.writeHead(400, { 'Content-Type': 'application/json' });
@@ -877,7 +1329,7 @@ function handlePollPost(req, res) {
877
1329
  }));
878
1330
  return;
879
1331
  }
880
- const acknowledgedEvent = acknowledgePendingEvent(msg.id);
1332
+ const acknowledgedEvent = acknowledgePendingEvent(msg.id, sourceEventType);
881
1333
  let skipJournalReply = false;
882
1334
  let existingSession = null;
883
1335
  if (!acknowledgedEvent && state.sessionStore && msg.id) {
@@ -900,6 +1352,30 @@ function handlePollPost(req, res) {
900
1352
  return;
901
1353
  }
902
1354
  const replyFileMeta = sessionFileMetadataFromPollReply(msg.file);
1355
+ // A publish (done reply carrying a component manifest) snapshots the
1356
+ // variant files into a fresh revision dir before the browser is told:
1357
+ // the import path changes every publish, so no transform cache can pin a
1358
+ // stale compile of a republished module (node_modules is unwatched).
1359
+ // Broken variants are bounced HERE, before the browser imports anything:
1360
+ // a compile error that reaches the page is a red overlay in the user's
1361
+ // face; bounced at publish it is a private fix with file and line.
1362
+ if (replyFileMeta.previewMode === 'svelte-component'
1363
+ && msg.id
1364
+ && (msg.type === 'done' || !msg.type)) {
1365
+ let compileCheck = { ok: true, failures: [] };
1366
+ try { compileCheck = compileCheckVariants(msg.id, process.cwd()); } catch { /* best-effort */ }
1367
+ if (!compileCheck.ok) {
1368
+ res.writeHead(422, { 'Content-Type': 'application/json' });
1369
+ res.end(JSON.stringify({
1370
+ error: 'variant_compile_failed',
1371
+ id: msg.id,
1372
+ failures: compileCheck.failures,
1373
+ _instructions: 'The publish was NOT delivered: the listed variant file(s) do not compile, so the browser never saw them. Fix each failure at the given file and line (the most common cause is a second top-level <style> element; Svelte allows exactly one, so merge all rules into the existing block), then send the same --reply done again.',
1374
+ }));
1375
+ return;
1376
+ }
1377
+ try { bumpSvelteComponentPreviewRevision(msg.id, process.cwd()); } catch { /* best-effort */ }
1378
+ }
903
1379
  if (state.sessionStore && msg.id && !skipJournalReply) {
904
1380
  try {
905
1381
  const eventType = msg.type === 'steer_done'
@@ -971,6 +1447,51 @@ function cleanupSvelteComponentSessionsBeforeExit() {
971
1447
  }
972
1448
  }
973
1449
 
1450
+ /**
1451
+ * A previous run that died without its shutdown hook leaves preview component
1452
+ * dirs behind. Drop the ones whose session the store no longer considers
1453
+ * active; anything still active is mid-generation and must survive a restart.
1454
+ */
1455
+ function sweepOrphanSvelteComponentSessionsOnStartup() {
1456
+ try {
1457
+ const activeIds = (state.sessionStore?.listActiveSessions() || [])
1458
+ .map((snapshot) => snapshot?.id)
1459
+ .filter(Boolean);
1460
+ const result = sweepInactiveSvelteComponentSessions(activeIds, process.cwd());
1461
+ if (result.removed.length > 0 || result.removedRoot) {
1462
+ console.log('[impeccable] swept orphaned Svelte component sessions:', JSON.stringify(result));
1463
+ }
1464
+ } catch (err) {
1465
+ console.warn('[impeccable] Svelte component session sweep failed:', err.message);
1466
+ }
1467
+ }
1468
+
1469
+ // Accept receipts are a short-lived idempotency record for a single accept.
1470
+ // Nothing reads one after the session that wrote it is gone, so they only need
1471
+ // to outlive a crash-and-retry window.
1472
+ const ACCEPT_RECEIPT_MAX_AGE_MS = 14 * 24 * 60 * 60 * 1000;
1473
+
1474
+ function sweepStaleAcceptReceiptsOnStartup() {
1475
+ try {
1476
+ const dir = path.join(getLiveDir(process.cwd()), 'accept-receipts');
1477
+ if (!fs.existsSync(dir)) return;
1478
+ const cutoff = Date.now() - ACCEPT_RECEIPT_MAX_AGE_MS;
1479
+ let removed = 0;
1480
+ for (const name of fs.readdirSync(dir)) {
1481
+ if (!name.endsWith('.json') && !name.endsWith('.tmp')) continue;
1482
+ const file = path.join(dir, name);
1483
+ try {
1484
+ if (fs.statSync(file).mtimeMs >= cutoff) continue;
1485
+ fs.rmSync(file, { force: true });
1486
+ removed++;
1487
+ } catch { /* non-fatal */ }
1488
+ }
1489
+ if (removed > 0) console.log(`[impeccable] removed ${removed} accept receipt(s) older than 14 days`);
1490
+ } catch (err) {
1491
+ console.warn('[impeccable] accept receipt retention sweep failed:', err.message);
1492
+ }
1493
+ }
1494
+
974
1495
  function applyLegacyDeferredAcceptsOnStartup() {
975
1496
  try {
976
1497
  const result = applyDeferredSvelteComponentAccepts(process.cwd());
@@ -1081,7 +1602,10 @@ if (args.includes('--background')) {
1081
1602
  process.exit(0);
1082
1603
  }
1083
1604
  } catch { /* not ready yet */ }
1084
- await new Promise(r => setTimeout(r, 200));
1605
+ // The detached child is typically listening in 35-45ms. A 200ms polling
1606
+ // floor dominated configured cold Live startup; poll cheaply and return
1607
+ // as soon as the child has written its ready record.
1608
+ await new Promise(r => setTimeout(r, 5));
1085
1609
  }
1086
1610
  console.error('Timed out waiting for live server to start.');
1087
1611
  process.exit(1);
@@ -1107,6 +1631,8 @@ manualApply.rollbackTransaction({
1107
1631
  reason: 'manual_edit_server_start_recovered_abandoned_transaction',
1108
1632
  });
1109
1633
  applyLegacyDeferredAcceptsOnStartup();
1634
+ sweepOrphanSvelteComponentSessionsOnStartup();
1635
+ sweepStaleAcceptReceiptsOnStartup();
1110
1636
  restorePendingEventsFromStore();
1111
1637
  manualApply.pruneStaleEvidence();
1112
1638
  const portArg = args.find(a => a.startsWith('--port='));