@bastani/atomic 0.9.0-alpha.3 → 0.9.0-alpha.4

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 (84) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/builtin/cursor/package.json +2 -2
  3. package/dist/builtin/intercom/package.json +1 -1
  4. package/dist/builtin/mcp/package.json +1 -1
  5. package/dist/builtin/subagents/package.json +1 -1
  6. package/dist/builtin/web-access/package.json +1 -1
  7. package/dist/builtin/workflows/CHANGELOG.md +17 -0
  8. package/dist/builtin/workflows/README.md +12 -12
  9. package/dist/builtin/workflows/builtin/goal-prompts.ts +8 -0
  10. package/dist/builtin/workflows/builtin/goal-runner.ts +96 -1
  11. package/dist/builtin/workflows/builtin/goal-types.ts +2 -0
  12. package/dist/builtin/workflows/builtin/goal.d.ts +3 -0
  13. package/dist/builtin/workflows/builtin/goal.ts +12 -1
  14. package/dist/builtin/workflows/builtin/index.d.ts +8 -8
  15. package/dist/builtin/workflows/builtin/open-claude-design-feedback.ts +359 -0
  16. package/dist/builtin/workflows/builtin/open-claude-design-phases.ts +254 -352
  17. package/dist/builtin/workflows/builtin/open-claude-design-runner.ts +256 -414
  18. package/dist/builtin/workflows/builtin/open-claude-design-setup.ts +272 -0
  19. package/dist/builtin/workflows/builtin/open-claude-design-utils.ts +58 -68
  20. package/dist/builtin/workflows/builtin/open-claude-design.d.ts +5 -9
  21. package/dist/builtin/workflows/builtin/open-claude-design.ts +14 -26
  22. package/dist/builtin/workflows/package.json +1 -1
  23. package/dist/builtin/workflows/skills/impeccable/SKILL.md +14 -23
  24. package/dist/builtin/workflows/skills/impeccable/reference/brand.md +2 -2
  25. package/dist/builtin/workflows/skills/impeccable/reference/live.md +25 -4
  26. package/dist/builtin/workflows/skills/impeccable/scripts/context-signals.mjs +1 -1
  27. package/dist/builtin/workflows/skills/impeccable/scripts/context.mjs +724 -29
  28. package/dist/builtin/workflows/skills/impeccable/scripts/critique-storage.mjs +1 -1
  29. package/dist/builtin/workflows/skills/impeccable/scripts/detector/browser/injected/index.mjs +219 -7
  30. package/dist/builtin/workflows/skills/impeccable/scripts/detector/cli/main.mjs +57 -11
  31. package/dist/builtin/workflows/skills/impeccable/scripts/detector/design-system.mjs +750 -0
  32. package/dist/builtin/workflows/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +648 -53
  33. package/dist/builtin/workflows/skills/impeccable/scripts/detector/detect-antipatterns.mjs +7 -0
  34. package/dist/builtin/workflows/skills/impeccable/scripts/detector/engines/browser/detect-url.mjs +29 -4
  35. package/dist/builtin/workflows/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs +44 -11
  36. package/dist/builtin/workflows/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +29 -0
  37. package/dist/builtin/workflows/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs +27 -1
  38. package/dist/builtin/workflows/skills/impeccable/scripts/detector/node/file-system.mjs +1 -1
  39. package/dist/builtin/workflows/skills/impeccable/scripts/detector/registry/antipatterns.mjs +29 -0
  40. package/dist/builtin/workflows/skills/impeccable/scripts/detector/rules/checks.mjs +401 -46
  41. package/dist/builtin/workflows/skills/impeccable/scripts/detector/shared/inline-ignores.mjs +148 -0
  42. package/dist/builtin/workflows/skills/impeccable/scripts/detector/shared/page.mjs +6 -6
  43. package/dist/builtin/workflows/skills/impeccable/scripts/{design-parser.mjs → lib/design-parser.mjs} +8 -1
  44. package/dist/builtin/workflows/skills/impeccable/scripts/lib/impeccable-config.mjs +638 -0
  45. package/dist/builtin/workflows/skills/impeccable/scripts/lib/impeccable-paths.mjs +128 -0
  46. package/dist/builtin/workflows/skills/impeccable/scripts/{is-generated.mjs → lib/is-generated.mjs} +2 -2
  47. package/dist/builtin/workflows/skills/impeccable/scripts/lib/target-args.mjs +42 -0
  48. package/dist/builtin/workflows/skills/impeccable/scripts/live/browser-script-parts.mjs +49 -0
  49. package/dist/builtin/workflows/skills/impeccable/scripts/{live-completion.mjs → live/completion.mjs} +1 -0
  50. package/dist/builtin/workflows/skills/impeccable/scripts/{live-event-validation.mjs → live/event-validation.mjs} +6 -5
  51. package/dist/builtin/workflows/skills/impeccable/scripts/live/manual-apply.mjs +939 -0
  52. package/dist/builtin/workflows/skills/impeccable/scripts/live/manual-edit-routes.mjs +357 -0
  53. package/dist/builtin/workflows/skills/impeccable/scripts/{live-manual-edits-buffer.mjs → live/manual-edits-buffer.mjs} +1 -1
  54. package/dist/builtin/workflows/skills/impeccable/scripts/{live-session-store.mjs → live/session-store.mjs} +21 -3
  55. package/dist/builtin/workflows/skills/impeccable/scripts/live/svelte-component.mjs +835 -0
  56. package/dist/builtin/workflows/skills/impeccable/scripts/live/sveltekit-adapter.mjs +274 -0
  57. package/dist/builtin/workflows/skills/impeccable/scripts/live/ui-core.mjs +180 -0
  58. package/dist/builtin/workflows/skills/impeccable/scripts/live/vocabulary.mjs +36 -0
  59. package/dist/builtin/workflows/skills/impeccable/scripts/live-accept.mjs +185 -60
  60. package/dist/builtin/workflows/skills/impeccable/scripts/live-browser-dom.js +146 -0
  61. package/dist/builtin/workflows/skills/impeccable/scripts/live-browser.js +3369 -1026
  62. package/dist/builtin/workflows/skills/impeccable/scripts/live-commit-manual-edits.mjs +2 -2
  63. package/dist/builtin/workflows/skills/impeccable/scripts/live-complete.mjs +2 -2
  64. package/dist/builtin/workflows/skills/impeccable/scripts/live-discard-manual-edits.mjs +1 -1
  65. package/dist/builtin/workflows/skills/impeccable/scripts/live-inject.mjs +133 -9
  66. package/dist/builtin/workflows/skills/impeccable/scripts/live-insert.mjs +42 -2
  67. package/dist/builtin/workflows/skills/impeccable/scripts/live-manual-edit-evidence.mjs +4 -4
  68. package/dist/builtin/workflows/skills/impeccable/scripts/live-poll.mjs +21 -15
  69. package/dist/builtin/workflows/skills/impeccable/scripts/live-resume.mjs +1 -1
  70. package/dist/builtin/workflows/skills/impeccable/scripts/live-server.mjs +205 -1269
  71. package/dist/builtin/workflows/skills/impeccable/scripts/live-status.mjs +2 -2
  72. package/dist/builtin/workflows/skills/impeccable/scripts/live-target.mjs +30 -0
  73. package/dist/builtin/workflows/skills/impeccable/scripts/live-wrap.mjs +69 -26
  74. package/dist/builtin/workflows/skills/impeccable/scripts/live.mjs +73 -22
  75. package/dist/core/atomic-guide-command.d.ts.map +1 -1
  76. package/dist/core/atomic-guide-command.js +5 -5
  77. package/dist/core/atomic-guide-command.js.map +1 -1
  78. package/docs/index.md +2 -2
  79. package/docs/quickstart.md +9 -9
  80. package/docs/workflows.md +42 -23
  81. package/package.json +2 -2
  82. package/dist/builtin/workflows/skills/impeccable/scripts/cleanup-deprecated.mjs +0 -284
  83. package/dist/builtin/workflows/skills/impeccable/scripts/impeccable-paths.mjs +0 -126
  84. /package/dist/builtin/workflows/skills/impeccable/scripts/{live-insert-ui.mjs → live/insert-ui.mjs} +0 -0
@@ -20,10 +20,18 @@ import fs from 'node:fs';
20
20
  import path from 'node:path';
21
21
  import net from 'node:net';
22
22
  import { fileURLToPath } from 'node:url';
23
- import { parseDesignMd } from './design-parser.mjs';
24
- import { resolveContextDir } from './context.mjs';
25
- import { createLiveSessionStore } from './live-session-store.mjs';
26
- import { validateEvent } from './live-event-validation.mjs';
23
+ import { parseDesignMd } from './lib/design-parser.mjs';
24
+ import { loadContext } from './context.mjs';
25
+ import {
26
+ assembleLiveBrowserScript,
27
+ assertLiveBrowserScriptParts,
28
+ readLiveBrowserScriptParts,
29
+ resolveLiveBrowserScriptParts,
30
+ } from './live/browser-script-parts.mjs';
31
+ import { createLiveSessionStore } from './live/session-store.mjs';
32
+ import { validateEvent } from './live/event-validation.mjs';
33
+ import { createManualEditRoutes } from './live/manual-edit-routes.mjs';
34
+ import { LIVE_COMMANDS } from './live/vocabulary.mjs';
27
35
  import {
28
36
  getDesignSidecarPath,
29
37
  getLiveDir,
@@ -32,22 +40,26 @@ import {
32
40
  removeLiveServerInfo,
33
41
  resolveDesignSidecarPath,
34
42
  writeLiveServerInfo,
35
- } from './impeccable-paths.mjs';
43
+ } from './lib/impeccable-paths.mjs';
44
+ import { countByPage as countPendingByPage } from './live/manual-edits-buffer.mjs';
45
+ import {
46
+ createManualApplyController,
47
+ summarizeManualApplyFailures,
48
+ } from './live/manual-apply.mjs';
36
49
  import {
37
- countByPage as countPendingByPage,
38
- readBuffer as readManualEditsBuffer,
39
- removeEntries as removeManualEditEntries,
40
- stageEntry as stageManualEditEntry,
41
- truncateBuffer as truncateManualEditsBuffer,
42
- } from './live-manual-edits-buffer.mjs';
43
- import { buildManualEditEvidence } from './live-manual-edit-evidence.mjs';
44
- import { commitManualEdits } from './live-commit-manual-edits.mjs';
50
+ applyDeferredSvelteComponentAccepts,
51
+ removeAllSvelteComponentSessions,
52
+ } from './live/svelte-component.mjs';
45
53
 
46
54
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
47
55
  // PRODUCT.md / DESIGN.md live wherever context.mjs resolves. The generated
48
56
  // DESIGN sidecar is project-local at .impeccable/design.json, with legacy
49
57
  // DESIGN.json fallback for existing projects.
50
- const CONTEXT_DIR = resolveContextDir(process.cwd());
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;
51
63
  const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway
52
64
  const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s
53
65
 
@@ -96,22 +108,29 @@ const state = {
96
108
  };
97
109
 
98
110
  const CHAT_POLL_FRESHNESS_MS = 60_000;
99
- const APPLY_EVENT_HARD_TIMEOUT_MS = Number(process.env.IMPECCABLE_LIVE_APPLY_EVENT_HARD_TIMEOUT_MS || 150_000);
100
- const APPLY_EVENT_SOFT_DEADLINE_MS = Number(process.env.IMPECCABLE_LIVE_APPLY_EVENT_SOFT_DEADLINE_MS || 120_000);
101
- const DEFAULT_MANUAL_EDIT_APPLY_CHUNK_SIZE = 3;
102
- const MIN_MANUAL_EDIT_APPLY_CHUNK_SIZE = 1;
103
- const MAX_MANUAL_EDIT_APPLY_CHUNK_SIZE = 20;
104
- const MANUAL_APPLY_COMPACT_TEXT_LIMIT = 240;
105
- const MANUAL_APPLY_COMPACT_NEARBY_LIMIT = 4;
111
+ const POLL_LEASE_EXPIRY_TIMER_GRACE_MS = 2;
106
112
  const DEBUG_MANUAL_EDIT_EVENTS = /^(1|true|yes)$/i.test(process.env.IMPECCABLE_LIVE_DEBUG_EVENTS || '');
107
113
 
108
- function tombstoneTimedOutApplyId(eventId, details = {}) {
109
- if (!eventId) return;
110
- state.timedOutApplyIds.set(eventId, details);
111
- if (state.timedOutApplyIds.size <= 200) return;
112
- const oldest = state.timedOutApplyIds.keys().next().value;
113
- state.timedOutApplyIds.delete(oldest);
114
- }
114
+ const manualApply = createManualApplyController({
115
+ pendingEvents: state.pendingEvents,
116
+ pendingApplyDeferreds: state.pendingApplyDeferreds,
117
+ timedOutApplyIds: state.timedOutApplyIds,
118
+ enqueueEvent,
119
+ acknowledgePendingEvent,
120
+ flushPendingPolls,
121
+ recordManualEditActivity,
122
+ cwd: () => process.cwd(),
123
+ });
124
+
125
+ const manualEditRoutes = createManualEditRoutes({
126
+ getToken: () => state.token,
127
+ manualApply,
128
+ recordManualEditActivity,
129
+ getManualEditStatus,
130
+ chatAgentLikelyActive,
131
+ cwd: () => process.cwd(),
132
+ env: () => process.env,
133
+ });
115
134
 
116
135
  function chatAgentLikelyActive() {
117
136
  if (state.pendingPolls.length > 0) return true;
@@ -119,752 +138,6 @@ function chatAgentLikelyActive() {
119
138
  return Date.now() - state.lastPollAt < CHAT_POLL_FRESHNESS_MS;
120
139
  }
121
140
 
122
- function manualEditApplyChunkSize(env = process.env) {
123
- const raw = Number(env.IMPECCABLE_LIVE_MANUAL_EDIT_CHUNK_SIZE);
124
- if (!Number.isFinite(raw)) return DEFAULT_MANUAL_EDIT_APPLY_CHUNK_SIZE;
125
- const size = Math.trunc(raw);
126
- return Math.max(MIN_MANUAL_EDIT_APPLY_CHUNK_SIZE, Math.min(MAX_MANUAL_EDIT_APPLY_CHUNK_SIZE, size));
127
- }
128
-
129
- function countManualApplyOps(entriesOrBatch) {
130
- const entries = Array.isArray(entriesOrBatch)
131
- ? entriesOrBatch
132
- : Array.isArray(entriesOrBatch?.entries) ? entriesOrBatch.entries : [];
133
- let count = 0;
134
- for (const entry of entries) count += Array.isArray(entry.ops) ? entry.ops.length : 0;
135
- return count;
136
- }
137
-
138
- function pushApplyEventAndWait(batch, pageUrl, chunk = null, repair = null) {
139
- const eventId = randomUUID().replace(/-/g, '').slice(0, 8);
140
- const evidencePath = writeManualApplyEvidence(eventId, batch);
141
- const event = {
142
- type: 'manual_edit_apply',
143
- id: eventId,
144
- pageUrl,
145
- batch: compactManualApplyBatch(batch),
146
- evidencePath,
147
- agentAction: buildManualApplyAgentAction(eventId),
148
- schemaVersion: 1,
149
- deadlineMs: APPLY_EVENT_SOFT_DEADLINE_MS,
150
- };
151
- if (chunk) event.chunk = chunk;
152
- if (repair) event.repair = repair;
153
- const rollbackSnapshot = snapshotApplyEventFiles(batch);
154
- recordManualEditActivity('manual_edit_apply_dispatched', {
155
- id: eventId,
156
- pageUrl,
157
- chunk,
158
- repair,
159
- entryCount: Array.isArray(batch.entries) ? batch.entries.length : 0,
160
- opCount: countManualApplyOps(batch),
161
- fileCount: collectManualApplyFiles(batch).length,
162
- });
163
- return new Promise((resolve, reject) => {
164
- const timer = setTimeout(() => {
165
- state.pendingApplyDeferreds.delete(eventId);
166
- tombstoneTimedOutApplyId(eventId, { batch, rollbackSnapshot });
167
- acknowledgePendingEvent(eventId);
168
- removeManualApplyEvidence(evidencePath);
169
- recordManualEditActivity('manual_edit_apply_timeout', {
170
- id: eventId,
171
- pageUrl,
172
- chunk,
173
- entryCount: Array.isArray(batch.entries) ? batch.entries.length : 0,
174
- opCount: countManualApplyOps(batch),
175
- });
176
- reject(new Error('chat_agent_timeout'));
177
- }, APPLY_EVENT_HARD_TIMEOUT_MS);
178
- state.pendingApplyDeferreds.set(eventId, { resolve, reject, timer, event, batch, pageUrl, rollbackSnapshot });
179
- enqueueEvent(event);
180
- });
181
- }
182
-
183
- function writeManualApplyEvidence(eventId, batch) {
184
- const dir = manualApplyEvidenceDir(process.cwd());
185
- fs.mkdirSync(dir, { recursive: true });
186
- const evidencePath = path.join(dir, `${eventId}.json`);
187
- fs.writeFileSync(evidencePath, JSON.stringify(batch, null, 2) + '\n', 'utf-8');
188
- return evidencePath;
189
- }
190
-
191
- function manualApplyEvidenceDir(cwd = process.cwd()) {
192
- return path.join(getLiveDir(cwd), 'manual-edit-evidence');
193
- }
194
-
195
- function normalizeManualApplyEvidencePath(evidencePath, cwd = process.cwd()) {
196
- if (!evidencePath || typeof evidencePath !== 'string') return null;
197
- const fullPath = path.isAbsolute(evidencePath) ? evidencePath : path.resolve(cwd, evidencePath);
198
- const evidenceDir = manualApplyEvidenceDir(cwd);
199
- const relative = path.relative(evidenceDir, fullPath);
200
- if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) return null;
201
- if (path.extname(relative) !== '.json') return null;
202
- return fullPath;
203
- }
204
-
205
- function removeManualApplyEvidence(evidencePath, cwd = process.cwd()) {
206
- const fullPath = normalizeManualApplyEvidencePath(evidencePath, cwd);
207
- if (!fullPath) return false;
208
- try {
209
- fs.unlinkSync(fullPath);
210
- return true;
211
- } catch {
212
- return false;
213
- }
214
- }
215
-
216
- function referencedManualApplyEvidencePaths(cwd = process.cwd()) {
217
- const referenced = new Set();
218
- const add = (event) => {
219
- const fullPath = normalizeManualApplyEvidencePath(event?.evidencePath, cwd);
220
- if (fullPath) referenced.add(fullPath);
221
- };
222
- for (const entry of state.pendingEvents) add(entry.event);
223
- for (const deferred of state.pendingApplyDeferreds.values()) add(deferred.event);
224
- return referenced;
225
- }
226
-
227
- function pruneStaleManualApplyEvidence(cwd = process.cwd()) {
228
- const dir = manualApplyEvidenceDir(cwd);
229
- if (!fs.existsSync(dir)) return [];
230
- const referenced = referencedManualApplyEvidencePaths(cwd);
231
- const removed = [];
232
- for (const name of fs.readdirSync(dir)) {
233
- if (!name.endsWith('.json')) continue;
234
- const fullPath = path.join(dir, name);
235
- if (referenced.has(fullPath)) continue;
236
- try {
237
- fs.unlinkSync(fullPath);
238
- removed.push(fullPath);
239
- } catch {
240
- // Stale evidence cleanup is best-effort; Apply verification never relies
241
- // on deleting these files.
242
- }
243
- }
244
- return removed;
245
- }
246
-
247
- function compactManualApplyBatch(batch = {}) {
248
- const entries = (batch.entries || []).map(compactManualApplyEntry);
249
- const candidates = compactManualApplyCandidates(batch.candidates || []);
250
- return {
251
- version: batch.version,
252
- pageUrl: batch.pageUrl || null,
253
- count: batch.count,
254
- entries,
255
- ops: entries.flatMap((entry) => entry.ops.map((op) => ({ ...op, entryId: entry.id }))),
256
- candidates: candidates.length > 0 ? candidates : undefined,
257
- context: batch.context ? {
258
- bufferPath: batch.context.bufferPath,
259
- totalEntries: batch.context.totalEntries,
260
- totalOps: batch.context.totalOps,
261
- chunkIndex: batch.context.chunkIndex,
262
- chunkTotal: batch.context.chunkTotal,
263
- totalApplyOps: batch.context.totalApplyOps,
264
- } : undefined,
265
- };
266
- }
267
-
268
- function compactManualApplyCandidates(candidates) {
269
- return (Array.isArray(candidates) ? candidates : [])
270
- .slice(0, 24)
271
- .map((candidate) => ({
272
- entryId: candidate.entryId,
273
- ref: candidate.ref,
274
- sourceHint: compactManualApplySourceMatch(candidate.sourceHint),
275
- textMatches: compactManualApplySourceMatches(candidate.textMatches, 8),
276
- objectKeyMatches: compactManualApplySourceMatches(candidate.objectKeyMatches, 8),
277
- contextTextMatches: compactManualApplySourceMatches(candidate.contextTextMatches, 8),
278
- locatorMatches: compactManualApplySourceMatches(candidate.locatorMatches, 6),
279
- }));
280
- }
281
-
282
- function compactManualApplySourceMatches(matches, limit) {
283
- return (Array.isArray(matches) ? matches : [])
284
- .slice(0, limit)
285
- .map(compactManualApplySourceMatch)
286
- .filter(Boolean);
287
- }
288
-
289
- function compactManualApplySourceMatch(match) {
290
- if (!match || typeof match !== 'object') return null;
291
- const file = match.relativeFile || match.file;
292
- if (!file && !match.line) return null;
293
- return {
294
- file: summarizeManualLogFile(file),
295
- line: match.line || null,
296
- column: match.column || null,
297
- reason: match.reason || match.kind || undefined,
298
- status: match.status || undefined,
299
- };
300
- }
301
-
302
- function compactManualApplyEntry(entry = {}) {
303
- return {
304
- id: entry.id,
305
- pageUrl: entry.pageUrl,
306
- stagedAt: entry.stagedAt || null,
307
- element: compactManualApplyContext(entry.element),
308
- ops: (entry.ops || []).map(compactManualApplyOp),
309
- };
310
- }
311
-
312
- function compactManualApplyOp(op = {}) {
313
- return {
314
- entryId: op.entryId,
315
- ref: op.ref,
316
- contextRef: op.contextRef,
317
- tag: op.tag,
318
- elementId: op.elementId,
319
- classes: Array.isArray(op.classes) ? op.classes : [],
320
- originalText: op.originalText,
321
- newText: op.newText,
322
- deleted: op.deleted === true || undefined,
323
- sourceHint: op.sourceHint || null,
324
- leaf: compactManualApplyContext(op.leaf),
325
- nearbyEditableTexts: compactNearbyManualEditTexts(op.nearbyEditableTexts),
326
- container: compactManualApplyContext(op.container),
327
- contextHints: Array.isArray(op.contextHints) ? op.contextHints.slice(0, 8) : undefined,
328
- };
329
- }
330
-
331
- function compactManualApplyContext(value) {
332
- if (!value || typeof value !== 'object') return null;
333
- return {
334
- ref: value.ref,
335
- tagName: value.tagName || value.tag || null,
336
- id: value.id || null,
337
- classes: Array.isArray(value.classes) ? value.classes : [],
338
- textContent: truncateManualApplyText(value.textContent, MANUAL_APPLY_COMPACT_TEXT_LIMIT),
339
- };
340
- }
341
-
342
- function compactNearbyManualEditTexts(items) {
343
- return (Array.isArray(items) ? items : [])
344
- .slice(0, MANUAL_APPLY_COMPACT_NEARBY_LIMIT)
345
- .map((item) => typeof item === 'string' ? { text: truncateManualApplyText(item, MANUAL_APPLY_COMPACT_TEXT_LIMIT) } : {
346
- ref: item?.ref,
347
- tag: item?.tag,
348
- classes: Array.isArray(item?.classes) ? item.classes : [],
349
- text: truncateManualApplyText(item?.text, MANUAL_APPLY_COMPACT_TEXT_LIMIT),
350
- });
351
- }
352
-
353
- function truncateManualApplyText(value, max) {
354
- if (typeof value !== 'string') return value || null;
355
- return value.length > max ? value.slice(0, max) : value;
356
- }
357
-
358
- async function pushApplyBatchInChunksAndWait(batch, pageUrl, context = {}) {
359
- const repair = context?.repair || batch?.repair || null;
360
- if (repair) return pushApplyEventAndWait(batch, pageUrl, null, repair);
361
- const chunks = splitManualApplyBatch(batch, manualEditApplyChunkSize());
362
- if (chunks.length <= 1) return pushApplyEventAndWait(batch, pageUrl);
363
-
364
- const expectedOpsByEntry = new Map();
365
- for (const entry of batch?.entries || []) {
366
- expectedOpsByEntry.set(entry.id, Array.isArray(entry.ops) ? entry.ops.length : 0);
367
- }
368
-
369
- const appliedOpsByEntry = new Map();
370
- const failedByEntry = new Map();
371
- const files = new Set();
372
- const notes = [];
373
- let aborted = false;
374
-
375
- for (const chunk of chunks) {
376
- if (aborted) {
377
- markChunkEntriesFailed(failedByEntry, chunk, 'manual_edit_chunk_aborted');
378
- continue;
379
- }
380
-
381
- let result;
382
- try {
383
- result = normalizeApplyChunkResult(await pushApplyEventAndWait(chunk.batch, pageUrl, chunk.meta));
384
- } catch (err) {
385
- markChunkEntriesFailed(failedByEntry, chunk, err.message || 'chat_agent_error');
386
- aborted = true;
387
- continue;
388
- }
389
-
390
- for (const file of result.files) files.add(file);
391
- notes.push(...result.notes);
392
-
393
- const chunkFailedIds = new Set();
394
- for (const item of result.failed) {
395
- const entryId = item.entryId || item.id;
396
- if (!entryId) continue;
397
- chunkFailedIds.add(entryId);
398
- if (!failedByEntry.has(entryId)) {
399
- failedByEntry.set(entryId, {
400
- entryId,
401
- reason: item.reason || item.message || 'failed',
402
- candidates: Array.isArray(item.candidates) ? item.candidates : [],
403
- });
404
- }
405
- }
406
-
407
- if (result.status === 'error') {
408
- markChunkEntriesFailed(failedByEntry, chunk, result.message || firstFailureReason(result) || 'chat_agent_error');
409
- aborted = true;
410
- continue;
411
- }
412
-
413
- const reportedAppliedIds = new Set(result.appliedEntryIds);
414
- for (const entryId of reportedAppliedIds) {
415
- if (!chunk.entryIds.has(entryId) || chunkFailedIds.has(entryId)) continue;
416
- appliedOpsByEntry.set(entryId, (appliedOpsByEntry.get(entryId) || 0) + (chunk.opCountsByEntry.get(entryId) || 0));
417
- }
418
-
419
- for (const entryId of chunk.entryIds) {
420
- if (reportedAppliedIds.has(entryId) || chunkFailedIds.has(entryId)) continue;
421
- if (!failedByEntry.has(entryId)) {
422
- failedByEntry.set(entryId, { entryId, reason: 'not_reported_applied', candidates: [] });
423
- }
424
- }
425
- }
426
-
427
- const appliedEntryIds = [];
428
- for (const [entryId, expectedOps] of expectedOpsByEntry.entries()) {
429
- if (failedByEntry.has(entryId)) continue;
430
- if ((appliedOpsByEntry.get(entryId) || 0) === expectedOps && expectedOps > 0) {
431
- appliedEntryIds.push(entryId);
432
- } else if (!failedByEntry.has(entryId)) {
433
- failedByEntry.set(entryId, { entryId, reason: 'not_reported_applied', candidates: [] });
434
- }
435
- }
436
-
437
- const failed = [...failedByEntry.values()];
438
- return {
439
- status: failed.length === 0 ? 'done' : appliedEntryIds.length > 0 ? 'partial' : 'error',
440
- appliedEntryIds,
441
- failed,
442
- files: [...files],
443
- notes,
444
- };
445
- }
446
-
447
- function normalizeApplyChunkResult(result) {
448
- const status = result?.status === 'partial' ? 'partial' : result?.status === 'error' ? 'error' : 'done';
449
- return {
450
- status,
451
- message: typeof result?.message === 'string' ? result.message : null,
452
- appliedEntryIds: Array.isArray(result?.appliedEntryIds) ? result.appliedEntryIds.filter((id) => typeof id === 'string') : [],
453
- failed: Array.isArray(result?.failed) ? result.failed.filter(Boolean) : [],
454
- files: Array.isArray(result?.files) ? result.files.filter((file) => typeof file === 'string') : [],
455
- notes: Array.isArray(result?.notes) ? result.notes.filter((note) => typeof note === 'string') : [],
456
- };
457
- }
458
-
459
- function manualApplyResultShapeHint(eventId = 'EVENT_ID') {
460
- return `Use live-poll.mjs --reply ${eventId} done --data '{"status":"done","appliedEntryIds":["ENTRY_ID"],"failed":[],"files":["src/page.html"],"notes":[]}'`;
461
- }
462
-
463
- function invalidManualApplyResult(reason, eventId, extra = {}) {
464
- return {
465
- ok: false,
466
- body: {
467
- error: 'invalid_manual_apply_result',
468
- reason,
469
- hint: manualApplyResultShapeHint(eventId),
470
- ...extra,
471
- },
472
- };
473
- }
474
-
475
- function validateManualApplyResultMessage(msg, deferred) {
476
- let data = msg?.data;
477
- const eventId = msg?.id || deferred?.event?.id || 'EVENT_ID';
478
- if (!data || typeof data !== 'object' || Array.isArray(data)) {
479
- return invalidManualApplyResult('missing_result_data', eventId);
480
- }
481
- if ('entries' in data || 'ops' in data) {
482
- return invalidManualApplyResult('summary_result_not_allowed', eventId);
483
- }
484
- if (!['done', 'partial', 'error'].includes(data.status)) {
485
- return invalidManualApplyResult('invalid_status', eventId, { status: data.status ?? null });
486
- }
487
-
488
- for (const key of ['appliedEntryIds', 'failed', 'files', 'notes']) {
489
- if (!Array.isArray(data[key])) {
490
- return invalidManualApplyResult(`${key}_must_be_array`, eventId);
491
- }
492
- }
493
-
494
- for (const [index, value] of data.appliedEntryIds.entries()) {
495
- if (typeof value !== 'string' || !value) {
496
- return invalidManualApplyResult('appliedEntryIds_must_contain_strings', eventId, { index });
497
- }
498
- }
499
- for (const [index, value] of data.files.entries()) {
500
- if (typeof value !== 'string' || !value) {
501
- return invalidManualApplyResult('files_must_contain_strings', eventId, { index });
502
- }
503
- }
504
- for (const [index, value] of data.notes.entries()) {
505
- if (typeof value !== 'string') {
506
- return invalidManualApplyResult('notes_must_contain_strings', eventId, { index });
507
- }
508
- }
509
- for (const [index, item] of data.failed.entries()) {
510
- if (!item || typeof item !== 'object' || Array.isArray(item)) {
511
- return invalidManualApplyResult('failed_must_contain_objects', eventId, { index });
512
- }
513
- if (typeof item.entryId !== 'string' || !item.entryId) {
514
- return invalidManualApplyResult('failed_entryId_required', eventId, { index });
515
- }
516
- if (typeof item.reason !== 'string' || !item.reason) {
517
- return invalidManualApplyResult('failed_reason_required', eventId, { index });
518
- }
519
- }
520
-
521
- const eventEntryIds = new Set((deferred?.batch?.entries || []).map((entry) => entry.id).filter(Boolean));
522
- for (const entryId of data.appliedEntryIds) {
523
- if (eventEntryIds.size > 0 && !eventEntryIds.has(entryId)) {
524
- return invalidManualApplyResult('applied_entry_id_not_in_event', eventId, { entryId });
525
- }
526
- }
527
- for (const item of data.failed) {
528
- if (eventEntryIds.size > 0 && !eventEntryIds.has(item.entryId)) {
529
- return invalidManualApplyResult('failed_entry_id_not_in_event', eventId, { entryId: item.entryId });
530
- }
531
- }
532
-
533
- if (data.status === 'done') {
534
- if (data.failed.length > 0) {
535
- return invalidManualApplyResult('done_result_has_failed_entries', eventId);
536
- }
537
- if (countManualApplyOps(deferred?.batch) > 0 && data.appliedEntryIds.length === 0) {
538
- return invalidManualApplyResult('done_result_missing_applied_entry_ids', eventId);
539
- }
540
- }
541
- if (data.status === 'partial' && data.appliedEntryIds.length === 0 && data.failed.length === 0) {
542
- return invalidManualApplyResult('partial_result_has_no_entries', eventId);
543
- }
544
- if (data.status === 'error' && data.appliedEntryIds.length > 0) {
545
- return invalidManualApplyResult('error_result_has_applied_entries', eventId);
546
- }
547
-
548
- return {
549
- ok: true,
550
- result: {
551
- status: data.status,
552
- message: typeof data.message === 'string' ? data.message : undefined,
553
- appliedEntryIds: data.appliedEntryIds,
554
- failed: data.failed,
555
- files: data.files,
556
- notes: data.notes,
557
- },
558
- };
559
- }
560
-
561
- function firstFailureReason(result) {
562
- const first = Array.isArray(result?.failed) ? result.failed.find(Boolean) : null;
563
- return first?.reason || first?.message || null;
564
- }
565
-
566
- function markChunkEntriesFailed(failedByEntry, chunk, reason) {
567
- for (const entryId of chunk.entryIds) {
568
- if (failedByEntry.has(entryId)) continue;
569
- failedByEntry.set(entryId, { entryId, reason, candidates: [] });
570
- }
571
- }
572
-
573
- function splitManualApplyBatch(batch, maxOps) {
574
- const totalOpCount = countManualApplyOps(batch);
575
- if (totalOpCount <= maxOps) {
576
- return [{
577
- batch,
578
- meta: null,
579
- entryIds: new Set((batch?.entries || []).map((entry) => entry.id).filter(Boolean)),
580
- opCountsByEntry: new Map((batch?.entries || []).map((entry) => [entry.id, Array.isArray(entry.ops) ? entry.ops.length : 0])),
581
- }];
582
- }
583
-
584
- const rawChunks = [];
585
- let current = createManualApplyChunkBuilder();
586
- for (const entry of batch?.entries || []) {
587
- const ops = entry.ops || [];
588
- if (ops.length <= maxOps) {
589
- if (current.opCount > 0 && current.opCount + ops.length > maxOps) {
590
- rawChunks.push(current);
591
- current = createManualApplyChunkBuilder();
592
- }
593
- for (const op of ops) addOpToManualApplyChunk(current, entry, op);
594
- continue;
595
- }
596
- if (current.opCount > 0) {
597
- rawChunks.push(current);
598
- current = createManualApplyChunkBuilder();
599
- }
600
- for (const op of ops) {
601
- if (current.opCount >= maxOps) {
602
- rawChunks.push(current);
603
- current = createManualApplyChunkBuilder();
604
- }
605
- addOpToManualApplyChunk(current, entry, op);
606
- }
607
- }
608
- if (current.opCount > 0) rawChunks.push(current);
609
-
610
- return rawChunks.map((chunk, index) => ({
611
- batch: {
612
- ...batch,
613
- count: chunk.opCount,
614
- entries: chunk.entries,
615
- ops: chunk.ops,
616
- candidates: filterManualApplyChunkCandidates(batch, chunk.refsByEntry),
617
- context: {
618
- ...(batch?.context || {}),
619
- totalEntries: chunk.entries.length,
620
- totalOps: chunk.opCount,
621
- chunkIndex: index + 1,
622
- chunkTotal: rawChunks.length,
623
- totalApplyOps: totalOpCount,
624
- },
625
- },
626
- meta: {
627
- index: index + 1,
628
- total: rawChunks.length,
629
- opCount: chunk.opCount,
630
- totalOpCount,
631
- },
632
- entryIds: new Set(chunk.entries.map((entry) => entry.id).filter(Boolean)),
633
- opCountsByEntry: chunk.opCountsByEntry,
634
- }));
635
- }
636
-
637
- function createManualApplyChunkBuilder() {
638
- return {
639
- entries: [],
640
- entryById: new Map(),
641
- entryIds: new Set(),
642
- ops: [],
643
- refsByEntry: new Map(),
644
- opCountsByEntry: new Map(),
645
- opCount: 0,
646
- };
647
- }
648
-
649
- function addOpToManualApplyChunk(chunk, entry, op) {
650
- let chunkEntry = chunk.entryById.get(entry.id);
651
- if (!chunkEntry) {
652
- chunkEntry = { ...entry, ops: [] };
653
- chunk.entryById.set(entry.id, chunkEntry);
654
- chunk.entryIds.add(entry.id);
655
- chunk.entries.push(chunkEntry);
656
- }
657
- chunkEntry.ops.push(op);
658
- chunk.ops.push({ ...op, entryId: op.entryId || entry.id });
659
- if (!chunk.refsByEntry.has(entry.id)) chunk.refsByEntry.set(entry.id, new Set());
660
- if (op.ref) chunk.refsByEntry.get(entry.id).add(op.ref);
661
- chunk.opCountsByEntry.set(entry.id, (chunk.opCountsByEntry.get(entry.id) || 0) + 1);
662
- chunk.opCount += 1;
663
- }
664
-
665
- function filterManualApplyChunkCandidates(batch, refsByEntry) {
666
- return (batch?.candidates || []).filter((candidate) => {
667
- const refs = refsByEntry.get(candidate.entryId);
668
- if (!refs) return false;
669
- if (!candidate.ref) return true;
670
- return refs.has(candidate.ref);
671
- });
672
- }
673
-
674
- function resolveApplyDeferred(eventId, body) {
675
- const deferred = state.pendingApplyDeferreds.get(eventId);
676
- if (!deferred) return false;
677
- state.pendingApplyDeferreds.delete(eventId);
678
- clearTimeout(deferred.timer);
679
- removeManualApplyEvidence(deferred.event?.evidencePath);
680
- deferred.resolve(body);
681
- return true;
682
- }
683
-
684
- function rejectApplyDeferred(eventId, reason) {
685
- const deferred = state.pendingApplyDeferreds.get(eventId);
686
- if (!deferred) return false;
687
- state.pendingApplyDeferreds.delete(eventId);
688
- clearTimeout(deferred.timer);
689
- removeManualApplyEvidence(deferred.event?.evidencePath);
690
- deferred.reject(new Error(reason || 'chat_agent_error'));
691
- return true;
692
- }
693
-
694
- function snapshotApplyEventFiles(batch) {
695
- const snapshot = new Map();
696
- for (const relativeFile of collectManualApplyFiles(batch)) {
697
- const absolute = path.resolve(process.cwd(), relativeFile);
698
- try {
699
- snapshot.set(relativeFile, {
700
- exists: fs.existsSync(absolute),
701
- content: fs.existsSync(absolute) ? fs.readFileSync(absolute, 'utf-8') : '',
702
- });
703
- } catch {
704
- // If a file cannot be read before dispatch, do not attempt late rollback.
705
- }
706
- }
707
- return snapshot;
708
- }
709
-
710
- function manualApplyTransactionPath(cwd = process.cwd()) {
711
- return path.join(getLiveDir(cwd), 'manual-edit-apply-transaction.json');
712
- }
713
-
714
- function readManualApplyTransaction(cwd = process.cwd()) {
715
- const file = manualApplyTransactionPath(cwd);
716
- if (!fs.existsSync(file)) return null;
717
- try {
718
- return JSON.parse(fs.readFileSync(file, 'utf-8'));
719
- } catch {
720
- return null;
721
- }
722
- }
723
-
724
- function writeManualApplyTransaction({ cwd = process.cwd(), pageUrl = null, batch }) {
725
- const file = manualApplyTransactionPath(cwd);
726
- const files = collectManualApplyFiles(batch);
727
- const transaction = {
728
- version: 1,
729
- id: randomUUID().replace(/-/g, '').slice(0, 8),
730
- createdAt: new Date().toISOString(),
731
- pageUrl,
732
- entryIds: (batch?.entries || []).map((entry) => entry.id).filter(Boolean),
733
- files: files.map((relativeFile) => {
734
- const absolute = path.resolve(cwd, relativeFile);
735
- const exists = fs.existsSync(absolute);
736
- return {
737
- file: relativeFile,
738
- exists,
739
- content: exists ? fs.readFileSync(absolute, 'utf-8') : '',
740
- };
741
- }),
742
- };
743
- fs.mkdirSync(path.dirname(file), { recursive: true });
744
- fs.writeFileSync(`${file}.tmp`, JSON.stringify(transaction, null, 2) + '\n', 'utf-8');
745
- fs.renameSync(`${file}.tmp`, file);
746
- return transaction;
747
- }
748
-
749
- function clearManualApplyTransaction(cwd = process.cwd(), transactionId = null) {
750
- const file = manualApplyTransactionPath(cwd);
751
- if (!fs.existsSync(file)) return false;
752
- if (transactionId) {
753
- const existing = readManualApplyTransaction(cwd);
754
- if (existing?.id && existing.id !== transactionId) return false;
755
- }
756
- try {
757
- fs.unlinkSync(file);
758
- return true;
759
- } catch {
760
- return false;
761
- }
762
- }
763
-
764
- function rollbackManualApplyTransaction({ cwd = process.cwd(), pageUrl = null, reason = 'manual_edit_transaction_rollback' } = {}) {
765
- const transaction = readManualApplyTransaction(cwd);
766
- if (!transaction) return null;
767
- if (pageUrl && transaction.pageUrl && transaction.pageUrl !== pageUrl) return null;
768
-
769
- let pendingIds = new Set();
770
- try {
771
- const buffer = readManualEditsBuffer(cwd);
772
- pendingIds = new Set((buffer.entries || []).map((entry) => entry.id).filter(Boolean));
773
- } catch {
774
- pendingIds = new Set(transaction.entryIds || []);
775
- }
776
- const shouldRollback = (transaction.entryIds || []).some((id) => pendingIds.has(id));
777
- if (!shouldRollback) {
778
- clearManualApplyTransaction(cwd, transaction.id);
779
- return { id: transaction.id, reason, rolledBackFiles: [], rollbackFailures: [], skipped: 'entries_not_pending' };
780
- }
781
-
782
- const rolledBackFiles = [];
783
- const rollbackFailures = [];
784
- for (const item of transaction.files || []) {
785
- const relativeFile = normalizeProjectFile(item.file);
786
- if (!relativeFile) continue;
787
- const absolute = path.resolve(cwd, relativeFile);
788
- try {
789
- if (item.exists) {
790
- fs.mkdirSync(path.dirname(absolute), { recursive: true });
791
- fs.writeFileSync(absolute, item.content || '', 'utf-8');
792
- } else if (fs.existsSync(absolute)) {
793
- fs.rmSync(absolute);
794
- }
795
- rolledBackFiles.push(relativeFile);
796
- } catch (err) {
797
- rollbackFailures.push({ file: relativeFile, reason: 'restore_failed', message: err.message || String(err) });
798
- }
799
- }
800
- clearManualApplyTransaction(cwd, transaction.id);
801
- recordManualEditActivity('manual_edit_transaction_rolled_back', {
802
- id: transaction.id,
803
- pageUrl: transaction.pageUrl || null,
804
- reason,
805
- entryIds: transaction.entryIds || [],
806
- rolledBackFiles: rolledBackFiles.map(summarizeManualLogFile).filter(Boolean),
807
- rollbackFailures: summarizeManualDiagnostics(rollbackFailures),
808
- });
809
- return { id: transaction.id, reason, rolledBackFiles, rollbackFailures };
810
- }
811
-
812
- function collectManualApplyFiles(batch, extraFiles = []) {
813
- const files = [];
814
- for (const entry of batch?.entries || []) {
815
- for (const op of entry.ops || []) files.push(op.sourceHint?.file);
816
- }
817
- for (const candidate of batch?.candidates || []) {
818
- files.push(candidate.sourceHint?.relativeFile, candidate.sourceHint?.file);
819
- for (const item of candidate.textMatches || []) files.push(item.file);
820
- for (const item of candidate.objectKeyMatches || []) files.push(item.file);
821
- for (const item of candidate.locatorMatches || []) files.push(item.file);
822
- for (const item of candidate.contextTextMatches || []) files.push(item.file);
823
- }
824
- files.push(...(extraFiles || []));
825
- return [...new Set(files)]
826
- .map((file) => normalizeProjectFile(file))
827
- .filter(Boolean);
828
- }
829
-
830
- function normalizeProjectFile(file) {
831
- if (!file || typeof file !== 'string') return null;
832
- const absolute = path.isAbsolute(file) ? file : path.resolve(process.cwd(), file);
833
- const relative = path.relative(process.cwd(), absolute);
834
- if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) return null;
835
- return relative;
836
- }
837
-
838
- function rollbackApplySnapshot(batch, rollbackSnapshot, extraFiles = [], reason = 'manual_edit_apply_snapshot_rollback') {
839
- const scope = collectManualApplyFiles(batch, extraFiles);
840
- const rolledBackFiles = [];
841
- const rollbackFailures = [];
842
- for (const relativeFile of scope) {
843
- const before = rollbackSnapshot?.get(relativeFile);
844
- if (!before) continue;
845
- const absolute = path.resolve(process.cwd(), relativeFile);
846
- try {
847
- if (before.exists) {
848
- fs.mkdirSync(path.dirname(absolute), { recursive: true });
849
- fs.writeFileSync(absolute, before.content, 'utf-8');
850
- } else if (fs.existsSync(absolute)) {
851
- fs.rmSync(absolute);
852
- }
853
- rolledBackFiles.push(relativeFile);
854
- } catch (err) {
855
- rollbackFailures.push({ file: relativeFile, reason: 'restore_failed', message: err.message || String(err) });
856
- }
857
- }
858
- return { rolledBackFiles, rollbackFailures };
859
- }
860
-
861
- function rollbackTimedOutApplyReply(msg) {
862
- const details = state.timedOutApplyIds.get(msg.id);
863
- if (!details) return { rolledBackFiles: [], rollbackFailures: [] };
864
- state.timedOutApplyIds.delete(msg.id);
865
- return rollbackApplySnapshot(details.batch, details.rollbackSnapshot, msg.data?.files || [], 'stale_manual_edit_apply_reply');
866
- }
867
-
868
141
  // Cap per-annotation upload size. A full 1920×1080 PNG is typically <1 MB;
869
142
  // cap at 10 MB to guard against runaway writes from a misbehaving client.
870
143
  const MAX_ANNOTATION_BYTES = 10 * 1024 * 1024;
@@ -897,6 +170,8 @@ function leaseEvent(entry, leaseMs) {
897
170
  return entry.event;
898
171
  }
899
172
  entry.leaseUntil = Date.now() + leaseMs;
173
+ scheduleLeaseFlush();
174
+ broadcastAgentPollingIfChanged();
900
175
  return entry.event;
901
176
  }
902
177
 
@@ -907,33 +182,14 @@ function acknowledgePendingEvent(id) {
907
182
  const acknowledged = state.pendingEvents[idx].event;
908
183
  state.pendingEvents.splice(idx, 1);
909
184
  scheduleLeaseFlush();
185
+ broadcastAgentPollingIfChanged();
910
186
  return acknowledged;
911
187
  }
912
188
 
913
- function manualApplyReplyCommand(eventOrId = 'EVENT_ID') {
914
- const id = typeof eventOrId === 'string' ? eventOrId : eventOrId?.id || 'EVENT_ID';
915
- return `live-poll.mjs --reply ${id} done --data '<json>'`;
916
- }
917
-
918
- function buildManualApplyAgentAction(eventOrId = 'EVENT_ID') {
919
- return {
920
- kind: 'manual_edit_apply',
921
- required: 'apply_source_edits_then_reply',
922
- replyCommand: manualApplyReplyCommand(eventOrId),
923
- warning: 'Polling only leases this work item; it does not commit source edits.',
924
- };
925
- }
926
-
927
- function summarizeManualApplyEvent(event = {}, batch = event.batch) {
928
- const entries = Array.isArray(batch?.entries) ? batch.entries : [];
929
- const opCount = entries.reduce((sum, entry) => sum + (Array.isArray(entry.ops) ? entry.ops.length : 0), 0);
930
- return {
931
- pageUrl: event.pageUrl || null,
932
- chunk: event.chunk || null,
933
- entryCount: entries.length,
934
- opCount,
935
- files: collectManualApplyFiles(batch),
936
- };
189
+ function findPendingEventById(id) {
190
+ if (!id) return null;
191
+ const entry = state.pendingEvents.find((item) => item.event?.id === id);
192
+ return entry?.event || null;
937
193
  }
938
194
 
939
195
  function summarizePendingEventForStatus(entry) {
@@ -949,51 +205,46 @@ function summarizePendingEventForStatus(entry) {
949
205
  summary.chunk = event.chunk || null;
950
206
  summary.repair = event.repair || null;
951
207
  summary.evidencePath = event.evidencePath || null;
952
- summary.agentAction = event.agentAction || buildManualApplyAgentAction(event);
953
- summary.manualApplySummary = summarizeManualApplyEvent(event, state.pendingApplyDeferreds.get(event.id)?.batch || event.batch);
208
+ summary.agentAction = event.agentAction || manualApply.buildAgentAction(event);
209
+ summary.manualApplySummary = manualApply.summarizeEvent(event, manualApply.getDeferred(event.id)?.batch || event.batch);
954
210
  }
955
211
  return summary;
956
212
  }
957
213
 
958
- function cancelPendingManualApplyEvents(pageUrl, reason = 'manual_edit_discarded') {
959
- const canceledById = new Map();
960
- const shouldCancel = (event) => event?.type === 'manual_edit_apply' && (!pageUrl || event.pageUrl === pageUrl);
214
+ function summarizeActiveSessionForClient(snapshot = {}) {
215
+ return {
216
+ id: snapshot.id,
217
+ phase: snapshot.phase,
218
+ pageUrl: snapshot.pageUrl ?? null,
219
+ sourceFile: snapshot.sourceFile ?? null,
220
+ previewFile: snapshot.previewFile ?? null,
221
+ previewMode: snapshot.previewMode ?? null,
222
+ expectedVariants: snapshot.expectedVariants ?? 0,
223
+ arrivedVariants: snapshot.arrivedVariants ?? 0,
224
+ visibleVariant: snapshot.visibleVariant ?? null,
225
+ checkpointRevision: snapshot.checkpointRevision ?? 0,
226
+ paramValues: snapshot.paramValues || {},
227
+ };
228
+ }
961
229
 
230
+ function activeSessionSummaries() {
231
+ if (!state.sessionStore) return [];
232
+ return state.sessionStore.listActiveSessions().map((snapshot) => summarizeActiveSessionForClient(snapshot));
233
+ }
234
+
235
+ function cancelQueuedAnonymousExitEvents() {
236
+ let removed = 0;
962
237
  for (let i = state.pendingEvents.length - 1; i >= 0; i -= 1) {
963
238
  const event = state.pendingEvents[i]?.event;
964
- if (!shouldCancel(event)) continue;
239
+ if (event?.type !== 'exit' || event.id) continue;
965
240
  state.pendingEvents.splice(i, 1);
966
- removeManualApplyEvidence(event.evidencePath);
967
- canceledById.set(event.id, {
968
- id: event.id,
969
- pageUrl: event.pageUrl,
970
- entryCount: event.batch?.entries?.length || 0,
971
- });
241
+ removed += 1;
972
242
  }
973
-
974
- for (const [eventId, deferred] of [...state.pendingApplyDeferreds.entries()]) {
975
- if (!shouldCancel(deferred.event)) continue;
976
- state.pendingApplyDeferreds.delete(eventId);
977
- clearTimeout(deferred.timer);
978
- const rollback = rollbackApplySnapshot(deferred.batch, deferred.rollbackSnapshot, [], reason);
979
- tombstoneTimedOutApplyId(eventId, {
980
- batch: deferred.batch,
981
- rollbackSnapshot: deferred.rollbackSnapshot,
982
- reason,
983
- });
984
- removeManualApplyEvidence(deferred.event?.evidencePath);
985
- canceledById.set(eventId, {
986
- id: eventId,
987
- pageUrl: deferred.pageUrl,
988
- entryCount: deferred.batch?.entries?.length || 0,
989
- rolledBackFiles: rollback.rolledBackFiles,
990
- rollbackFailures: rollback.rollbackFailures,
991
- });
992
- deferred.reject(new Error(reason));
243
+ if (removed > 0) {
244
+ scheduleLeaseFlush();
245
+ broadcastAgentPollingIfChanged();
993
246
  }
994
-
995
- if (canceledById.size > 0) flushPendingPolls();
996
- return [...canceledById.values()];
247
+ return removed;
997
248
  }
998
249
 
999
250
  function scheduleLeaseFlush() {
@@ -1001,7 +252,6 @@ function scheduleLeaseFlush() {
1001
252
  clearTimeout(state.leaseTimer);
1002
253
  state.leaseTimer = null;
1003
254
  }
1004
- if (state.pendingPolls.length === 0) return;
1005
255
  const now = Date.now();
1006
256
  const nextLeaseUntil = state.pendingEvents
1007
257
  .map((entry) => entry.leaseUntil || 0)
@@ -1011,7 +261,8 @@ function scheduleLeaseFlush() {
1011
261
  state.leaseTimer = setTimeout(() => {
1012
262
  state.leaseTimer = null;
1013
263
  flushPendingPolls();
1014
- }, Math.max(0, nextLeaseUntil - now));
264
+ broadcastAgentPollingIfChanged();
265
+ }, Math.max(0, nextLeaseUntil - now + POLL_LEASE_EXPIRY_TIMER_GRACE_MS));
1015
266
  }
1016
267
 
1017
268
  function flushPendingPolls() {
@@ -1032,7 +283,9 @@ function flushPendingPolls() {
1032
283
  }
1033
284
 
1034
285
  function agentPollingConnected() {
1035
- return state.pendingPolls.length > 0;
286
+ const now = Date.now();
287
+ return state.pendingPolls.length > 0
288
+ || state.pendingEvents.some((entry) => entry.leaseUntil && entry.leaseUntil > now);
1036
289
  }
1037
290
 
1038
291
  function broadcastAgentPollingIfChanged() {
@@ -1085,61 +338,6 @@ function getManualEditStatus() {
1085
338
  }
1086
339
  }
1087
340
 
1088
- function summarizePendingManualEditBatch(pageUrl = null) {
1089
- try {
1090
- const buffer = readManualEditsBuffer(process.cwd());
1091
- const entries = (buffer.entries || [])
1092
- .filter((entry) => !pageUrl || entry.pageUrl === pageUrl);
1093
- return {
1094
- pendingEntryCount: entries.length,
1095
- pendingOpCount: entries.reduce((sum, entry) => sum + (entry.ops?.length || 0), 0),
1096
- };
1097
- } catch (err) {
1098
- return { pendingSummaryError: err.message || String(err) };
1099
- }
1100
- }
1101
-
1102
- function summarizeManualApplyFailures(failed) {
1103
- if (!Array.isArray(failed)) return [];
1104
- return failed.slice(0, 20).map((item) => ({
1105
- id: item.id || item.entryId || null,
1106
- reason: item.reason || item.message || 'failed',
1107
- message: compactManualLogText(item.message, 300),
1108
- files: Array.isArray(item.files) ? item.files.slice(0, 12).map(summarizeManualLogFile).filter(Boolean) : undefined,
1109
- checks: summarizeManualDiagnostics(item.checks),
1110
- failures: summarizeManualDiagnostics(item.failures),
1111
- candidates: summarizeManualDiagnostics(item.candidates),
1112
- }));
1113
- }
1114
-
1115
- function summarizeManualDiagnostics(items) {
1116
- if (!Array.isArray(items) || items.length === 0) return undefined;
1117
- return items.slice(0, 12).map((item) => ({
1118
- reason: item.reason || item.kind || undefined,
1119
- detail: compactManualLogText(item.detail, 220),
1120
- message: compactManualLogText(item.message, 300),
1121
- file: summarizeManualLogFile(item.file || item.relativeFile),
1122
- line: item.line || undefined,
1123
- ref: compactManualLogText(item.ref, 180),
1124
- marker: compactManualLogText(item.marker, 120),
1125
- files: Array.isArray(item.files) ? item.files.slice(0, 8).map(summarizeManualLogFile).filter(Boolean) : undefined,
1126
- }));
1127
- }
1128
-
1129
- function summarizeManualLogFile(file) {
1130
- if (!file || typeof file !== 'string') return undefined;
1131
- if (!path.isAbsolute(file)) return file;
1132
- const relative = path.relative(process.cwd(), file);
1133
- return relative && !relative.startsWith('..') && !path.isAbsolute(relative) ? relative : file;
1134
- }
1135
-
1136
- function compactManualLogText(value, max = 200) {
1137
- if (typeof value !== 'string') return undefined;
1138
- const normalized = value.replace(/\s+/g, ' ').trim();
1139
- if (normalized.length <= max) return normalized;
1140
- return normalized.slice(0, max) + `... [truncated ${normalized.length - max} chars]`;
1141
- }
1142
-
1143
341
  // ---------------------------------------------------------------------------
1144
342
  // Load scripts
1145
343
  // ---------------------------------------------------------------------------
@@ -1159,29 +357,25 @@ function loadBrowserScripts() {
1159
357
  try { detectScript = fs.readFileSync(p, 'utf-8'); break; } catch { /* try next */ }
1160
358
  }
1161
359
 
1162
- // live-browser.js: DO NOT cache. Return the path so the /live.js handler
1163
- // can re-read on every request. Editing the browser script during iteration
1164
- // should land on the next tab reload, not require a server restart.
1165
- const sessionPath = path.join(__dirname, 'live-browser-session.js');
1166
- const livePath = path.join(__dirname, 'live-browser.js');
1167
- for (const p of [sessionPath, livePath]) {
1168
- if (!fs.existsSync(p)) {
1169
- process.stderr.write('Error: live browser script not found at ' + p + '\n');
1170
- process.exit(1);
1171
- }
360
+ // Browser script parts: DO NOT cache. Return paths so the /live.js handler
361
+ // can re-read every part on each request. Editing browser code during
362
+ // iteration should land on the next tab reload, not require a server restart.
363
+ const liveScriptParts = resolveLiveBrowserScriptParts(__dirname);
364
+ try {
365
+ assertLiveBrowserScriptParts(liveScriptParts);
366
+ } catch (err) {
367
+ process.stderr.write('Error: ' + err.message + '\n');
368
+ process.exit(1);
1172
369
  }
1173
370
 
1174
- return { detectScript, sessionPath, livePath };
371
+ return { detectScript, liveScriptParts };
1175
372
  }
1176
373
 
1177
374
  function hasProjectContext() {
1178
375
  // PRODUCT.md carries brand voice / anti-references — that's what determines
1179
376
  // whether variants are brand-aware. DESIGN.md (visual tokens) is a separate
1180
377
  // concern, surfaced by the design panel's own empty state.
1181
- try {
1182
- fs.accessSync(path.join(CONTEXT_DIR, 'PRODUCT.md'), fs.constants.R_OK);
1183
- return true;
1184
- } catch { return false; }
378
+ return !!PROJECT_CONTEXT.hasProduct;
1185
379
  }
1186
380
 
1187
381
  function statOrNull(filePath) {
@@ -1191,7 +385,7 @@ function statOrNull(filePath) {
1191
385
  // HTTP request handler
1192
386
  // ---------------------------------------------------------------------------
1193
387
 
1194
- function createRequestHandler({ detectScript, sessionPath, livePath }) {
388
+ function createRequestHandler({ detectScript, liveScriptParts }) {
1195
389
  return (req, res) => {
1196
390
  const url = new URL(req.url, `http://localhost:${state.port}`);
1197
391
  res.setHeader('Access-Control-Allow-Origin', '*');
@@ -1207,21 +401,20 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) {
1207
401
  // the next tab reload. No-store headers prevent browser caching across
1208
402
  // sessions — during iteration, a cached old script silently breaks
1209
403
  // every subsequent session.
1210
- let sessionScript;
1211
- let liveScript;
404
+ let parts;
1212
405
  try {
1213
- sessionScript = fs.readFileSync(sessionPath, 'utf-8');
1214
- liveScript = fs.readFileSync(livePath, 'utf-8');
406
+ parts = readLiveBrowserScriptParts(liveScriptParts);
1215
407
  } catch (err) {
1216
408
  res.writeHead(500, { 'Content-Type': 'text/plain' });
1217
409
  res.end('Error reading live browser scripts: ' + err.message);
1218
410
  return;
1219
411
  }
1220
- const body =
1221
- `window.__IMPECCABLE_TOKEN__ = '${state.token}';\n` +
1222
- `window.__IMPECCABLE_PORT__ = ${state.port};\n` +
1223
- sessionScript + '\n' +
1224
- liveScript;
412
+ const body = assembleLiveBrowserScript({
413
+ token: state.token,
414
+ port: state.port,
415
+ vocabulary: LIVE_COMMANDS,
416
+ parts,
417
+ });
1225
418
  res.writeHead(200, {
1226
419
  'Content-Type': 'application/javascript',
1227
420
  'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0',
@@ -1318,7 +511,7 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) {
1318
511
  if (p === '/status') {
1319
512
  const token = url.searchParams.get('token');
1320
513
  if (token !== state.token) { res.writeHead(401, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Unauthorized' })); return; }
1321
- const sessions = state.sessionStore ? state.sessionStore.listActiveSessions() : [];
514
+ const sessions = activeSessionSummaries();
1322
515
  res.writeHead(200, { 'Content-Type': 'application/json' });
1323
516
  res.end(JSON.stringify({
1324
517
  status: 'ok',
@@ -1357,8 +550,8 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) {
1357
550
  const token = url.searchParams.get('token');
1358
551
  if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
1359
552
 
1360
- const mdPath = path.join(CONTEXT_DIR, 'DESIGN.md');
1361
- const jsonPath = resolveDesignSidecarPath(process.cwd(), CONTEXT_DIR) || getDesignSidecarPath(process.cwd());
553
+ const mdPath = DESIGN_MD_PATH;
554
+ const jsonPath = resolveDesignSidecarPath(process.cwd(), PROJECT_CONTEXT.designContextDir || CONTEXT_DIR) || getDesignSidecarPath(process.cwd());
1362
555
  const mdStat = statOrNull(mdPath);
1363
556
  const jsonStat = statOrNull(jsonPath);
1364
557
 
@@ -1423,6 +616,9 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) {
1423
616
  if (p === '/events' && req.method === 'GET') {
1424
617
  const token = url.searchParams.get('token');
1425
618
  if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
619
+ clearTimeout(state.exitTimer);
620
+ state.exitTimer = null;
621
+ cancelQueuedAnonymousExitEvents();
1426
622
  res.writeHead(200, {
1427
623
  'Content-Type': 'text/event-stream',
1428
624
  'Cache-Control': 'no-cache',
@@ -1432,10 +628,10 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) {
1432
628
  type: 'connected',
1433
629
  hasProjectContext: hasProjectContext(),
1434
630
  agentPolling: agentPollingConnected(),
631
+ activeSessions: activeSessionSummaries(),
1435
632
  }) + '\n\n');
1436
633
 
1437
634
  state.sseClients.add(res);
1438
- clearTimeout(state.exitTimer);
1439
635
 
1440
636
  // Keepalive: SSE comment every 30s prevents silent connection drops.
1441
637
  const heartbeat = setInterval(() => {
@@ -1455,334 +651,7 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) {
1455
651
  return;
1456
652
  }
1457
653
 
1458
- // --- Manual copy edits: Save stages entries, Apply commits the staged
1459
- // page batch through the local AI copy-edit runner.
1460
- if (p === '/manual-edit-stash' && req.method === 'POST') {
1461
- let body = '';
1462
- req.on('data', (c) => { body += c; });
1463
- req.on('end', () => {
1464
- let msg;
1465
- try { msg = JSON.parse(body); } catch {
1466
- res.writeHead(400, { 'Content-Type': 'application/json' });
1467
- res.end(JSON.stringify({ error: 'Invalid JSON' }));
1468
- return;
1469
- }
1470
- if (msg.token !== state.token) {
1471
- res.writeHead(401, { 'Content-Type': 'application/json' });
1472
- res.end(JSON.stringify({ error: 'Unauthorized' }));
1473
- return;
1474
- }
1475
- const error = validateEvent({ ...msg, type: 'manual_edits' });
1476
- if (error) {
1477
- res.writeHead(400, { 'Content-Type': 'application/json' });
1478
- res.end(JSON.stringify({ error }));
1479
- return;
1480
- }
1481
- try {
1482
- stageManualEditEntry(process.cwd(), {
1483
- id: msg.id,
1484
- pageUrl: msg.pageUrl,
1485
- element: msg.element,
1486
- ops: msg.ops,
1487
- });
1488
- } catch (err) {
1489
- res.writeHead(500, { 'Content-Type': 'application/json' });
1490
- res.end(JSON.stringify({ error: 'stash_write_failed', message: err.message }));
1491
- return;
1492
- }
1493
- const { totalCount, perPage } = countPendingByPage(process.cwd());
1494
- const pendingCount = perPage[msg.pageUrl] || 0;
1495
- recordManualEditActivity('manual_edit_stashed', {
1496
- id: msg.id,
1497
- pageUrl: msg.pageUrl,
1498
- opCount: msg.ops.length,
1499
- pendingCount,
1500
- totalCount,
1501
- hintedFileCount: new Set((msg.ops || []).map((op) => summarizeManualLogFile(op.sourceHint?.file)).filter(Boolean)).size,
1502
- });
1503
- res.writeHead(200, { 'Content-Type': 'application/json' });
1504
- res.end(JSON.stringify({ ok: true, pendingCount, totalCount, perPage }));
1505
- });
1506
- return;
1507
- }
1508
-
1509
- // GET /manual-edit-stash?pageUrl=<url> → { count, totalCount, perPage, entries }
1510
- if (p === '/manual-edit-stash' && req.method === 'GET') {
1511
- const token = url.searchParams.get('token');
1512
- if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
1513
- const pageUrl = url.searchParams.get('pageUrl') || '';
1514
- const { totalCount, perPage } = countPendingByPage(process.cwd());
1515
- const buffer = readManualEditsBuffer(process.cwd());
1516
- const entriesForPage = pageUrl ? buffer.entries.filter((e) => e.pageUrl === pageUrl) : buffer.entries;
1517
- res.writeHead(200, { 'Content-Type': 'application/json' });
1518
- res.end(JSON.stringify({
1519
- count: pageUrl ? (perPage[pageUrl] || 0) : totalCount,
1520
- totalCount,
1521
- perPage,
1522
- entries: entriesForPage,
1523
- }));
1524
- return;
1525
- }
1526
-
1527
- // POST /manual-edit-commit?pageUrl=<url> → ask the AI to apply the staged page batch.
1528
- if (p === '/manual-edit-commit' && req.method === 'POST') {
1529
- const token = url.searchParams.get('token');
1530
- if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
1531
- const pageUrl = url.searchParams.get('pageUrl');
1532
- const asyncMode = /^(1|true|yes)$/i.test(url.searchParams.get('async') || '');
1533
- const repairOnly = /^(1|true|yes)$/i.test(url.searchParams.get('repair') || '');
1534
- const existingTransaction = readManualApplyTransaction(process.cwd());
1535
- if (repairOnly && !existingTransaction) {
1536
- res.writeHead(409, { 'Content-Type': 'application/json' });
1537
- res.end(JSON.stringify({ error: 'manual_edit_repair_transaction_missing' }));
1538
- return;
1539
- }
1540
- const recoveredTransaction = repairOnly ? null : rollbackManualApplyTransaction({
1541
- cwd: process.cwd(),
1542
- pageUrl,
1543
- reason: 'manual_edit_commit_recovered_abandoned_transaction',
1544
- });
1545
- const before = getManualEditStatus();
1546
- const pendingCount = pageUrl ? (before.perPage[pageUrl] || 0) : before.totalCount;
1547
- recordManualEditActivity('manual_edit_commit_started', {
1548
- pageUrl,
1549
- repairOnly,
1550
- pendingCount,
1551
- totalCount: before.totalCount,
1552
- recoveredTransaction: recoveredTransaction ? {
1553
- id: recoveredTransaction.id,
1554
- reason: recoveredTransaction.reason,
1555
- skipped: recoveredTransaction.skipped,
1556
- rolledBackFiles: recoveredTransaction.rolledBackFiles,
1557
- rollbackFailures: summarizeManualDiagnostics(recoveredTransaction.rollbackFailures),
1558
- } : null,
1559
- ...summarizePendingManualEditBatch(pageUrl),
1560
- });
1561
- if (asyncMode) {
1562
- res.writeHead(202, { 'Content-Type': 'application/json' });
1563
- res.end(JSON.stringify({
1564
- status: 'started',
1565
- pendingCount,
1566
- totalCount: before.totalCount,
1567
- perPage: before.perPage,
1568
- }));
1569
- }
1570
- (async () => {
1571
- let result;
1572
- let routedProvider = 'subprocess';
1573
- let transaction = null;
1574
- let commitBatch = null;
1575
- try {
1576
- if (pendingCount > 0) {
1577
- const transactionBatch = buildManualEditEvidence({ cwd: process.cwd(), pageUrl });
1578
- commitBatch = transactionBatch;
1579
- if (!repairOnly && countManualApplyOps(transactionBatch) > 0) {
1580
- transaction = writeManualApplyTransaction({
1581
- cwd: process.cwd(),
1582
- pageUrl,
1583
- batch: transactionBatch,
1584
- });
1585
- } else if (repairOnly && existingTransaction) {
1586
- transaction = existingTransaction;
1587
- }
1588
- }
1589
- const requestedMode = (process.env.IMPECCABLE_LIVE_COPY_AGENT || 'auto').trim().toLowerCase();
1590
- const useChatRoute = requestedMode === 'chat'
1591
- || (requestedMode === 'auto' && chatAgentLikelyActive());
1592
- if (useChatRoute) {
1593
- routedProvider = 'chat';
1594
- const timeoutMs = Number(process.env.IMPECCABLE_LIVE_COPY_AGENT_TIMEOUT_MS || 120000);
1595
- result = await commitManualEdits({
1596
- cwd: process.cwd(),
1597
- pageUrl,
1598
- provider: 'chat',
1599
- env: process.env,
1600
- timeoutMs,
1601
- chatAvailable: chatAgentLikelyActive,
1602
- applyBatchToSource: (batch, context) => pushApplyBatchInChunksAndWait(batch, pageUrl, context),
1603
- repairOnly,
1604
- transactionId: transaction?.id || existingTransaction?.id || null,
1605
- batch: commitBatch,
1606
- });
1607
- } else {
1608
- const timeoutMs = Number(process.env.IMPECCABLE_LIVE_COPY_AGENT_TIMEOUT_MS || 120000);
1609
- const provider = ['codex', 'claude', 'mock'].includes(requestedMode) ? requestedMode : undefined;
1610
- result = await commitManualEdits({
1611
- cwd: process.cwd(),
1612
- pageUrl,
1613
- provider,
1614
- env: process.env,
1615
- timeoutMs,
1616
- chatAvailable: chatAgentLikelyActive,
1617
- repairOnly,
1618
- transactionId: transaction?.id || existingTransaction?.id || null,
1619
- batch: commitBatch,
1620
- });
1621
- }
1622
- } catch (err) {
1623
- if (transaction) {
1624
- rollbackManualApplyTransaction({
1625
- cwd: process.cwd(),
1626
- pageUrl,
1627
- reason: 'manual_edit_commit_exception',
1628
- });
1629
- }
1630
- const message = err.stderr?.toString?.() || err.message;
1631
- recordManualEditActivity('manual_edit_commit_failed', {
1632
- pageUrl,
1633
- provider: routedProvider,
1634
- error: 'manual_edit_commit_failed',
1635
- message,
1636
- transactionId: transaction?.id || null,
1637
- });
1638
- if (!asyncMode) {
1639
- res.writeHead(500, { 'Content-Type': 'application/json' });
1640
- res.end(JSON.stringify({
1641
- error: 'manual_edit_commit_failed',
1642
- message,
1643
- }));
1644
- }
1645
- return;
1646
- } finally {
1647
- if (transaction) {
1648
- const shouldKeepTransaction = result?.needsManualDecision === true;
1649
- if (!shouldKeepTransaction) clearManualApplyTransaction(process.cwd(), transaction.id);
1650
- }
1651
- }
1652
- const { totalCount, perPage } = countPendingByPage(process.cwd());
1653
- if (result?.needsManualDecision) {
1654
- recordManualEditActivity('manual_edit_repair_needs_decision', {
1655
- pageUrl,
1656
- provider: routedProvider,
1657
- transactionId: transaction?.id || existingTransaction?.id || null,
1658
- repair: result.repair || null,
1659
- failed: summarizeManualApplyFailures(result.failed),
1660
- files: Array.isArray(result.files) ? result.files.slice(0, 20).map(summarizeManualLogFile).filter(Boolean) : [],
1661
- remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount,
1662
- totalCount,
1663
- });
1664
- } else {
1665
- recordManualEditActivity('manual_edit_commit_done', {
1666
- pageUrl,
1667
- provider: routedProvider,
1668
- reason: result.reason || null,
1669
- repair: result.repair || null,
1670
- appliedCount: Array.isArray(result.applied) ? result.applied.length : 0,
1671
- failedCount: Array.isArray(result.failed) ? result.failed.length : 0,
1672
- failed: summarizeManualApplyFailures(result.failed),
1673
- files: Array.isArray(result.files) ? result.files.slice(0, 20).map(summarizeManualLogFile).filter(Boolean) : [],
1674
- warnings: summarizeManualDiagnostics(result.warnings),
1675
- rolledBackFiles: Array.isArray(result.rolledBackFiles) ? result.rolledBackFiles.slice(0, 20).map(summarizeManualLogFile).filter(Boolean) : [],
1676
- rollbackFailures: summarizeManualDiagnostics(result.rollbackFailures),
1677
- unreportedFiles: Array.isArray(result.unreportedFiles) ? result.unreportedFiles.slice(0, 20).map(summarizeManualLogFile).filter(Boolean) : undefined,
1678
- noteCount: Array.isArray(result.notes) ? result.notes.length : 0,
1679
- cleared: result.cleared || 0,
1680
- remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount,
1681
- totalCount,
1682
- });
1683
- }
1684
- if (!asyncMode) {
1685
- res.writeHead(200, { 'Content-Type': 'application/json' });
1686
- res.end(JSON.stringify({ ...result, totalCount, perPage }));
1687
- }
1688
- })();
1689
- return;
1690
- }
1691
-
1692
- // POST /manual-edit-repair-decision → user resolves an exhausted repair loop.
1693
- if (p === '/manual-edit-repair-decision' && req.method === 'POST') {
1694
- let body = '';
1695
- req.on('data', (chunk) => { body += chunk; });
1696
- req.on('end', () => {
1697
- let payload = {};
1698
- try { payload = body ? JSON.parse(body) : {}; } catch {
1699
- res.writeHead(400, { 'Content-Type': 'application/json' });
1700
- res.end(JSON.stringify({ error: 'Invalid JSON' }));
1701
- return;
1702
- }
1703
- const token = payload.token || url.searchParams.get('token');
1704
- if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
1705
- const pageUrl = payload.pageUrl || url.searchParams.get('pageUrl') || null;
1706
- const action = String(payload.action || url.searchParams.get('action') || '').trim().toLowerCase();
1707
- if (action !== 'rollback') {
1708
- res.writeHead(400, { 'Content-Type': 'application/json' });
1709
- res.end(JSON.stringify({ error: 'unsupported_manual_edit_repair_decision', action }));
1710
- return;
1711
- }
1712
- const rollback = rollbackManualApplyTransaction({
1713
- cwd: process.cwd(),
1714
- pageUrl,
1715
- reason: 'manual_edit_user_requested_rollback',
1716
- });
1717
- const { totalCount, perPage } = countPendingByPage(process.cwd());
1718
- const response = {
1719
- action,
1720
- pageUrl,
1721
- rollback,
1722
- remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount,
1723
- totalCount,
1724
- perPage,
1725
- };
1726
- recordManualEditActivity('manual_edit_repair_rollback_done', response);
1727
- res.writeHead(200, { 'Content-Type': 'application/json' });
1728
- res.end(JSON.stringify(response));
1729
- });
1730
- return;
1731
- }
1732
-
1733
- // POST /manual-edit-discard?pageUrl=<url> → drops entries (all if no pageUrl)
1734
- if (p === '/manual-edit-discard' && req.method === 'POST') {
1735
- const token = url.searchParams.get('token');
1736
- if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
1737
- const pageUrl = url.searchParams.get('pageUrl');
1738
- let discarded;
1739
- let discardedEntries = [];
1740
- let canceledApplyEvents = [];
1741
- let transactionRollback = null;
1742
- try {
1743
- const buffer = readManualEditsBuffer(process.cwd());
1744
- transactionRollback = rollbackManualApplyTransaction({
1745
- cwd: process.cwd(),
1746
- pageUrl,
1747
- reason: 'manual_edit_discarded',
1748
- });
1749
- if (pageUrl) {
1750
- discardedEntries = buffer.entries.filter((entry) => entry.pageUrl === pageUrl);
1751
- discarded = removeManualEditEntries(process.cwd(), (entry) => entry.pageUrl === pageUrl);
1752
- } else {
1753
- discardedEntries = buffer.entries;
1754
- discarded = truncateManualEditsBuffer(process.cwd());
1755
- }
1756
- canceledApplyEvents = cancelPendingManualApplyEvents(pageUrl);
1757
- } catch (err) {
1758
- res.writeHead(500, { 'Content-Type': 'application/json' });
1759
- res.end(JSON.stringify({ error: 'discard_failed', message: err.message }));
1760
- return;
1761
- }
1762
- const { totalCount, perPage } = countPendingByPage(process.cwd());
1763
- recordManualEditActivity('manual_edit_discarded', {
1764
- pageUrl,
1765
- discarded,
1766
- canceledApplyIds: canceledApplyEvents.map((event) => event.id),
1767
- transactionRollback: transactionRollback ? {
1768
- id: transactionRollback.id,
1769
- rolledBackFiles: transactionRollback.rolledBackFiles?.map(summarizeManualLogFile).filter(Boolean) || [],
1770
- rollbackFailures: summarizeManualDiagnostics(transactionRollback.rollbackFailures),
1771
- skipped: transactionRollback.skipped,
1772
- } : undefined,
1773
- totalCount,
1774
- });
1775
- res.writeHead(200, { 'Content-Type': 'application/json' });
1776
- res.end(JSON.stringify({ discarded, entries: discardedEntries, canceledApplyEvents, totalCount, perPage }));
1777
- return;
1778
- }
1779
-
1780
- // Defense in depth: redirect any stragglers from the old /manual-edit endpoint.
1781
- if (p === '/manual-edit' && req.method === 'POST') {
1782
- res.writeHead(410, { 'Content-Type': 'application/json' });
1783
- res.end(JSON.stringify({ error: '/manual-edit is removed; use /manual-edit-stash and /manual-edit-commit for staged copy edits.' }));
1784
- return;
1785
- }
654
+ if (manualEditRoutes(req, res, url)) return;
1786
655
 
1787
656
  // --- Browser→server events (replaces WebSocket messages) ---
1788
657
  if (p === '/events' && req.method === 'POST') {
@@ -1827,6 +696,9 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) {
1827
696
  return;
1828
697
  }
1829
698
  }
699
+ if (msg.type === 'exit') {
700
+ cleanupSvelteComponentSessionsBeforeExit();
701
+ }
1830
702
  if (msg.type !== 'checkpoint') {
1831
703
  enqueueEvent(msg);
1832
704
  }
@@ -1872,16 +744,7 @@ function handlePollGet(req, res, url) {
1872
744
  return;
1873
745
  }
1874
746
  state.lastPollAt = Date.now();
1875
- // Bound the client-supplied long-poll timeout so a user-provided value cannot
1876
- // schedule an unbounded timer (resource exhaustion).
1877
747
  const timeout = parseInt(url.searchParams.get('timeout') || DEFAULT_POLL_TIMEOUT, 10);
1878
- if (!Number.isFinite(timeout) || timeout < 0 || timeout > 300000) {
1879
- // Reject out-of-range long-poll lifetimes so a user-provided value cannot
1880
- // schedule an unbounded timer (resource exhaustion).
1881
- res.writeHead(400, { 'Content-Type': 'application/json' });
1882
- res.end(JSON.stringify({ error: 'Invalid timeout' }));
1883
- return;
1884
- }
1885
748
  const leaseMs = parseInt(url.searchParams.get('leaseMs') || '30000', 10);
1886
749
  const available = findAvailablePendingEvent();
1887
750
  if (available) {
@@ -1914,6 +777,36 @@ function handlePollGet(req, res, url) {
1914
777
  });
1915
778
  }
1916
779
 
780
+ function sessionFileMetadataFromPollReply(file) {
781
+ if (!file || typeof file !== 'string') return { file };
782
+ const normalized = file.split(path.sep).join('/');
783
+ 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;
786
+
787
+ let full;
788
+ try {
789
+ full = path.resolve(process.cwd(), normalized);
790
+ const rel = path.relative(process.cwd(), full);
791
+ if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return base;
792
+ } catch {
793
+ return base;
794
+ }
795
+
796
+ try {
797
+ const manifest = JSON.parse(fs.readFileSync(full, 'utf-8'));
798
+ if (manifest?.previewMode !== 'svelte-component' || !manifest.sourceFile) return base;
799
+ return {
800
+ file: String(manifest.sourceFile).split(path.sep).join('/'),
801
+ sourceFile: String(manifest.sourceFile).split(path.sep).join('/'),
802
+ previewFile: normalized,
803
+ previewMode: 'svelte-component',
804
+ };
805
+ } catch {
806
+ return base;
807
+ }
808
+ }
809
+
1917
810
  function handlePollPost(req, res) {
1918
811
  let body = '';
1919
812
  req.on('data', (c) => { body += c; });
@@ -1929,9 +822,9 @@ function handlePollPost(req, res) {
1929
822
  res.end(JSON.stringify({ error: 'Unauthorized' }));
1930
823
  return;
1931
824
  }
1932
- const pendingApplyDeferred = state.pendingApplyDeferreds.get(msg.id);
825
+ const pendingApplyDeferred = manualApply.getDeferred(msg.id);
1933
826
  if (pendingApplyDeferred) {
1934
- const validation = validateManualApplyResultMessage(msg, pendingApplyDeferred);
827
+ const validation = manualApply.validateResultMessage(msg, pendingApplyDeferred);
1935
828
  if (!validation.ok) {
1936
829
  recordManualEditActivity('manual_edit_apply_reply_invalid', {
1937
830
  id: msg.id,
@@ -1956,15 +849,15 @@ function handlePollPost(req, res) {
1956
849
  fileCount: validation.result.files.length,
1957
850
  noteCount: validation.result.notes.length,
1958
851
  });
1959
- resolveApplyDeferred(msg.id, validation.result);
852
+ manualApply.resolveDeferred(msg.id, validation.result);
1960
853
  acknowledgePendingEvent(msg.id);
1961
854
  flushPendingPolls();
1962
855
  res.writeHead(200, { 'Content-Type': 'application/json' });
1963
856
  res.end(JSON.stringify({ ok: true }));
1964
857
  return;
1965
858
  }
1966
- if (state.timedOutApplyIds.has(msg.id)) {
1967
- const rollback = rollbackTimedOutApplyReply(msg);
859
+ if (manualApply.hasTimedOutId(msg.id)) {
860
+ const rollback = manualApply.rollbackTimedOutReply(msg);
1968
861
  recordManualEditActivity('manual_edit_apply_stale_reply_rejected', {
1969
862
  id: msg.id,
1970
863
  rolledBackFileCount: rollback.rolledBackFiles?.length || 0,
@@ -1974,6 +867,16 @@ function handlePollPost(req, res) {
1974
867
  res.end(JSON.stringify({ error: 'stale_manual_edit_apply_reply', ...rollback }));
1975
868
  return;
1976
869
  }
870
+ const pendingEventBeforeAck = findPendingEventById(msg.id);
871
+ if (pendingEventBeforeAck?.type === 'steer' && msg.type === 'steer_done'
872
+ && !msg.file && !(typeof msg.message === 'string' && msg.message.trim())) {
873
+ res.writeHead(400, { 'Content-Type': 'application/json' });
874
+ res.end(JSON.stringify({
875
+ error: 'steer_done_requires_file_or_message',
876
+ hint: 'Reply with --file after writing source, or include a message explaining an intentional no-op.',
877
+ }));
878
+ return;
879
+ }
1977
880
  const acknowledgedEvent = acknowledgePendingEvent(msg.id);
1978
881
  let skipJournalReply = false;
1979
882
  let existingSession = null;
@@ -1996,6 +899,7 @@ function handlePollPost(req, res) {
1996
899
  }));
1997
900
  return;
1998
901
  }
902
+ const replyFileMeta = sessionFileMetadataFromPollReply(msg.file);
1999
903
  if (state.sessionStore && msg.id && !skipJournalReply) {
2000
904
  try {
2001
905
  const eventType = msg.type === 'steer_done'
@@ -2010,7 +914,10 @@ function handlePollPost(req, res) {
2010
914
  state.sessionStore.appendEvent({
2011
915
  type: eventType,
2012
916
  id: msg.id,
2013
- file: msg.file,
917
+ file: replyFileMeta.file,
918
+ sourceFile: replyFileMeta.sourceFile,
919
+ previewFile: replyFileMeta.previewFile,
920
+ previewMode: replyFileMeta.previewMode,
2014
921
  message: msg.message,
2015
922
  sourceEventType: acknowledgedEvent?.type,
2016
923
  carbonize: msg.data?.carbonize === true,
@@ -2019,7 +926,16 @@ function handlePollPost(req, res) {
2019
926
  }
2020
927
  flushPendingPolls();
2021
928
  // Forward the reply to the browser via SSE
2022
- broadcast({ type: msg.type || 'done', id: msg.id, message: msg.message, file: msg.file, data: msg.data });
929
+ broadcast({
930
+ type: msg.type || 'done',
931
+ id: msg.id,
932
+ message: msg.message,
933
+ file: msg.file,
934
+ sourceFile: replyFileMeta.sourceFile,
935
+ previewFile: replyFileMeta.previewFile,
936
+ previewMode: replyFileMeta.previewMode,
937
+ data: msg.data,
938
+ });
2023
939
  res.writeHead(200, { 'Content-Type': 'application/json' });
2024
940
  res.end(JSON.stringify({ ok: true }));
2025
941
  });
@@ -2032,6 +948,7 @@ function handlePollPost(req, res) {
2032
948
  let httpServer = null;
2033
949
 
2034
950
  function shutdown() {
951
+ cleanupSvelteComponentSessionsBeforeExit();
2035
952
  removeLiveServerInfo(process.cwd());
2036
953
  if (state.leaseTimer) clearTimeout(state.leaseTimer);
2037
954
  state.leaseTimer = null;
@@ -2046,6 +963,25 @@ function shutdown() {
2046
963
  process.exit(0);
2047
964
  }
2048
965
 
966
+ function cleanupSvelteComponentSessionsBeforeExit() {
967
+ try {
968
+ removeAllSvelteComponentSessions(process.cwd());
969
+ } catch (err) {
970
+ console.warn('[impeccable] Svelte component session cleanup failed:', err.message);
971
+ }
972
+ }
973
+
974
+ function applyLegacyDeferredAcceptsOnStartup() {
975
+ try {
976
+ const result = applyDeferredSvelteComponentAccepts(process.cwd());
977
+ if (result.applied > 0 || result.failed > 0) {
978
+ console.log('[impeccable] applied legacy deferred Svelte component accepts:', JSON.stringify(result));
979
+ }
980
+ } catch (err) {
981
+ console.warn('[impeccable] legacy deferred Svelte component accept apply failed:', err.message);
982
+ }
983
+ }
984
+
2049
985
  // ---------------------------------------------------------------------------
2050
986
  // Main
2051
987
  // ---------------------------------------------------------------------------
@@ -2167,12 +1103,12 @@ if (existingRecord?.info) {
2167
1103
 
2168
1104
  state.token = randomUUID();
2169
1105
  state.sessionStore = createLiveSessionStore({ cwd: process.cwd() });
2170
- rollbackManualApplyTransaction({
2171
- cwd: process.cwd(),
1106
+ manualApply.rollbackTransaction({
2172
1107
  reason: 'manual_edit_server_start_recovered_abandoned_transaction',
2173
1108
  });
1109
+ applyLegacyDeferredAcceptsOnStartup();
2174
1110
  restorePendingEventsFromStore();
2175
- pruneStaleManualApplyEvidence(process.cwd());
1111
+ manualApply.pruneStaleEvidence();
2176
1112
  const portArg = args.find(a => a.startsWith('--port='));
2177
1113
  state.port = portArg ? parseInt(portArg.split('=')[1], 10) : await findOpenPort();
2178
1114
  // Annotation screenshots live in the project root so the agent's Read tool
@@ -2182,8 +1118,8 @@ const annotRoot = getLiveAnnotationsDir(process.cwd());
2182
1118
  fs.mkdirSync(annotRoot, { recursive: true });
2183
1119
  state.sessionDir = fs.mkdtempSync(path.join(annotRoot, 'session-'));
2184
1120
 
2185
- const { detectScript, sessionPath, livePath } = loadBrowserScripts();
2186
- httpServer = http.createServer(createRequestHandler({ detectScript, sessionPath, livePath }));
1121
+ const { detectScript, liveScriptParts } = loadBrowserScripts();
1122
+ httpServer = http.createServer(createRequestHandler({ detectScript, liveScriptParts }));
2187
1123
 
2188
1124
  httpServer.listen(state.port, '127.0.0.1', () => {
2189
1125
  writeLiveServerInfo(process.cwd(), { pid: process.pid, port: state.port, token: state.token });