@bastani/atomic 0.9.11-alpha.6 → 0.9.11-alpha.7

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 (140) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/builtin/intercom/package.json +1 -1
  3. package/dist/builtin/mcp/package.json +1 -1
  4. package/dist/builtin/subagents/package.json +1 -1
  5. package/dist/builtin/web-access/package.json +1 -1
  6. package/dist/builtin/workflows/package.json +1 -1
  7. package/dist/builtin/workflows/skills/impeccable/SKILL.md +33 -129
  8. package/dist/builtin/workflows/skills/impeccable/agents/impeccable_asset_producer.toml +11 -3
  9. package/dist/builtin/workflows/skills/impeccable/agents/impeccable_documenter.toml +26 -0
  10. package/dist/builtin/workflows/skills/impeccable/agents/impeccable_finish_reviewer.toml +35 -0
  11. package/dist/builtin/workflows/skills/impeccable/reference/android.md +1 -1
  12. package/dist/builtin/workflows/skills/impeccable/reference/animate.md +72 -189
  13. package/dist/builtin/workflows/skills/impeccable/reference/audit.md +10 -9
  14. package/dist/builtin/workflows/skills/impeccable/reference/audit.native.md +2 -2
  15. package/dist/builtin/workflows/skills/impeccable/reference/bolder.md +19 -108
  16. package/dist/builtin/workflows/skills/impeccable/reference/clarify.md +59 -253
  17. package/dist/builtin/workflows/skills/impeccable/reference/colorize.md +51 -222
  18. package/dist/builtin/workflows/skills/impeccable/reference/craft-floor.md +45 -0
  19. package/dist/builtin/workflows/skills/impeccable/reference/craft.md +3 -121
  20. package/dist/builtin/workflows/skills/impeccable/reference/critique.md +28 -20
  21. package/dist/builtin/workflows/skills/impeccable/reference/degraded/asset-producer.md +97 -0
  22. package/dist/builtin/workflows/skills/impeccable/reference/degraded/documenter.md +23 -0
  23. package/dist/builtin/workflows/skills/impeccable/reference/degraded/finish-reviewer.md +32 -0
  24. package/dist/builtin/workflows/skills/impeccable/reference/degraded/manual-edit-applier.md +92 -0
  25. package/dist/builtin/workflows/skills/impeccable/reference/delight.md +47 -279
  26. package/dist/builtin/workflows/skills/impeccable/reference/distill.md +2 -2
  27. package/dist/builtin/workflows/skills/impeccable/reference/doctor.md +53 -0
  28. package/dist/builtin/workflows/skills/impeccable/reference/document.md +60 -73
  29. package/dist/builtin/workflows/skills/impeccable/reference/harden.md +1 -12
  30. package/dist/builtin/workflows/skills/impeccable/reference/hooks.md +17 -4
  31. package/dist/builtin/workflows/skills/impeccable/reference/init.md +64 -163
  32. package/dist/builtin/workflows/skills/impeccable/reference/ios.md +1 -1
  33. package/dist/builtin/workflows/skills/impeccable/reference/layout.md +52 -153
  34. package/dist/builtin/workflows/skills/impeccable/reference/live.md +44 -32
  35. package/dist/builtin/workflows/skills/impeccable/reference/new-work.md +105 -0
  36. package/dist/builtin/workflows/skills/impeccable/reference/{product.md → operate.md} +6 -5
  37. package/dist/builtin/workflows/skills/impeccable/reference/optimize.md +4 -4
  38. package/dist/builtin/workflows/skills/impeccable/reference/overdrive.md +1 -4
  39. package/dist/builtin/workflows/skills/impeccable/reference/polish.md +68 -212
  40. package/dist/builtin/workflows/skills/impeccable/reference/quieter.md +3 -3
  41. package/dist/builtin/workflows/skills/impeccable/reference/routing.md +18 -0
  42. package/dist/builtin/workflows/skills/impeccable/reference/shape.md +38 -144
  43. package/dist/builtin/workflows/skills/impeccable/reference/typeset.md +48 -269
  44. package/dist/builtin/workflows/skills/impeccable/reference/visualize.md +38 -0
  45. package/dist/builtin/workflows/skills/impeccable/scripts/command-metadata.json +1 -1
  46. package/dist/builtin/workflows/skills/impeccable/scripts/concept-seed.mjs +584 -0
  47. package/dist/builtin/workflows/skills/impeccable/scripts/context-signals.mjs +117 -9
  48. package/dist/builtin/workflows/skills/impeccable/scripts/context.mjs +486 -59
  49. package/dist/builtin/workflows/skills/impeccable/scripts/critique-storage.mjs +16 -45
  50. package/dist/builtin/workflows/skills/impeccable/scripts/detector/browser/injected/index.mjs +96 -10
  51. package/dist/builtin/workflows/skills/impeccable/scripts/detector/cli/main.mjs +143 -26
  52. package/dist/builtin/workflows/skills/impeccable/scripts/detector/design-system.mjs +181 -12
  53. package/dist/builtin/workflows/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +3187 -182
  54. package/dist/builtin/workflows/skills/impeccable/scripts/detector/engines/browser/detect-url.mjs +102 -7
  55. package/dist/builtin/workflows/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs +282 -70
  56. package/dist/builtin/workflows/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +183 -15
  57. package/dist/builtin/workflows/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs +22 -7
  58. package/dist/builtin/workflows/skills/impeccable/scripts/detector/findings.mjs +7 -1
  59. package/dist/builtin/workflows/skills/impeccable/scripts/detector/node/file-system.mjs +16 -2
  60. package/dist/builtin/workflows/skills/impeccable/scripts/detector/registry/antipatterns.mjs +155 -42
  61. package/dist/builtin/workflows/skills/impeccable/scripts/detector/rules/checks.mjs +2988 -155
  62. package/dist/builtin/workflows/skills/impeccable/scripts/detector/shared/constants.mjs +11 -0
  63. package/dist/builtin/workflows/skills/impeccable/scripts/doctor.mjs +336 -0
  64. package/dist/builtin/workflows/skills/impeccable/scripts/generate-image.mjs +232 -0
  65. package/dist/builtin/workflows/skills/impeccable/scripts/hook-admin.mjs +90 -10
  66. package/dist/builtin/workflows/skills/impeccable/scripts/hook-lib.mjs +410 -78
  67. package/dist/builtin/workflows/skills/impeccable/scripts/hook.mjs +25 -8
  68. package/dist/builtin/workflows/skills/impeccable/scripts/lib/artifact-schema.mjs +93 -0
  69. package/dist/builtin/workflows/skills/impeccable/scripts/lib/composition-catalog.mjs +165 -0
  70. package/dist/builtin/workflows/skills/impeccable/scripts/lib/concept-catalog.mjs +329 -0
  71. package/dist/builtin/workflows/skills/impeccable/scripts/lib/impeccable-config.mjs +20 -5
  72. package/dist/builtin/workflows/skills/impeccable/scripts/lib/impeccable-paths.mjs +16 -8
  73. package/dist/builtin/workflows/skills/impeccable/scripts/lib/provider.mjs +1 -0
  74. package/dist/builtin/workflows/skills/impeccable/scripts/lib/staleness-deep.mjs +455 -0
  75. package/dist/builtin/workflows/skills/impeccable/scripts/lib/staleness-notice.mjs +169 -0
  76. package/dist/builtin/workflows/skills/impeccable/scripts/lib/staleness.mjs +457 -0
  77. package/dist/builtin/workflows/skills/impeccable/scripts/lib/surface-briefs.mjs +151 -0
  78. package/dist/builtin/workflows/skills/impeccable/scripts/lib/target-slug.mjs +33 -0
  79. package/dist/builtin/workflows/skills/impeccable/scripts/lib/template-extensions.mjs +146 -0
  80. package/dist/builtin/workflows/skills/impeccable/scripts/live/completion.mjs +10 -1
  81. package/dist/builtin/workflows/skills/impeccable/scripts/live/event-validation.mjs +15 -0
  82. package/dist/builtin/workflows/skills/impeccable/scripts/live/generation-preflight.mjs +149 -0
  83. package/dist/builtin/workflows/skills/impeccable/scripts/live/poll-lanes.mjs +14 -0
  84. package/dist/builtin/workflows/skills/impeccable/scripts/live/session-store.mjs +109 -32
  85. package/dist/builtin/workflows/skills/impeccable/scripts/live/source-lock.mjs +105 -0
  86. package/dist/builtin/workflows/skills/impeccable/scripts/live/source-search.mjs +105 -0
  87. package/dist/builtin/workflows/skills/impeccable/scripts/live/sveltekit-adapter.mjs +8 -6
  88. package/dist/builtin/workflows/skills/impeccable/scripts/live/tanstack-adapter.mjs +280 -0
  89. package/dist/builtin/workflows/skills/impeccable/scripts/live-accept.mjs +208 -68
  90. package/dist/builtin/workflows/skills/impeccable/scripts/live-browser.js +563 -112
  91. package/dist/builtin/workflows/skills/impeccable/scripts/live-commit-manual-edits.mjs +3 -0
  92. package/dist/builtin/workflows/skills/impeccable/scripts/live-inject.mjs +198 -13
  93. package/dist/builtin/workflows/skills/impeccable/scripts/live-insert.mjs +24 -6
  94. package/dist/builtin/workflows/skills/impeccable/scripts/live-manual-edit-evidence.mjs +6 -1
  95. package/dist/builtin/workflows/skills/impeccable/scripts/live-poll.mjs +44 -14
  96. package/dist/builtin/workflows/skills/impeccable/scripts/live-server.mjs +398 -33
  97. package/dist/builtin/workflows/skills/impeccable/scripts/live-status.mjs +9 -5
  98. package/dist/builtin/workflows/skills/impeccable/scripts/live-wrap.mjs +81 -67
  99. package/dist/builtin/workflows/skills/impeccable/scripts/live.mjs +7 -2
  100. package/dist/builtin/workflows/skills/impeccable/scripts/palette.mjs +76 -81
  101. package/dist/builtin/workflows/skills/impeccable/scripts/pin.mjs +2 -2
  102. package/dist/builtin/workflows/skills/impeccable/scripts/serve-question.mjs +890 -0
  103. package/dist/builtin/workflows/skills/impeccable/scripts/surface-brief.mjs +74 -0
  104. package/dist/builtin/workflows/src/extension/index.bundle.mjs +201 -22
  105. package/dist/builtin/workflows/src/runs/foreground/executor-stage-control.ts +23 -9
  106. package/dist/builtin/workflows/src/runs/foreground/executor-stage-replay.ts +1 -0
  107. package/dist/builtin/workflows/src/runs/foreground/postmortem-stage-chat.ts +20 -5
  108. package/dist/builtin/workflows/src/runs/foreground/stage-control-registry.ts +10 -0
  109. package/dist/builtin/workflows/src/runs/foreground/stage-delivery-activity.ts +86 -0
  110. package/dist/builtin/workflows/src/runs/foreground/stage-runner-context.ts +6 -2
  111. package/dist/builtin/workflows/src/runs/foreground/stage-runner-controller.ts +19 -13
  112. package/dist/builtin/workflows/src/runs/foreground/stage-runner-send-user-message.ts +8 -0
  113. package/dist/builtin/workflows/src/runs/foreground/stage-runner-types.ts +16 -0
  114. package/dist/builtin/workflows/src/tui/stage-chat-view-delivery-activity.ts +121 -0
  115. package/dist/builtin/workflows/src/tui/stage-chat-view-live-events.ts +42 -0
  116. package/dist/builtin/workflows/src/tui/stage-chat-view-state.ts +16 -0
  117. package/dist/builtin/workflows/src/tui/stage-chat-view-types.ts +4 -0
  118. package/dist/modes/interactive/components/chat-session-host-actions.d.ts.map +1 -1
  119. package/dist/modes/interactive/components/chat-session-host-actions.js +2 -14
  120. package/dist/modes/interactive/components/chat-session-host-actions.js.map +1 -1
  121. package/dist/modes/interactive/components/chat-session-host-events.d.ts.map +1 -1
  122. package/dist/modes/interactive/components/chat-session-host-events.js +9 -0
  123. package/dist/modes/interactive/components/chat-session-host-events.js.map +1 -1
  124. package/dist/modes/interactive/components/chat-session-host-runtime.d.ts +25 -0
  125. package/dist/modes/interactive/components/chat-session-host-runtime.d.ts.map +1 -1
  126. package/dist/modes/interactive/components/chat-session-host-runtime.js +53 -0
  127. package/dist/modes/interactive/components/chat-session-host-runtime.js.map +1 -1
  128. package/dist/modes/interactive/components/chat-session-host.d.ts +13 -0
  129. package/dist/modes/interactive/components/chat-session-host.d.ts.map +1 -1
  130. package/dist/modes/interactive/components/chat-session-host.js +22 -1
  131. package/dist/modes/interactive/components/chat-session-host.js.map +1 -1
  132. package/dist/modes/interactive/interactive-agent-events.js +9 -3
  133. package/dist/modes/interactive/interactive-agent-events.js.map +1 -1
  134. package/docs/tui.md +7 -2
  135. package/docs/workflows.md +1 -1
  136. package/npm-shrinkwrap.json +23 -23
  137. package/package.json +2 -2
  138. package/dist/builtin/workflows/skills/impeccable/reference/brand.md +0 -108
  139. package/dist/builtin/workflows/skills/impeccable/reference/codex.md +0 -105
  140. package/dist/builtin/workflows/skills/impeccable/reference/interaction-design.md +0 -189
@@ -28,8 +28,10 @@ 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
36
  import { LIVE_COMMANDS } from './live/vocabulary.mjs';
35
37
  import {
@@ -63,6 +65,10 @@ const DESIGN_MD_PATH = PROJECT_CONTEXT.designPath
63
65
  : null;
64
66
  const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway
65
67
  const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s
68
+ // The browser checkpoints for several unrelated reasons (see checkpointPayload
69
+ // in live-browser.js). Only these two report that variant availability changed,
70
+ // and only they may drive variant_progress / the *_reviewable phases.
71
+ const VARIANT_PROGRESS_CHECKPOINT_REASONS = new Set(['variants_progress', 'variants_ready']);
66
72
 
67
73
  // ---------------------------------------------------------------------------
68
74
  // Port detection
@@ -156,29 +162,207 @@ function restorePendingEventsFromStore() {
156
162
  }
157
163
  }
158
164
 
159
- function findAvailablePendingEvent(now = Date.now()) {
160
- for (const entry of state.pendingEvents) {
161
- if (entry.leaseUntil && entry.leaseUntil > now) continue;
162
- return entry;
163
- }
164
- return null;
165
+ function findAvailablePendingEvent(now = Date.now(), types = null) {
166
+ return selectAvailablePendingEvent(state.pendingEvents, { now, types });
165
167
  }
166
168
 
167
- function leaseEvent(entry, leaseMs) {
169
+ async function leaseEvent(entry, leaseMs) {
170
+ // Claim the entry before awaiting anything. prepareGenerateEventForLease
171
+ // yields to the event loop, and selectAvailablePendingEvent only skips
172
+ // entries whose lease is in the future — an unclaimed entry would be handed
173
+ // to a second poll in that window and generated twice.
174
+ entry.leaseUntil = Date.now() + leaseMs;
175
+ await prepareGenerateEventForLease(entry);
168
176
  if (!entry.event?.id) {
169
177
  const idx = state.pendingEvents.indexOf(entry);
170
178
  if (idx !== -1) state.pendingEvents.splice(idx, 1);
171
179
  return entry.event;
172
180
  }
181
+ // Re-stamp so the lease window starts when the agent actually receives the
182
+ // work, not when scaffolding began.
173
183
  entry.leaseUntil = Date.now() + leaseMs;
184
+ recordGenerateDelivery(entry);
174
185
  scheduleLeaseFlush();
175
186
  broadcastAgentPollingIfChanged();
176
187
  return entry.event;
177
188
  }
178
189
 
179
- function acknowledgePendingEvent(id) {
190
+ function recordGenerateDelivery(entry) {
191
+ const event = entry?.event;
192
+ if (!event || event.type !== 'generate' || event.generationReadyAt) return;
193
+ const at = Date.now();
194
+ entry.event = { ...event, generationReadyAt: at };
195
+ state.sessionStore?.appendEvent(entry.event);
196
+ recordAgentPhase(event.id, 'generation_ready', { at });
197
+ }
198
+
199
+ async function prepareGenerateEventForLease(entry) {
200
+ const event = entry?.event;
201
+ if (!event || event.type !== 'generate' || event.scaffoldAttempted) return;
202
+
203
+ recordAgentPhase(event.id, 'picked_up');
204
+ recordAgentPhase(event.id, 'scaffolding');
205
+ const result = await runGenerationPreflight(event, {
206
+ cwd: process.cwd(),
207
+ scriptsDir: __dirname,
208
+ });
209
+ entry.event = {
210
+ ...event,
211
+ scaffoldAttempted: true,
212
+ scaffoldDurationMs: result.durationMs ?? null,
213
+ ...(result.ok ? { scaffold: result.scaffold } : { scaffoldError: result.error || result.reason }),
214
+ };
215
+ state.sessionStore?.appendEvent(entry.event);
216
+ recordAgentPhase(event.id, result.ok ? 'source_ready' : 'scaffold_fallback', {
217
+ durationMs: result.durationMs ?? null,
218
+ previewMode: result.scaffold?.previewMode || 'source',
219
+ });
220
+ }
221
+
222
+ function recordAgentPhase(id, phase, details = {}) {
223
+ if (!id) return;
224
+ const event = {
225
+ type: 'agent_phase',
226
+ id,
227
+ phase,
228
+ at: Date.now(),
229
+ ...details,
230
+ };
231
+ state.sessionStore?.appendEvent(event);
232
+ broadcast(event);
233
+ }
234
+
235
+ /**
236
+ * Detect a browser that missed the generation `done` broadcast.
237
+ *
238
+ * The preflight no longer writes the scaffold into source for source-preview
239
+ * targets (the agent writes wrapper + variants in one atomic edit), so the old
240
+ * scaffold-write full-reload that opened the "stranded at 0/N" race is gone.
241
+ * This recovery stays as defense in depth: any framework reload that drops the
242
+ * agent's variant write + `done` while the browser is mid-reload leaves the new
243
+ * page in GENERATING at 0/N. That resumed page always checkpoints
244
+ * (`browser_resumed`), so a checkpoint claiming "still generating, variants
245
+ * missing" for a session whose generation already completed is direct
246
+ * evidence of the miss. Rebuild the `done` payload from the snapshot so the
247
+ * caller can re-broadcast it; the browser's done handler is idempotent and
248
+ * falls back to injecting variants from source.
249
+ *
250
+ * Keys on the store's monotone `generationCompletedAt`, not `phase` — the
251
+ * behind checkpoint itself regresses `phase` to `generating`, and a browser
252
+ * that misses the redelivered `done` too (another reload) must still trigger
253
+ * redelivery from its next checkpoint.
254
+ */
255
+ function detectMissedGenerationCompletion(event) {
256
+ if (!event?.id || event.type !== 'checkpoint') return null;
257
+ if (event.phase !== 'generating') return null;
258
+ if (!variantCountLooksBehind(event.arrivedVariants, event.expectedVariants)) return null;
259
+ if (!state.sessionStore) return null;
260
+ let snapshot = null;
261
+ try {
262
+ snapshot = state.sessionStore.getSnapshot(event.id);
263
+ } catch {
264
+ return null;
265
+ }
266
+ return missedCompletionFromSnapshot(snapshot);
267
+ }
268
+
269
+ function variantCountLooksBehind(arrivedValue, expectedValue) {
270
+ const arrived = Number(arrivedValue) || 0;
271
+ const expected = Number(expectedValue) || 0;
272
+ return arrived <= 0 || (expected > 0 && arrived < expected);
273
+ }
274
+
275
+ function missedCompletionFromSnapshot(snapshot) {
276
+ if (!snapshot?.id || !snapshot.generationCompletedAt) return null;
277
+ if (snapshot.generationCanceled) return null;
278
+ // Accept/discard already underway: the browser is no longer waiting on
279
+ // generation, and a late `done` there would collide with teardown.
280
+ if (GENERATION_FENCED_PHASES.has(snapshot.phase)) return null;
281
+ const file = snapshot.sourceFile || snapshot.previewFile;
282
+ if (!file) return null;
283
+ return {
284
+ type: 'done',
285
+ id: snapshot.id,
286
+ file,
287
+ sourceFile: snapshot.sourceFile || undefined,
288
+ previewFile: snapshot.previewFile || undefined,
289
+ previewMode: snapshot.previewMode || undefined,
290
+ redelivered: true,
291
+ };
292
+ }
293
+
294
+ function recordGenerationCheckpoint(event) {
295
+ if (!event?.id || event.type !== 'checkpoint') return;
296
+ if (generationIsFenced(event.id)) return;
297
+ // Only checkpoints that report a change in variant availability are
298
+ // generation progress. The browser also checkpoints for durability on Tune
299
+ // slider drags, resumes, and anchor recovery; treating those as progress
300
+ // echoed `variant_progress` straight back to the browser that sent it, which
301
+ // remounts the component preview mid-drag (reverting the user's live param
302
+ // edit and detaching the popover's element), and permanently latched the
303
+ // *_reviewable phases from the wrong trigger, corrupting generation timings.
304
+ if (!VARIANT_PROGRESS_CHECKPOINT_REASONS.has(event.reason)) return;
305
+ const arrived = Number(event.arrivedVariants) || 0;
306
+ const expected = Number(event.expectedVariants) || 0;
307
+ if (arrived <= 0 || expected <= 0) return;
308
+ const previewMode = event.previewMode || 'source';
309
+ const previewFile = event.previewFile || event.file;
310
+ if (previewFile) {
311
+ broadcast({
312
+ type: 'variant_progress',
313
+ id: event.id,
314
+ file: previewFile,
315
+ sourceFile: event.sourceFile || (previewMode === 'source' ? previewFile : undefined),
316
+ previewFile,
317
+ previewMode,
318
+ arrivedVariants: arrived,
319
+ expectedVariants: expected,
320
+ publicationKind: event.publicationKind || 'variants',
321
+ });
322
+ }
323
+ const details = {
324
+ arrivedVariants: arrived,
325
+ expectedVariants: expected,
326
+ checkpointReason: event.reason || null,
327
+ };
328
+ const at = Date.now();
329
+ if (!generationPhaseAlreadyRecorded(event.id, 'first_reviewable')) {
330
+ recordAgentPhase(event.id, 'first_reviewable', { ...details, at });
331
+ }
332
+ if (arrived >= 2 && expected >= 3 && !generationPhaseAlreadyRecorded(event.id, 'second_reviewable')) {
333
+ recordAgentPhase(event.id, 'second_reviewable', { ...details, at });
334
+ }
335
+ if (arrived >= expected && !generationPhaseAlreadyRecorded(event.id, 'all_variants_ready')) {
336
+ recordAgentPhase(event.id, 'all_variants_ready', { ...details, at });
337
+ }
338
+ }
339
+
340
+ function generationIsFenced(id) {
341
+ if (!state.sessionStore || !id) return false;
342
+ try {
343
+ const snapshot = state.sessionStore.getSnapshot(id, { includeCompleted: true });
344
+ return snapshot?.generationCanceled === true;
345
+ } catch {
346
+ return false;
347
+ }
348
+ }
349
+
350
+ function generationPhaseAlreadyRecorded(id, phase) {
351
+ if (!state.sessionStore) return false;
352
+ try {
353
+ const snapshot = state.sessionStore.getSnapshot(id, { includeCompleted: true });
354
+ return !!snapshot?.generationTimings?.[phase];
355
+ } catch {
356
+ return false;
357
+ }
358
+ }
359
+
360
+ function acknowledgePendingEvent(id, sourceEventType) {
180
361
  if (!id) return false;
181
- const idx = state.pendingEvents.findIndex((entry) => entry.event?.id === id);
362
+ const idx = state.pendingEvents.findIndex((entry) => (
363
+ entry.event?.id === id
364
+ && (!sourceEventType || entry.event?.type === sourceEventType)
365
+ ));
182
366
  if (idx === -1) return false;
183
367
  const acknowledged = state.pendingEvents[idx].event;
184
368
  state.pendingEvents.splice(idx, 1);
@@ -187,9 +371,39 @@ function acknowledgePendingEvent(id) {
187
371
  return acknowledged;
188
372
  }
189
373
 
190
- function findPendingEventById(id) {
374
+ function releasePendingEvent(id, sourceEventType) {
375
+ const entry = state.pendingEvents.find((item) => (
376
+ item.event?.id === id
377
+ && (!sourceEventType || item.event?.type === sourceEventType)
378
+ ));
379
+ if (!entry) return null;
380
+ entry.leaseUntil = 0;
381
+ scheduleLeaseFlush();
382
+ return entry.event;
383
+ }
384
+
385
+ function retirePendingGeneration(id) {
386
+ if (!id) return 0;
387
+ let retired = 0;
388
+ for (let index = state.pendingEvents.length - 1; index >= 0; index -= 1) {
389
+ const event = state.pendingEvents[index]?.event;
390
+ if (event?.id !== id || event.type !== 'generate') continue;
391
+ state.pendingEvents.splice(index, 1);
392
+ retired += 1;
393
+ }
394
+ if (retired > 0) {
395
+ scheduleLeaseFlush();
396
+ broadcastAgentPollingIfChanged();
397
+ }
398
+ return retired;
399
+ }
400
+
401
+ function findPendingEventById(id, sourceEventType) {
191
402
  if (!id) return null;
192
- const entry = state.pendingEvents.find((item) => item.event?.id === id);
403
+ const entry = state.pendingEvents.find((item) => (
404
+ item.event?.id === id
405
+ && (!sourceEventType || item.event?.type === sourceEventType)
406
+ ));
193
407
  return entry?.event || null;
194
408
  }
195
409
 
@@ -198,7 +412,7 @@ function summarizePendingEventForStatus(entry) {
198
412
  const summary = {
199
413
  id: event.id,
200
414
  type: event.type,
201
- leased: !!(entry.leaseUntil && entry.leaseUntil > Date.now()),
415
+ leased: isLeased(entry),
202
416
  leaseUntil: entry.leaseUntil || null,
203
417
  };
204
418
  if (event.type === 'manual_edit_apply') {
@@ -224,7 +438,13 @@ function summarizeActiveSessionForClient(snapshot = {}) {
224
438
  arrivedVariants: snapshot.arrivedVariants ?? 0,
225
439
  visibleVariant: snapshot.visibleVariant ?? null,
226
440
  checkpointRevision: snapshot.checkpointRevision ?? 0,
441
+ browserCheckpointRevision: snapshot.browserCheckpointRevision ?? snapshot.checkpointRevision ?? 0,
442
+ publicationCheckpointRevision: snapshot.publicationCheckpointRevision ?? 0,
227
443
  paramValues: snapshot.paramValues || {},
444
+ generationPhase: snapshot.generationPhase ?? null,
445
+ generationCompletedAt: snapshot.generationCompletedAt ?? null,
446
+ generationCanceled: snapshot.generationCanceled === true,
447
+ cancelReason: snapshot.cancelReason ?? null,
228
448
  };
229
449
  }
230
450
 
@@ -269,24 +489,46 @@ function scheduleLeaseFlush() {
269
489
  function flushPendingPolls() {
270
490
  let changed = false;
271
491
  while (state.pendingPolls.length > 0) {
272
- const entry = findAvailablePendingEvent();
492
+ let pollIndex = -1;
493
+ let entry = null;
494
+ for (let index = 0; index < state.pendingPolls.length; index += 1) {
495
+ const candidate = findAvailablePendingEvent(Date.now(), state.pendingPolls[index].types);
496
+ if (!candidate) continue;
497
+ pollIndex = index;
498
+ entry = candidate;
499
+ break;
500
+ }
273
501
  if (!entry) {
274
502
  scheduleLeaseFlush();
275
503
  broadcastAgentPollingIfChanged();
276
504
  return;
277
505
  }
278
- const poll = state.pendingPolls.shift();
279
- poll.resolve(leaseEvent(entry, poll.leaseMs));
506
+ const [poll] = state.pendingPolls.splice(pollIndex, 1);
507
+ // leaseEvent is async (it may scaffold source), but it claims the entry
508
+ // synchronously, so the next loop iteration will not re-select it. Resolve
509
+ // the poll when the lease settles rather than awaiting here, so one slow
510
+ // scaffold never delays the other parked polls. On the exceptional failure
511
+ // path, answer `timeout` so the agent re-polls; the claim stays until the
512
+ // lease expires, which keeps a deterministic failure from hot-looping.
513
+ leaseEvent(entry, poll.leaseMs).then(poll.resolve, (error) => {
514
+ console.error('[live] lease failed for ' + (entry.event?.id || 'unknown') + ': ' + (error?.message || error));
515
+ poll.resolve({ type: 'timeout' });
516
+ });
280
517
  changed = true;
281
518
  }
282
519
  scheduleLeaseFlush();
283
520
  if (changed) broadcastAgentPollingIfChanged();
284
521
  }
285
522
 
523
+ function isLeased(entry) {
524
+ return !!(entry?.leaseUntil && entry.leaseUntil > Date.now());
525
+ }
526
+
286
527
  function agentPollingConnected() {
287
- const now = Date.now();
288
- return state.pendingPolls.length > 0
289
- || state.pendingEvents.some((entry) => entry.leaseUntil && entry.leaseUntil > now);
528
+ // A leased event only proves that a poll returned once. The foreground task
529
+ // may have ended immediately afterward, so only an actively waiting poll is
530
+ // evidence that steering can wake the task right now.
531
+ return state.pendingPolls.length > 0;
290
532
  }
291
533
 
292
534
  function broadcastAgentPollingIfChanged() {
@@ -383,13 +625,37 @@ function statOrNull(filePath) {
383
625
  try { return fs.statSync(filePath); } catch { return null; }
384
626
  }
385
627
 
628
+ // Strict loopback-origin test for CORS. Parses the Origin as a URL (never a
629
+ // substring match, so `http://localhost.evil.com` and `http://127.0.0.1.evil.com`
630
+ // fail) and accepts only http/https on localhost, 127.0.0.1, or the IPv6 loopback.
631
+ function isLoopbackOrigin(origin) {
632
+ if (typeof origin !== 'string' || origin.length === 0) return false;
633
+ let parsed;
634
+ try { parsed = new URL(origin); } catch { return false; }
635
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false;
636
+ const host = parsed.hostname.toLowerCase();
637
+ return host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]';
638
+ }
639
+
386
640
  // HTTP request handler
387
641
  // ---------------------------------------------------------------------------
388
642
 
389
643
  function createRequestHandler({ detectScript, liveScriptParts }) {
390
644
  return (req, res) => {
391
645
  const url = new URL(req.url, `http://localhost:${state.port}`);
392
- res.setHeader('Access-Control-Allow-Origin', '*');
646
+ // Loopback-restricted CORS. Reflect the caller's Origin only when it is a
647
+ // loopback origin, always paired with `Vary: Origin` so an intermediary
648
+ // cache never serves a response authorized for one origin to another. A
649
+ // remote page (e.g. https://evil.example probing the port from a tab open
650
+ // on the same machine) gets no Access-Control-Allow-Origin, so its
651
+ // JS-initiated fetch cannot read any response. Requests with no Origin
652
+ // header (script tags, curl, the agent's own fetches) are not subject to
653
+ // CORS and keep working; no ACAO header is needed for them.
654
+ const origin = req.headers.origin;
655
+ if (origin && isLoopbackOrigin(origin)) {
656
+ res.setHeader('Access-Control-Allow-Origin', origin);
657
+ res.setHeader('Vary', 'Origin');
658
+ }
393
659
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
394
660
  res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
395
661
  if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
@@ -398,6 +664,15 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
398
664
 
399
665
  // --- Scripts ---
400
666
  if (p === '/live.js') {
667
+ // Token-gated: the script body embeds state.token, which unlocks every
668
+ // token-guarded route. Serving it unauthenticated let any local page read
669
+ // the token and drive the session. The injected <script src> carries
670
+ // `?token=...` (see live-inject.mjs). A missing/wrong token → 401.
671
+ if (url.searchParams.get('token') !== state.token) {
672
+ res.writeHead(401, { 'Content-Type': 'text/plain' });
673
+ res.end('Unauthorized');
674
+ return;
675
+ }
401
676
  // Re-read from disk each request so edits to live-browser.js land on
402
677
  // the next tab reload. No-store headers prevent browser caching across
403
678
  // sessions — during iteration, a cached old script silently breaks
@@ -605,7 +880,13 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
605
880
  const filePath = url.searchParams.get('path');
606
881
  if (!filePath || filePath.includes('..')) { res.writeHead(400); res.end('Bad path'); return; }
607
882
  const absPath = path.resolve(process.cwd(), filePath);
608
- if (!absPath.startsWith(process.cwd())) { res.writeHead(403); res.end('Forbidden'); return; }
883
+ // Confine to the project root. A bare `startsWith(cwd)` string check lets a
884
+ // sibling dir whose name extends the root name (projeto -> projeto-backup)
885
+ // slip through; compare on the relative path instead (same pattern as
886
+ // sessionFileMetadataFromPollReply below). An empty rel means the request
887
+ // resolved to the root directory itself, which this file route never serves.
888
+ const rel = path.relative(process.cwd(), absPath);
889
+ if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) { res.writeHead(403); res.end('Forbidden'); return; }
609
890
  let content;
610
891
  try { content = fs.readFileSync(absPath, 'utf-8'); }
611
892
  catch { res.writeHead(404); res.end('File not found'); return; }
@@ -689,6 +970,16 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
689
970
  res.end(JSON.stringify({ error }));
690
971
  return;
691
972
  }
973
+ if (msg.type === 'agent_phase') {
974
+ recordAgentPhase(msg.id, msg.phase, {
975
+ ...(Number.isFinite(msg.durationMs) ? { durationMs: msg.durationMs } : {}),
976
+ owner: typeof msg.owner === 'string' ? msg.owner : undefined,
977
+ });
978
+ res.writeHead(200, { 'Content-Type': 'application/json' });
979
+ res.end(JSON.stringify({ ok: true }));
980
+ return;
981
+ }
982
+ const missedCompletion = detectMissedGenerationCompletion(msg);
692
983
  if (state.sessionStore && msg.id) {
693
984
  try {
694
985
  state.sessionStore.appendEvent(msg);
@@ -698,6 +989,11 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
698
989
  return;
699
990
  }
700
991
  }
992
+ if (msg.type === 'accept' || msg.type === 'discard') {
993
+ retirePendingGeneration(msg.id);
994
+ }
995
+ recordGenerationCheckpoint(msg);
996
+ if (missedCompletion) broadcast(missedCompletion);
701
997
  if (msg.type === 'exit') {
702
998
  cleanupSvelteComponentSessionsBeforeExit();
703
999
  }
@@ -738,6 +1034,12 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
738
1034
  // Agent poll endpoints (unchanged from WS version)
739
1035
  // ---------------------------------------------------------------------------
740
1036
 
1037
+ function parsePollTypes(value) {
1038
+ if (!value) return null;
1039
+ const types = String(value).split(',').map((type) => type.trim()).filter(Boolean);
1040
+ return types.length > 0 ? new Set(types) : null;
1041
+ }
1042
+
741
1043
  function handlePollGet(req, res, url) {
742
1044
  const token = url.searchParams.get('token');
743
1045
  if (token !== state.token) {
@@ -748,13 +1050,25 @@ function handlePollGet(req, res, url) {
748
1050
  state.lastPollAt = Date.now();
749
1051
  const timeout = parseInt(url.searchParams.get('timeout') || DEFAULT_POLL_TIMEOUT, 10);
750
1052
  const leaseMs = parseInt(url.searchParams.get('leaseMs') || '30000', 10);
751
- const available = findAvailablePendingEvent();
1053
+ const types = parsePollTypes(url.searchParams.get('types'));
1054
+ const available = findAvailablePendingEvent(Date.now(), types);
752
1055
  if (available) {
753
- res.writeHead(200, { 'Content-Type': 'application/json' });
754
- res.end(JSON.stringify(leaseEvent(available, leaseMs)));
1056
+ // Do not await inline: leaseEvent may scaffold source, and this handler runs
1057
+ // on the server's only thread. The client can disconnect during that window,
1058
+ // so check the socket before replying.
1059
+ leaseEvent(available, leaseMs).then((event) => {
1060
+ if (res.writableEnded || res.destroyed) return;
1061
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1062
+ res.end(JSON.stringify(event));
1063
+ }, (error) => {
1064
+ console.error('[live] lease failed for ' + (available.event?.id || 'unknown') + ': ' + (error?.message || error));
1065
+ if (res.writableEnded || res.destroyed) return;
1066
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1067
+ res.end(JSON.stringify({ type: 'timeout' }));
1068
+ });
755
1069
  return;
756
1070
  }
757
- const poll = { resolve, leaseMs };
1071
+ const poll = { resolve, leaseMs, types };
758
1072
  const timer = setTimeout(() => {
759
1073
  const idx = state.pendingPolls.indexOf(poll);
760
1074
  if (idx !== -1) state.pendingPolls.splice(idx, 1);
@@ -783,12 +1097,15 @@ function sessionFileMetadataFromPollReply(file) {
783
1097
  if (!file || typeof file !== 'string') return { file };
784
1098
  const normalized = file.split(path.sep).join('/');
785
1099
  const base = { file: normalized };
786
- if (!normalized.endsWith('/manifest.json') && normalized !== 'manifest.json') return base;
787
- if (!normalized.includes('node_modules/.impeccable-live/') && !normalized.includes('src/lib/impeccable/')) return base;
1100
+ const metadataFile = normalized;
1101
+ if (!metadataFile.endsWith('/manifest.json') && metadataFile !== 'manifest.json') return base;
1102
+ if (!metadataFile.includes('node_modules/.impeccable-live/')
1103
+ && !metadataFile.includes('src/lib/impeccable/')
1104
+ && !metadataFile.includes('/.impeccable-live/')) return base;
788
1105
 
789
1106
  let full;
790
1107
  try {
791
- full = path.resolve(process.cwd(), normalized);
1108
+ full = path.resolve(process.cwd(), metadataFile);
792
1109
  const rel = path.relative(process.cwd(), full);
793
1110
  if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return base;
794
1111
  } catch {
@@ -797,18 +1114,47 @@ function sessionFileMetadataFromPollReply(file) {
797
1114
 
798
1115
  try {
799
1116
  const manifest = JSON.parse(fs.readFileSync(full, 'utf-8'));
800
- if (manifest?.previewMode !== 'svelte-component' || !manifest.sourceFile) return base;
1117
+ if (manifest?.previewMode !== 'svelte-component'
1118
+ || !manifest.sourceFile) return base;
801
1119
  return {
802
1120
  file: String(manifest.sourceFile).split(path.sep).join('/'),
803
1121
  sourceFile: String(manifest.sourceFile).split(path.sep).join('/'),
804
1122
  previewFile: normalized,
805
- previewMode: 'svelte-component',
1123
+ previewMode: manifest.previewMode,
806
1124
  };
807
1125
  } catch {
808
1126
  return base;
809
1127
  }
810
1128
  }
811
1129
 
1130
+ function inferSourceEventType(msg = {}, pendingEvents = state.pendingEvents) {
1131
+ const entriesForId = pendingEvents.filter((entry) => entry.event?.id === msg.id);
1132
+ const pendingTypes = new Set(entriesForId.map((entry) => entry.event?.type));
1133
+ if (msg.type === 'discarded' || msg.type === 'discard') return 'discard';
1134
+ if (msg.type === 'complete') {
1135
+ if (pendingTypes.has('carbonize_cleanup')) return 'carbonize_cleanup';
1136
+ return pendingTypes.has('accept') ? 'accept' : (pendingTypes.has('generate') ? 'generate' : undefined);
1137
+ }
1138
+ if (msg.type === 'steer_done') return 'steer';
1139
+ // `agent_done` can be the automatic acknowledgement for a carbonize Accept.
1140
+ // New pollers send sourceEventType explicitly; default to generate only for
1141
+ // older callers so a late worker cannot acknowledge a queued Accept.
1142
+ if (msg.type === 'agent_done' || msg.type === 'done') return 'generate';
1143
+ // `error` is reference/live.md's documented failure reply, and parseReplyArgs
1144
+ // never sets sourceEventType on it (the poller is a fresh process that cannot
1145
+ // know what it leased). Returning undefined here makes acknowledgePendingEvent
1146
+ // match *any* event for this id: a stale generate worker's failure silently
1147
+ // consumed the user's queued Accept, which was then never delivered to any
1148
+ // agent and left the browser in SAVING forever. Attribute the failure to the
1149
+ // event this agent actually holds a lease on, and otherwise to `generate` —
1150
+ // never to a wildcard. If that generate was already retired by an Accept, the
1151
+ // ack simply finds no match, which is the correct outcome for a stale reply.
1152
+ if (msg.type === 'error') {
1153
+ return entriesForId.find(isLeased)?.event?.type || 'generate';
1154
+ }
1155
+ return undefined;
1156
+ }
1157
+
812
1158
  function handlePollPost(req, res) {
813
1159
  let body = '';
814
1160
  req.on('data', (c) => { body += c; });
@@ -869,7 +1215,23 @@ function handlePollPost(req, res) {
869
1215
  res.end(JSON.stringify({ error: 'stale_manual_edit_apply_reply', ...rollback }));
870
1216
  return;
871
1217
  }
872
- const pendingEventBeforeAck = findPendingEventById(msg.id);
1218
+ const sourceEventType = msg.sourceEventType || inferSourceEventType(msg);
1219
+ if (msg.type === 'retry') {
1220
+ const releasedEvent = releasePendingEvent(msg.id, sourceEventType);
1221
+ if (!releasedEvent) {
1222
+ res.writeHead(msg.id ? 404 : 400, { 'Content-Type': 'application/json' });
1223
+ res.end(JSON.stringify({
1224
+ error: msg.id ? 'unknown_poll_retry_id' : 'missing_poll_retry_id',
1225
+ id: msg.id,
1226
+ }));
1227
+ return;
1228
+ }
1229
+ flushPendingPolls();
1230
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1231
+ res.end(JSON.stringify({ ok: true, released: true }));
1232
+ return;
1233
+ }
1234
+ const pendingEventBeforeAck = findPendingEventById(msg.id, sourceEventType);
873
1235
  if (pendingEventBeforeAck?.type === 'steer' && msg.type === 'steer_done'
874
1236
  && !msg.file && !(typeof msg.message === 'string' && msg.message.trim())) {
875
1237
  res.writeHead(400, { 'Content-Type': 'application/json' });
@@ -879,7 +1241,7 @@ function handlePollPost(req, res) {
879
1241
  }));
880
1242
  return;
881
1243
  }
882
- const acknowledgedEvent = acknowledgePendingEvent(msg.id);
1244
+ const acknowledgedEvent = acknowledgePendingEvent(msg.id, sourceEventType);
883
1245
  let skipJournalReply = false;
884
1246
  let existingSession = null;
885
1247
  if (!acknowledgedEvent && state.sessionStore && msg.id) {
@@ -1083,7 +1445,10 @@ if (args.includes('--background')) {
1083
1445
  process.exit(0);
1084
1446
  }
1085
1447
  } catch { /* not ready yet */ }
1086
- await new Promise(r => setTimeout(r, 200));
1448
+ // The detached child is typically listening in 35-45ms. A 200ms polling
1449
+ // floor dominated configured cold Live startup; poll cheaply and return
1450
+ // as soon as the child has written its ready record.
1451
+ await new Promise(r => setTimeout(r, 5));
1087
1452
  }
1088
1453
  console.error('Timed out waiting for live server to start.');
1089
1454
  process.exit(1);
@@ -37,15 +37,19 @@ export async function statusCli() {
37
37
  pendingEvents: server.pendingEvents,
38
38
  } : null,
39
39
  activeSessions: server?.activeSessions || activeSessions,
40
- recoveryHint: manualApply
41
- ? manualApplyResumeHint(manualApply)
42
- : server
43
- ? 'Run live-poll.mjs to continue pending work, or live-complete.mjs --id <session> after manual cleanup.'
44
- : 'Start live-server.mjs to requeue pending durable events, then run live-poll.mjs.',
40
+ recoveryHint: recoveryHint({ server, manualApply }),
45
41
  };
46
42
  console.log(JSON.stringify(payload, null, 2));
47
43
  }
48
44
 
45
+ function recoveryHint({ server, manualApply }) {
46
+ if (manualApply) return manualApplyResumeHint(manualApply);
47
+ if (server) {
48
+ return 'Run live-poll.mjs to continue pending work, or live-complete.mjs --id <session> after manual cleanup.';
49
+ }
50
+ return 'Start live-server.mjs to requeue pending durable events, then run live-poll.mjs.';
51
+ }
52
+
49
53
  function findPendingManualApply(server, activeSessions) {
50
54
  const fromServer = server?.pendingEvents?.find((event) => event?.type === 'manual_edit_apply');
51
55
  if (fromServer) return fromServer;