quiver-cli 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/LICENSE +21 -0
  2. package/dist/cli.js +314 -86
  3. package/package.json +1 -1
  4. package/template/.agents/skills/agent-browser/SKILL.md +4 -9
  5. package/template/.agents/skills/apps/skybridge/SKILL.md +1 -1
  6. package/template/.agents/skills/code/improve/SKILL.md +9 -5
  7. package/template/.agents/skills/code/improve/references/audit-playbook.md +10 -10
  8. package/template/.agents/skills/code/improve/references/closing-the-loop.md +4 -3
  9. package/template/.agents/skills/code/improve/references/plan-template.md +5 -0
  10. package/template/.agents/skills/design/impeccable/SKILL.md +14 -24
  11. package/template/.agents/skills/design/impeccable/reference/animate.md +1 -1
  12. package/template/.agents/skills/design/impeccable/reference/bolder.md +1 -1
  13. package/template/.agents/skills/design/impeccable/reference/brand.md +2 -2
  14. package/template/.agents/skills/design/impeccable/reference/colorize.md +1 -1
  15. package/template/.agents/skills/design/impeccable/reference/critique.md +6 -6
  16. package/template/.agents/skills/design/impeccable/reference/delight.md +1 -1
  17. package/template/.agents/skills/design/impeccable/reference/distill.md +1 -1
  18. package/template/.agents/skills/design/impeccable/reference/document.md +1 -1
  19. package/template/.agents/skills/design/impeccable/reference/extract.md +1 -1
  20. package/template/.agents/skills/design/impeccable/reference/hooks.md +90 -0
  21. package/template/.agents/skills/design/impeccable/reference/init.md +5 -5
  22. package/template/.agents/skills/design/impeccable/reference/live.md +16 -16
  23. package/template/.agents/skills/design/impeccable/reference/overdrive.md +1 -1
  24. package/template/.agents/skills/design/impeccable/reference/polish.md +2 -2
  25. package/template/.agents/skills/design/impeccable/reference/quieter.md +1 -1
  26. package/template/.agents/skills/design/impeccable/reference/shape.md +2 -2
  27. package/template/.agents/skills/design/impeccable/scripts/context-signals.mjs +1 -1
  28. package/template/.agents/skills/design/impeccable/scripts/context.mjs +724 -33
  29. package/template/.agents/skills/design/impeccable/scripts/critique-storage.mjs +1 -1
  30. package/template/.agents/skills/design/impeccable/scripts/detector/browser/injected/index.mjs +204 -0
  31. package/template/.agents/skills/design/impeccable/scripts/detector/cli/main.mjs +57 -11
  32. package/template/.agents/skills/design/impeccable/scripts/detector/design-system.mjs +750 -0
  33. package/template/.agents/skills/design/impeccable/scripts/detector/detect-antipatterns-browser.js +633 -46
  34. package/template/.agents/skills/design/impeccable/scripts/detector/detect-antipatterns.mjs +7 -0
  35. package/template/.agents/skills/design/impeccable/scripts/detector/engines/browser/detect-url.mjs +29 -4
  36. package/template/.agents/skills/design/impeccable/scripts/detector/engines/regex/detect-text.mjs +43 -10
  37. package/template/.agents/skills/design/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +29 -0
  38. package/template/.agents/skills/design/impeccable/scripts/detector/engines/static-html/detect-html.mjs +27 -1
  39. package/template/.agents/skills/design/impeccable/scripts/detector/node/file-system.mjs +1 -1
  40. package/template/.agents/skills/design/impeccable/scripts/detector/registry/antipatterns.mjs +29 -0
  41. package/template/.agents/skills/design/impeccable/scripts/detector/rules/checks.mjs +401 -46
  42. package/template/.agents/skills/design/impeccable/scripts/detector/shared/inline-ignores.mjs +148 -0
  43. package/template/.agents/skills/design/impeccable/scripts/hook-admin.mjs +661 -0
  44. package/template/.agents/skills/design/impeccable/scripts/hook-before-edit.mjs +476 -0
  45. package/template/.agents/skills/design/impeccable/scripts/hook-lib.mjs +1632 -0
  46. package/template/.agents/skills/design/impeccable/scripts/hook.mjs +61 -0
  47. package/template/.agents/skills/design/impeccable/scripts/{design-parser.mjs → lib/design-parser.mjs} +8 -1
  48. package/template/.agents/skills/design/impeccable/scripts/lib/impeccable-config.mjs +638 -0
  49. package/template/.agents/skills/design/impeccable/scripts/lib/impeccable-paths.mjs +128 -0
  50. package/template/.agents/skills/design/impeccable/scripts/lib/target-args.mjs +42 -0
  51. package/template/.agents/skills/design/impeccable/scripts/live/browser-script-parts.mjs +49 -0
  52. package/template/.agents/skills/design/impeccable/scripts/{live-event-validation.mjs → live/event-validation.mjs} +6 -5
  53. package/template/.agents/skills/design/impeccable/scripts/live/manual-apply.mjs +939 -0
  54. package/template/.agents/skills/design/impeccable/scripts/live/manual-edit-routes.mjs +357 -0
  55. package/template/.agents/skills/design/impeccable/scripts/{live-manual-edits-buffer.mjs → live/manual-edits-buffer.mjs} +1 -1
  56. package/template/.agents/skills/design/impeccable/scripts/{live-session-store.mjs → live/session-store.mjs} +1 -1
  57. package/template/.agents/skills/design/impeccable/scripts/{live-ui-core.mjs → live/ui-core.mjs} +2 -1
  58. package/template/.agents/skills/design/impeccable/scripts/live/vocabulary.mjs +36 -0
  59. package/template/.agents/skills/design/impeccable/scripts/live-accept.mjs +3 -3
  60. package/template/.agents/skills/design/impeccable/scripts/live-browser-dom.js +146 -0
  61. package/template/.agents/skills/design/impeccable/scripts/live-browser.js +1456 -599
  62. package/template/.agents/skills/design/impeccable/scripts/live-commit-manual-edits.mjs +2 -2
  63. package/template/.agents/skills/design/impeccable/scripts/live-complete.mjs +2 -2
  64. package/template/.agents/skills/design/impeccable/scripts/live-discard-manual-edits.mjs +1 -1
  65. package/template/.agents/skills/design/impeccable/scripts/live-inject.mjs +35 -9
  66. package/template/.agents/skills/design/impeccable/scripts/live-insert.mjs +2 -2
  67. package/template/.agents/skills/design/impeccable/scripts/live-manual-edit-evidence.mjs +2 -2
  68. package/template/.agents/skills/design/impeccable/scripts/live-poll.mjs +18 -13
  69. package/template/.agents/skills/design/impeccable/scripts/live-resume.mjs +1 -1
  70. package/template/.agents/skills/design/impeccable/scripts/live-server.mjs +77 -1264
  71. package/template/.agents/skills/design/impeccable/scripts/live-status.mjs +2 -2
  72. package/template/.agents/skills/design/impeccable/scripts/live-target.mjs +30 -0
  73. package/template/.agents/skills/design/impeccable/scripts/live-wrap.mjs +4 -4
  74. package/template/.agents/skills/design/impeccable/scripts/live.mjs +73 -22
  75. package/template/.agents/skills/writing/humanizer/SKILL.md +621 -0
  76. package/template/.agents/upstreams.json +17 -8
  77. package/template/.agents/skills/apps/skybridge/references/architecture.md +0 -175
  78. package/template/.agents/skills/apps/skybridge/references/copy-template.md +0 -24
  79. package/template/.agents/skills/apps/skybridge/references/csp.md +0 -33
  80. package/template/.agents/skills/apps/skybridge/references/deploy.md +0 -33
  81. package/template/.agents/skills/apps/skybridge/references/discover.md +0 -84
  82. package/template/.agents/skills/apps/skybridge/references/download-file.md +0 -77
  83. package/template/.agents/skills/apps/skybridge/references/fetch-and-render-data.md +0 -151
  84. package/template/.agents/skills/apps/skybridge/references/oauth.md +0 -115
  85. package/template/.agents/skills/apps/skybridge/references/open-external-links.md +0 -71
  86. package/template/.agents/skills/apps/skybridge/references/prompt-llm.md +0 -20
  87. package/template/.agents/skills/apps/skybridge/references/publish.md +0 -19
  88. package/template/.agents/skills/apps/skybridge/references/run-locally.md +0 -51
  89. package/template/.agents/skills/apps/skybridge/references/state-and-context.md +0 -151
  90. package/template/.agents/skills/apps/skybridge/references/ui-guidelines.md +0 -205
  91. package/template/.agents/skills/design/impeccable/scripts/cleanup-deprecated.mjs +0 -284
  92. package/template/.agents/skills/design/impeccable/scripts/impeccable-paths.mjs +0 -126
  93. /package/template/.agents/skills/design/impeccable/scripts/{is-generated.mjs → lib/is-generated.mjs} +0 -0
  94. /package/template/.agents/skills/design/impeccable/scripts/{live-completion.mjs → live/completion.mjs} +0 -0
  95. /package/template/.agents/skills/design/impeccable/scripts/{live-insert-ui.mjs → live/insert-ui.mjs} +0 -0
  96. /package/template/.agents/skills/design/impeccable/scripts/{live-svelte-component.mjs → live/svelte-component.mjs} +0 -0
  97. /package/template/.agents/skills/design/impeccable/scripts/{live-sveltekit-adapter.mjs → live/sveltekit-adapter.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,26 +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';
36
45
  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';
46
+ createManualApplyController,
47
+ summarizeManualApplyFailures,
48
+ } from './live/manual-apply.mjs';
45
49
  import {
46
50
  applyDeferredSvelteComponentAccepts,
47
51
  removeAllSvelteComponentSessions,
48
- } from './live-svelte-component.mjs';
52
+ } from './live/svelte-component.mjs';
49
53
 
50
54
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
51
55
  // PRODUCT.md / DESIGN.md live wherever context.mjs resolves. The generated
52
56
  // DESIGN sidecar is project-local at .impeccable/design.json, with legacy
53
57
  // DESIGN.json fallback for existing projects.
54
- 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;
55
63
  const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway
56
64
  const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s
57
65
 
@@ -100,23 +108,29 @@ const state = {
100
108
  };
101
109
 
102
110
  const CHAT_POLL_FRESHNESS_MS = 60_000;
103
- const APPLY_EVENT_HARD_TIMEOUT_MS = Number(process.env.IMPECCABLE_LIVE_APPLY_EVENT_HARD_TIMEOUT_MS || 150_000);
104
- const APPLY_EVENT_SOFT_DEADLINE_MS = Number(process.env.IMPECCABLE_LIVE_APPLY_EVENT_SOFT_DEADLINE_MS || 120_000);
105
- const DEFAULT_MANUAL_EDIT_APPLY_CHUNK_SIZE = 3;
106
- const MIN_MANUAL_EDIT_APPLY_CHUNK_SIZE = 1;
107
- const MAX_MANUAL_EDIT_APPLY_CHUNK_SIZE = 20;
108
- const MANUAL_APPLY_COMPACT_TEXT_LIMIT = 240;
109
- const MANUAL_APPLY_COMPACT_NEARBY_LIMIT = 4;
110
111
  const POLL_LEASE_EXPIRY_TIMER_GRACE_MS = 2;
111
112
  const DEBUG_MANUAL_EDIT_EVENTS = /^(1|true|yes)$/i.test(process.env.IMPECCABLE_LIVE_DEBUG_EVENTS || '');
112
113
 
113
- function tombstoneTimedOutApplyId(eventId, details = {}) {
114
- if (!eventId) return;
115
- state.timedOutApplyIds.set(eventId, details);
116
- if (state.timedOutApplyIds.size <= 200) return;
117
- const oldest = state.timedOutApplyIds.keys().next().value;
118
- state.timedOutApplyIds.delete(oldest);
119
- }
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
+ });
120
134
 
121
135
  function chatAgentLikelyActive() {
122
136
  if (state.pendingPolls.length > 0) return true;
@@ -124,752 +138,6 @@ function chatAgentLikelyActive() {
124
138
  return Date.now() - state.lastPollAt < CHAT_POLL_FRESHNESS_MS;
125
139
  }
126
140
 
127
- function manualEditApplyChunkSize(env = process.env) {
128
- const raw = Number(env.IMPECCABLE_LIVE_MANUAL_EDIT_CHUNK_SIZE);
129
- if (!Number.isFinite(raw)) return DEFAULT_MANUAL_EDIT_APPLY_CHUNK_SIZE;
130
- const size = Math.trunc(raw);
131
- return Math.max(MIN_MANUAL_EDIT_APPLY_CHUNK_SIZE, Math.min(MAX_MANUAL_EDIT_APPLY_CHUNK_SIZE, size));
132
- }
133
-
134
- function countManualApplyOps(entriesOrBatch) {
135
- const entries = Array.isArray(entriesOrBatch)
136
- ? entriesOrBatch
137
- : Array.isArray(entriesOrBatch?.entries) ? entriesOrBatch.entries : [];
138
- let count = 0;
139
- for (const entry of entries) count += Array.isArray(entry.ops) ? entry.ops.length : 0;
140
- return count;
141
- }
142
-
143
- function pushApplyEventAndWait(batch, pageUrl, chunk = null, repair = null) {
144
- const eventId = randomUUID().replace(/-/g, '').slice(0, 8);
145
- const evidencePath = writeManualApplyEvidence(eventId, batch);
146
- const event = {
147
- type: 'manual_edit_apply',
148
- id: eventId,
149
- pageUrl,
150
- batch: compactManualApplyBatch(batch),
151
- evidencePath,
152
- agentAction: buildManualApplyAgentAction(eventId),
153
- schemaVersion: 1,
154
- deadlineMs: APPLY_EVENT_SOFT_DEADLINE_MS,
155
- };
156
- if (chunk) event.chunk = chunk;
157
- if (repair) event.repair = repair;
158
- const rollbackSnapshot = snapshotApplyEventFiles(batch);
159
- recordManualEditActivity('manual_edit_apply_dispatched', {
160
- id: eventId,
161
- pageUrl,
162
- chunk,
163
- repair,
164
- entryCount: Array.isArray(batch.entries) ? batch.entries.length : 0,
165
- opCount: countManualApplyOps(batch),
166
- fileCount: collectManualApplyFiles(batch).length,
167
- });
168
- return new Promise((resolve, reject) => {
169
- const timer = setTimeout(() => {
170
- state.pendingApplyDeferreds.delete(eventId);
171
- tombstoneTimedOutApplyId(eventId, { batch, rollbackSnapshot });
172
- acknowledgePendingEvent(eventId);
173
- removeManualApplyEvidence(evidencePath);
174
- recordManualEditActivity('manual_edit_apply_timeout', {
175
- id: eventId,
176
- pageUrl,
177
- chunk,
178
- entryCount: Array.isArray(batch.entries) ? batch.entries.length : 0,
179
- opCount: countManualApplyOps(batch),
180
- });
181
- reject(new Error('chat_agent_timeout'));
182
- }, APPLY_EVENT_HARD_TIMEOUT_MS);
183
- state.pendingApplyDeferreds.set(eventId, { resolve, reject, timer, event, batch, pageUrl, rollbackSnapshot });
184
- enqueueEvent(event);
185
- });
186
- }
187
-
188
- function writeManualApplyEvidence(eventId, batch) {
189
- const dir = manualApplyEvidenceDir(process.cwd());
190
- fs.mkdirSync(dir, { recursive: true });
191
- const evidencePath = path.join(dir, `${eventId}.json`);
192
- fs.writeFileSync(evidencePath, JSON.stringify(batch, null, 2) + '\n', 'utf-8');
193
- return evidencePath;
194
- }
195
-
196
- function manualApplyEvidenceDir(cwd = process.cwd()) {
197
- return path.join(getLiveDir(cwd), 'manual-edit-evidence');
198
- }
199
-
200
- function normalizeManualApplyEvidencePath(evidencePath, cwd = process.cwd()) {
201
- if (!evidencePath || typeof evidencePath !== 'string') return null;
202
- const fullPath = path.isAbsolute(evidencePath) ? evidencePath : path.resolve(cwd, evidencePath);
203
- const evidenceDir = manualApplyEvidenceDir(cwd);
204
- const relative = path.relative(evidenceDir, fullPath);
205
- if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) return null;
206
- if (path.extname(relative) !== '.json') return null;
207
- return fullPath;
208
- }
209
-
210
- function removeManualApplyEvidence(evidencePath, cwd = process.cwd()) {
211
- const fullPath = normalizeManualApplyEvidencePath(evidencePath, cwd);
212
- if (!fullPath) return false;
213
- try {
214
- fs.unlinkSync(fullPath);
215
- return true;
216
- } catch {
217
- return false;
218
- }
219
- }
220
-
221
- function referencedManualApplyEvidencePaths(cwd = process.cwd()) {
222
- const referenced = new Set();
223
- const add = (event) => {
224
- const fullPath = normalizeManualApplyEvidencePath(event?.evidencePath, cwd);
225
- if (fullPath) referenced.add(fullPath);
226
- };
227
- for (const entry of state.pendingEvents) add(entry.event);
228
- for (const deferred of state.pendingApplyDeferreds.values()) add(deferred.event);
229
- return referenced;
230
- }
231
-
232
- function pruneStaleManualApplyEvidence(cwd = process.cwd()) {
233
- const dir = manualApplyEvidenceDir(cwd);
234
- if (!fs.existsSync(dir)) return [];
235
- const referenced = referencedManualApplyEvidencePaths(cwd);
236
- const removed = [];
237
- for (const name of fs.readdirSync(dir)) {
238
- if (!name.endsWith('.json')) continue;
239
- const fullPath = path.join(dir, name);
240
- if (referenced.has(fullPath)) continue;
241
- try {
242
- fs.unlinkSync(fullPath);
243
- removed.push(fullPath);
244
- } catch {
245
- // Stale evidence cleanup is best-effort; Apply verification never relies
246
- // on deleting these files.
247
- }
248
- }
249
- return removed;
250
- }
251
-
252
- function compactManualApplyBatch(batch = {}) {
253
- const entries = (batch.entries || []).map(compactManualApplyEntry);
254
- const candidates = compactManualApplyCandidates(batch.candidates || []);
255
- return {
256
- version: batch.version,
257
- pageUrl: batch.pageUrl || null,
258
- count: batch.count,
259
- entries,
260
- ops: entries.flatMap((entry) => entry.ops.map((op) => ({ ...op, entryId: entry.id }))),
261
- candidates: candidates.length > 0 ? candidates : undefined,
262
- context: batch.context ? {
263
- bufferPath: batch.context.bufferPath,
264
- totalEntries: batch.context.totalEntries,
265
- totalOps: batch.context.totalOps,
266
- chunkIndex: batch.context.chunkIndex,
267
- chunkTotal: batch.context.chunkTotal,
268
- totalApplyOps: batch.context.totalApplyOps,
269
- } : undefined,
270
- };
271
- }
272
-
273
- function compactManualApplyCandidates(candidates) {
274
- return (Array.isArray(candidates) ? candidates : [])
275
- .slice(0, 24)
276
- .map((candidate) => ({
277
- entryId: candidate.entryId,
278
- ref: candidate.ref,
279
- sourceHint: compactManualApplySourceMatch(candidate.sourceHint),
280
- textMatches: compactManualApplySourceMatches(candidate.textMatches, 8),
281
- objectKeyMatches: compactManualApplySourceMatches(candidate.objectKeyMatches, 8),
282
- contextTextMatches: compactManualApplySourceMatches(candidate.contextTextMatches, 8),
283
- locatorMatches: compactManualApplySourceMatches(candidate.locatorMatches, 6),
284
- }));
285
- }
286
-
287
- function compactManualApplySourceMatches(matches, limit) {
288
- return (Array.isArray(matches) ? matches : [])
289
- .slice(0, limit)
290
- .map(compactManualApplySourceMatch)
291
- .filter(Boolean);
292
- }
293
-
294
- function compactManualApplySourceMatch(match) {
295
- if (!match || typeof match !== 'object') return null;
296
- const file = match.relativeFile || match.file;
297
- if (!file && !match.line) return null;
298
- return {
299
- file: summarizeManualLogFile(file),
300
- line: match.line || null,
301
- column: match.column || null,
302
- reason: match.reason || match.kind || undefined,
303
- status: match.status || undefined,
304
- };
305
- }
306
-
307
- function compactManualApplyEntry(entry = {}) {
308
- return {
309
- id: entry.id,
310
- pageUrl: entry.pageUrl,
311
- stagedAt: entry.stagedAt || null,
312
- element: compactManualApplyContext(entry.element),
313
- ops: (entry.ops || []).map(compactManualApplyOp),
314
- };
315
- }
316
-
317
- function compactManualApplyOp(op = {}) {
318
- return {
319
- entryId: op.entryId,
320
- ref: op.ref,
321
- contextRef: op.contextRef,
322
- tag: op.tag,
323
- elementId: op.elementId,
324
- classes: Array.isArray(op.classes) ? op.classes : [],
325
- originalText: op.originalText,
326
- newText: op.newText,
327
- deleted: op.deleted === true || undefined,
328
- sourceHint: op.sourceHint || null,
329
- leaf: compactManualApplyContext(op.leaf),
330
- nearbyEditableTexts: compactNearbyManualEditTexts(op.nearbyEditableTexts),
331
- container: compactManualApplyContext(op.container),
332
- contextHints: Array.isArray(op.contextHints) ? op.contextHints.slice(0, 8) : undefined,
333
- };
334
- }
335
-
336
- function compactManualApplyContext(value) {
337
- if (!value || typeof value !== 'object') return null;
338
- return {
339
- ref: value.ref,
340
- tagName: value.tagName || value.tag || null,
341
- id: value.id || null,
342
- classes: Array.isArray(value.classes) ? value.classes : [],
343
- textContent: truncateManualApplyText(value.textContent, MANUAL_APPLY_COMPACT_TEXT_LIMIT),
344
- };
345
- }
346
-
347
- function compactNearbyManualEditTexts(items) {
348
- return (Array.isArray(items) ? items : [])
349
- .slice(0, MANUAL_APPLY_COMPACT_NEARBY_LIMIT)
350
- .map((item) => typeof item === 'string' ? { text: truncateManualApplyText(item, MANUAL_APPLY_COMPACT_TEXT_LIMIT) } : {
351
- ref: item?.ref,
352
- tag: item?.tag,
353
- classes: Array.isArray(item?.classes) ? item.classes : [],
354
- text: truncateManualApplyText(item?.text, MANUAL_APPLY_COMPACT_TEXT_LIMIT),
355
- });
356
- }
357
-
358
- function truncateManualApplyText(value, max) {
359
- if (typeof value !== 'string') return value || null;
360
- return value.length > max ? value.slice(0, max) : value;
361
- }
362
-
363
- async function pushApplyBatchInChunksAndWait(batch, pageUrl, context = {}) {
364
- const repair = context?.repair || batch?.repair || null;
365
- if (repair) return pushApplyEventAndWait(batch, pageUrl, null, repair);
366
- const chunks = splitManualApplyBatch(batch, manualEditApplyChunkSize());
367
- if (chunks.length <= 1) return pushApplyEventAndWait(batch, pageUrl);
368
-
369
- const expectedOpsByEntry = new Map();
370
- for (const entry of batch?.entries || []) {
371
- expectedOpsByEntry.set(entry.id, Array.isArray(entry.ops) ? entry.ops.length : 0);
372
- }
373
-
374
- const appliedOpsByEntry = new Map();
375
- const failedByEntry = new Map();
376
- const files = new Set();
377
- const notes = [];
378
- let aborted = false;
379
-
380
- for (const chunk of chunks) {
381
- if (aborted) {
382
- markChunkEntriesFailed(failedByEntry, chunk, 'manual_edit_chunk_aborted');
383
- continue;
384
- }
385
-
386
- let result;
387
- try {
388
- result = normalizeApplyChunkResult(await pushApplyEventAndWait(chunk.batch, pageUrl, chunk.meta));
389
- } catch (err) {
390
- markChunkEntriesFailed(failedByEntry, chunk, err.message || 'chat_agent_error');
391
- aborted = true;
392
- continue;
393
- }
394
-
395
- for (const file of result.files) files.add(file);
396
- notes.push(...result.notes);
397
-
398
- const chunkFailedIds = new Set();
399
- for (const item of result.failed) {
400
- const entryId = item.entryId || item.id;
401
- if (!entryId) continue;
402
- chunkFailedIds.add(entryId);
403
- if (!failedByEntry.has(entryId)) {
404
- failedByEntry.set(entryId, {
405
- entryId,
406
- reason: item.reason || item.message || 'failed',
407
- candidates: Array.isArray(item.candidates) ? item.candidates : [],
408
- });
409
- }
410
- }
411
-
412
- if (result.status === 'error') {
413
- markChunkEntriesFailed(failedByEntry, chunk, result.message || firstFailureReason(result) || 'chat_agent_error');
414
- aborted = true;
415
- continue;
416
- }
417
-
418
- const reportedAppliedIds = new Set(result.appliedEntryIds);
419
- for (const entryId of reportedAppliedIds) {
420
- if (!chunk.entryIds.has(entryId) || chunkFailedIds.has(entryId)) continue;
421
- appliedOpsByEntry.set(entryId, (appliedOpsByEntry.get(entryId) || 0) + (chunk.opCountsByEntry.get(entryId) || 0));
422
- }
423
-
424
- for (const entryId of chunk.entryIds) {
425
- if (reportedAppliedIds.has(entryId) || chunkFailedIds.has(entryId)) continue;
426
- if (!failedByEntry.has(entryId)) {
427
- failedByEntry.set(entryId, { entryId, reason: 'not_reported_applied', candidates: [] });
428
- }
429
- }
430
- }
431
-
432
- const appliedEntryIds = [];
433
- for (const [entryId, expectedOps] of expectedOpsByEntry.entries()) {
434
- if (failedByEntry.has(entryId)) continue;
435
- if ((appliedOpsByEntry.get(entryId) || 0) === expectedOps && expectedOps > 0) {
436
- appliedEntryIds.push(entryId);
437
- } else if (!failedByEntry.has(entryId)) {
438
- failedByEntry.set(entryId, { entryId, reason: 'not_reported_applied', candidates: [] });
439
- }
440
- }
441
-
442
- const failed = [...failedByEntry.values()];
443
- return {
444
- status: failed.length === 0 ? 'done' : appliedEntryIds.length > 0 ? 'partial' : 'error',
445
- appliedEntryIds,
446
- failed,
447
- files: [...files],
448
- notes,
449
- };
450
- }
451
-
452
- function normalizeApplyChunkResult(result) {
453
- const status = result?.status === 'partial' ? 'partial' : result?.status === 'error' ? 'error' : 'done';
454
- return {
455
- status,
456
- message: typeof result?.message === 'string' ? result.message : null,
457
- appliedEntryIds: Array.isArray(result?.appliedEntryIds) ? result.appliedEntryIds.filter((id) => typeof id === 'string') : [],
458
- failed: Array.isArray(result?.failed) ? result.failed.filter(Boolean) : [],
459
- files: Array.isArray(result?.files) ? result.files.filter((file) => typeof file === 'string') : [],
460
- notes: Array.isArray(result?.notes) ? result.notes.filter((note) => typeof note === 'string') : [],
461
- };
462
- }
463
-
464
- function manualApplyResultShapeHint(eventId = 'EVENT_ID') {
465
- return `Use live-poll.mjs --reply ${eventId} done --data '{"status":"done","appliedEntryIds":["ENTRY_ID"],"failed":[],"files":["src/page.html"],"notes":[]}'`;
466
- }
467
-
468
- function invalidManualApplyResult(reason, eventId, extra = {}) {
469
- return {
470
- ok: false,
471
- body: {
472
- error: 'invalid_manual_apply_result',
473
- reason,
474
- hint: manualApplyResultShapeHint(eventId),
475
- ...extra,
476
- },
477
- };
478
- }
479
-
480
- function validateManualApplyResultMessage(msg, deferred) {
481
- let data = msg?.data;
482
- const eventId = msg?.id || deferred?.event?.id || 'EVENT_ID';
483
- if (!data || typeof data !== 'object' || Array.isArray(data)) {
484
- return invalidManualApplyResult('missing_result_data', eventId);
485
- }
486
- if ('entries' in data || 'ops' in data) {
487
- return invalidManualApplyResult('summary_result_not_allowed', eventId);
488
- }
489
- if (!['done', 'partial', 'error'].includes(data.status)) {
490
- return invalidManualApplyResult('invalid_status', eventId, { status: data.status ?? null });
491
- }
492
-
493
- for (const key of ['appliedEntryIds', 'failed', 'files', 'notes']) {
494
- if (!Array.isArray(data[key])) {
495
- return invalidManualApplyResult(`${key}_must_be_array`, eventId);
496
- }
497
- }
498
-
499
- for (const [index, value] of data.appliedEntryIds.entries()) {
500
- if (typeof value !== 'string' || !value) {
501
- return invalidManualApplyResult('appliedEntryIds_must_contain_strings', eventId, { index });
502
- }
503
- }
504
- for (const [index, value] of data.files.entries()) {
505
- if (typeof value !== 'string' || !value) {
506
- return invalidManualApplyResult('files_must_contain_strings', eventId, { index });
507
- }
508
- }
509
- for (const [index, value] of data.notes.entries()) {
510
- if (typeof value !== 'string') {
511
- return invalidManualApplyResult('notes_must_contain_strings', eventId, { index });
512
- }
513
- }
514
- for (const [index, item] of data.failed.entries()) {
515
- if (!item || typeof item !== 'object' || Array.isArray(item)) {
516
- return invalidManualApplyResult('failed_must_contain_objects', eventId, { index });
517
- }
518
- if (typeof item.entryId !== 'string' || !item.entryId) {
519
- return invalidManualApplyResult('failed_entryId_required', eventId, { index });
520
- }
521
- if (typeof item.reason !== 'string' || !item.reason) {
522
- return invalidManualApplyResult('failed_reason_required', eventId, { index });
523
- }
524
- }
525
-
526
- const eventEntryIds = new Set((deferred?.batch?.entries || []).map((entry) => entry.id).filter(Boolean));
527
- for (const entryId of data.appliedEntryIds) {
528
- if (eventEntryIds.size > 0 && !eventEntryIds.has(entryId)) {
529
- return invalidManualApplyResult('applied_entry_id_not_in_event', eventId, { entryId });
530
- }
531
- }
532
- for (const item of data.failed) {
533
- if (eventEntryIds.size > 0 && !eventEntryIds.has(item.entryId)) {
534
- return invalidManualApplyResult('failed_entry_id_not_in_event', eventId, { entryId: item.entryId });
535
- }
536
- }
537
-
538
- if (data.status === 'done') {
539
- if (data.failed.length > 0) {
540
- return invalidManualApplyResult('done_result_has_failed_entries', eventId);
541
- }
542
- if (countManualApplyOps(deferred?.batch) > 0 && data.appliedEntryIds.length === 0) {
543
- return invalidManualApplyResult('done_result_missing_applied_entry_ids', eventId);
544
- }
545
- }
546
- if (data.status === 'partial' && data.appliedEntryIds.length === 0 && data.failed.length === 0) {
547
- return invalidManualApplyResult('partial_result_has_no_entries', eventId);
548
- }
549
- if (data.status === 'error' && data.appliedEntryIds.length > 0) {
550
- return invalidManualApplyResult('error_result_has_applied_entries', eventId);
551
- }
552
-
553
- return {
554
- ok: true,
555
- result: {
556
- status: data.status,
557
- message: typeof data.message === 'string' ? data.message : undefined,
558
- appliedEntryIds: data.appliedEntryIds,
559
- failed: data.failed,
560
- files: data.files,
561
- notes: data.notes,
562
- },
563
- };
564
- }
565
-
566
- function firstFailureReason(result) {
567
- const first = Array.isArray(result?.failed) ? result.failed.find(Boolean) : null;
568
- return first?.reason || first?.message || null;
569
- }
570
-
571
- function markChunkEntriesFailed(failedByEntry, chunk, reason) {
572
- for (const entryId of chunk.entryIds) {
573
- if (failedByEntry.has(entryId)) continue;
574
- failedByEntry.set(entryId, { entryId, reason, candidates: [] });
575
- }
576
- }
577
-
578
- function splitManualApplyBatch(batch, maxOps) {
579
- const totalOpCount = countManualApplyOps(batch);
580
- if (totalOpCount <= maxOps) {
581
- return [{
582
- batch,
583
- meta: null,
584
- entryIds: new Set((batch?.entries || []).map((entry) => entry.id).filter(Boolean)),
585
- opCountsByEntry: new Map((batch?.entries || []).map((entry) => [entry.id, Array.isArray(entry.ops) ? entry.ops.length : 0])),
586
- }];
587
- }
588
-
589
- const rawChunks = [];
590
- let current = createManualApplyChunkBuilder();
591
- for (const entry of batch?.entries || []) {
592
- const ops = entry.ops || [];
593
- if (ops.length <= maxOps) {
594
- if (current.opCount > 0 && current.opCount + ops.length > maxOps) {
595
- rawChunks.push(current);
596
- current = createManualApplyChunkBuilder();
597
- }
598
- for (const op of ops) addOpToManualApplyChunk(current, entry, op);
599
- continue;
600
- }
601
- if (current.opCount > 0) {
602
- rawChunks.push(current);
603
- current = createManualApplyChunkBuilder();
604
- }
605
- for (const op of ops) {
606
- if (current.opCount >= maxOps) {
607
- rawChunks.push(current);
608
- current = createManualApplyChunkBuilder();
609
- }
610
- addOpToManualApplyChunk(current, entry, op);
611
- }
612
- }
613
- if (current.opCount > 0) rawChunks.push(current);
614
-
615
- return rawChunks.map((chunk, index) => ({
616
- batch: {
617
- ...batch,
618
- count: chunk.opCount,
619
- entries: chunk.entries,
620
- ops: chunk.ops,
621
- candidates: filterManualApplyChunkCandidates(batch, chunk.refsByEntry),
622
- context: {
623
- ...(batch?.context || {}),
624
- totalEntries: chunk.entries.length,
625
- totalOps: chunk.opCount,
626
- chunkIndex: index + 1,
627
- chunkTotal: rawChunks.length,
628
- totalApplyOps: totalOpCount,
629
- },
630
- },
631
- meta: {
632
- index: index + 1,
633
- total: rawChunks.length,
634
- opCount: chunk.opCount,
635
- totalOpCount,
636
- },
637
- entryIds: new Set(chunk.entries.map((entry) => entry.id).filter(Boolean)),
638
- opCountsByEntry: chunk.opCountsByEntry,
639
- }));
640
- }
641
-
642
- function createManualApplyChunkBuilder() {
643
- return {
644
- entries: [],
645
- entryById: new Map(),
646
- entryIds: new Set(),
647
- ops: [],
648
- refsByEntry: new Map(),
649
- opCountsByEntry: new Map(),
650
- opCount: 0,
651
- };
652
- }
653
-
654
- function addOpToManualApplyChunk(chunk, entry, op) {
655
- let chunkEntry = chunk.entryById.get(entry.id);
656
- if (!chunkEntry) {
657
- chunkEntry = { ...entry, ops: [] };
658
- chunk.entryById.set(entry.id, chunkEntry);
659
- chunk.entryIds.add(entry.id);
660
- chunk.entries.push(chunkEntry);
661
- }
662
- chunkEntry.ops.push(op);
663
- chunk.ops.push({ ...op, entryId: op.entryId || entry.id });
664
- if (!chunk.refsByEntry.has(entry.id)) chunk.refsByEntry.set(entry.id, new Set());
665
- if (op.ref) chunk.refsByEntry.get(entry.id).add(op.ref);
666
- chunk.opCountsByEntry.set(entry.id, (chunk.opCountsByEntry.get(entry.id) || 0) + 1);
667
- chunk.opCount += 1;
668
- }
669
-
670
- function filterManualApplyChunkCandidates(batch, refsByEntry) {
671
- return (batch?.candidates || []).filter((candidate) => {
672
- const refs = refsByEntry.get(candidate.entryId);
673
- if (!refs) return false;
674
- if (!candidate.ref) return true;
675
- return refs.has(candidate.ref);
676
- });
677
- }
678
-
679
- function resolveApplyDeferred(eventId, body) {
680
- const deferred = state.pendingApplyDeferreds.get(eventId);
681
- if (!deferred) return false;
682
- state.pendingApplyDeferreds.delete(eventId);
683
- clearTimeout(deferred.timer);
684
- removeManualApplyEvidence(deferred.event?.evidencePath);
685
- deferred.resolve(body);
686
- return true;
687
- }
688
-
689
- function rejectApplyDeferred(eventId, reason) {
690
- const deferred = state.pendingApplyDeferreds.get(eventId);
691
- if (!deferred) return false;
692
- state.pendingApplyDeferreds.delete(eventId);
693
- clearTimeout(deferred.timer);
694
- removeManualApplyEvidence(deferred.event?.evidencePath);
695
- deferred.reject(new Error(reason || 'chat_agent_error'));
696
- return true;
697
- }
698
-
699
- function snapshotApplyEventFiles(batch) {
700
- const snapshot = new Map();
701
- for (const relativeFile of collectManualApplyFiles(batch)) {
702
- const absolute = path.resolve(process.cwd(), relativeFile);
703
- try {
704
- snapshot.set(relativeFile, {
705
- exists: fs.existsSync(absolute),
706
- content: fs.existsSync(absolute) ? fs.readFileSync(absolute, 'utf-8') : '',
707
- });
708
- } catch {
709
- // If a file cannot be read before dispatch, do not attempt late rollback.
710
- }
711
- }
712
- return snapshot;
713
- }
714
-
715
- function manualApplyTransactionPath(cwd = process.cwd()) {
716
- return path.join(getLiveDir(cwd), 'manual-edit-apply-transaction.json');
717
- }
718
-
719
- function readManualApplyTransaction(cwd = process.cwd()) {
720
- const file = manualApplyTransactionPath(cwd);
721
- if (!fs.existsSync(file)) return null;
722
- try {
723
- return JSON.parse(fs.readFileSync(file, 'utf-8'));
724
- } catch {
725
- return null;
726
- }
727
- }
728
-
729
- function writeManualApplyTransaction({ cwd = process.cwd(), pageUrl = null, batch }) {
730
- const file = manualApplyTransactionPath(cwd);
731
- const files = collectManualApplyFiles(batch);
732
- const transaction = {
733
- version: 1,
734
- id: randomUUID().replace(/-/g, '').slice(0, 8),
735
- createdAt: new Date().toISOString(),
736
- pageUrl,
737
- entryIds: (batch?.entries || []).map((entry) => entry.id).filter(Boolean),
738
- files: files.map((relativeFile) => {
739
- const absolute = path.resolve(cwd, relativeFile);
740
- const exists = fs.existsSync(absolute);
741
- return {
742
- file: relativeFile,
743
- exists,
744
- content: exists ? fs.readFileSync(absolute, 'utf-8') : '',
745
- };
746
- }),
747
- };
748
- fs.mkdirSync(path.dirname(file), { recursive: true });
749
- fs.writeFileSync(`${file}.tmp`, JSON.stringify(transaction, null, 2) + '\n', 'utf-8');
750
- fs.renameSync(`${file}.tmp`, file);
751
- return transaction;
752
- }
753
-
754
- function clearManualApplyTransaction(cwd = process.cwd(), transactionId = null) {
755
- const file = manualApplyTransactionPath(cwd);
756
- if (!fs.existsSync(file)) return false;
757
- if (transactionId) {
758
- const existing = readManualApplyTransaction(cwd);
759
- if (existing?.id && existing.id !== transactionId) return false;
760
- }
761
- try {
762
- fs.unlinkSync(file);
763
- return true;
764
- } catch {
765
- return false;
766
- }
767
- }
768
-
769
- function rollbackManualApplyTransaction({ cwd = process.cwd(), pageUrl = null, reason = 'manual_edit_transaction_rollback' } = {}) {
770
- const transaction = readManualApplyTransaction(cwd);
771
- if (!transaction) return null;
772
- if (pageUrl && transaction.pageUrl && transaction.pageUrl !== pageUrl) return null;
773
-
774
- let pendingIds = new Set();
775
- try {
776
- const buffer = readManualEditsBuffer(cwd);
777
- pendingIds = new Set((buffer.entries || []).map((entry) => entry.id).filter(Boolean));
778
- } catch {
779
- pendingIds = new Set(transaction.entryIds || []);
780
- }
781
- const shouldRollback = (transaction.entryIds || []).some((id) => pendingIds.has(id));
782
- if (!shouldRollback) {
783
- clearManualApplyTransaction(cwd, transaction.id);
784
- return { id: transaction.id, reason, rolledBackFiles: [], rollbackFailures: [], skipped: 'entries_not_pending' };
785
- }
786
-
787
- const rolledBackFiles = [];
788
- const rollbackFailures = [];
789
- for (const item of transaction.files || []) {
790
- const relativeFile = normalizeProjectFile(item.file);
791
- if (!relativeFile) continue;
792
- const absolute = path.resolve(cwd, relativeFile);
793
- try {
794
- if (item.exists) {
795
- fs.mkdirSync(path.dirname(absolute), { recursive: true });
796
- fs.writeFileSync(absolute, item.content || '', 'utf-8');
797
- } else if (fs.existsSync(absolute)) {
798
- fs.rmSync(absolute);
799
- }
800
- rolledBackFiles.push(relativeFile);
801
- } catch (err) {
802
- rollbackFailures.push({ file: relativeFile, reason: 'restore_failed', message: err.message || String(err) });
803
- }
804
- }
805
- clearManualApplyTransaction(cwd, transaction.id);
806
- recordManualEditActivity('manual_edit_transaction_rolled_back', {
807
- id: transaction.id,
808
- pageUrl: transaction.pageUrl || null,
809
- reason,
810
- entryIds: transaction.entryIds || [],
811
- rolledBackFiles: rolledBackFiles.map(summarizeManualLogFile).filter(Boolean),
812
- rollbackFailures: summarizeManualDiagnostics(rollbackFailures),
813
- });
814
- return { id: transaction.id, reason, rolledBackFiles, rollbackFailures };
815
- }
816
-
817
- function collectManualApplyFiles(batch, extraFiles = []) {
818
- const files = [];
819
- for (const entry of batch?.entries || []) {
820
- for (const op of entry.ops || []) files.push(op.sourceHint?.file);
821
- }
822
- for (const candidate of batch?.candidates || []) {
823
- files.push(candidate.sourceHint?.relativeFile, candidate.sourceHint?.file);
824
- for (const item of candidate.textMatches || []) files.push(item.file);
825
- for (const item of candidate.objectKeyMatches || []) files.push(item.file);
826
- for (const item of candidate.locatorMatches || []) files.push(item.file);
827
- for (const item of candidate.contextTextMatches || []) files.push(item.file);
828
- }
829
- files.push(...(extraFiles || []));
830
- return [...new Set(files)]
831
- .map((file) => normalizeProjectFile(file))
832
- .filter(Boolean);
833
- }
834
-
835
- function normalizeProjectFile(file) {
836
- if (!file || typeof file !== 'string') return null;
837
- const absolute = path.isAbsolute(file) ? file : path.resolve(process.cwd(), file);
838
- const relative = path.relative(process.cwd(), absolute);
839
- if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) return null;
840
- return relative;
841
- }
842
-
843
- function rollbackApplySnapshot(batch, rollbackSnapshot, extraFiles = [], reason = 'manual_edit_apply_snapshot_rollback') {
844
- const scope = collectManualApplyFiles(batch, extraFiles);
845
- const rolledBackFiles = [];
846
- const rollbackFailures = [];
847
- for (const relativeFile of scope) {
848
- const before = rollbackSnapshot?.get(relativeFile);
849
- if (!before) continue;
850
- const absolute = path.resolve(process.cwd(), relativeFile);
851
- try {
852
- if (before.exists) {
853
- fs.mkdirSync(path.dirname(absolute), { recursive: true });
854
- fs.writeFileSync(absolute, before.content, 'utf-8');
855
- } else if (fs.existsSync(absolute)) {
856
- fs.rmSync(absolute);
857
- }
858
- rolledBackFiles.push(relativeFile);
859
- } catch (err) {
860
- rollbackFailures.push({ file: relativeFile, reason: 'restore_failed', message: err.message || String(err) });
861
- }
862
- }
863
- return { rolledBackFiles, rollbackFailures };
864
- }
865
-
866
- function rollbackTimedOutApplyReply(msg) {
867
- const details = state.timedOutApplyIds.get(msg.id);
868
- if (!details) return { rolledBackFiles: [], rollbackFailures: [] };
869
- state.timedOutApplyIds.delete(msg.id);
870
- return rollbackApplySnapshot(details.batch, details.rollbackSnapshot, msg.data?.files || [], 'stale_manual_edit_apply_reply');
871
- }
872
-
873
141
  // Cap per-annotation upload size. A full 1920×1080 PNG is typically <1 MB;
874
142
  // cap at 10 MB to guard against runaway writes from a misbehaving client.
875
143
  const MAX_ANNOTATION_BYTES = 10 * 1024 * 1024;
@@ -924,32 +192,6 @@ function findPendingEventById(id) {
924
192
  return entry?.event || null;
925
193
  }
926
194
 
927
- function manualApplyReplyCommand(eventOrId = 'EVENT_ID') {
928
- const id = typeof eventOrId === 'string' ? eventOrId : eventOrId?.id || 'EVENT_ID';
929
- return `live-poll.mjs --reply ${id} done --data '<json>'`;
930
- }
931
-
932
- function buildManualApplyAgentAction(eventOrId = 'EVENT_ID') {
933
- return {
934
- kind: 'manual_edit_apply',
935
- required: 'apply_source_edits_then_reply',
936
- replyCommand: manualApplyReplyCommand(eventOrId),
937
- warning: 'Polling only leases this work item; it does not commit source edits.',
938
- };
939
- }
940
-
941
- function summarizeManualApplyEvent(event = {}, batch = event.batch) {
942
- const entries = Array.isArray(batch?.entries) ? batch.entries : [];
943
- const opCount = entries.reduce((sum, entry) => sum + (Array.isArray(entry.ops) ? entry.ops.length : 0), 0);
944
- return {
945
- pageUrl: event.pageUrl || null,
946
- chunk: event.chunk || null,
947
- entryCount: entries.length,
948
- opCount,
949
- files: collectManualApplyFiles(batch),
950
- };
951
- }
952
-
953
195
  function summarizePendingEventForStatus(entry) {
954
196
  const event = entry.event || {};
955
197
  const summary = {
@@ -963,8 +205,8 @@ function summarizePendingEventForStatus(entry) {
963
205
  summary.chunk = event.chunk || null;
964
206
  summary.repair = event.repair || null;
965
207
  summary.evidencePath = event.evidencePath || null;
966
- summary.agentAction = event.agentAction || buildManualApplyAgentAction(event);
967
- 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);
968
210
  }
969
211
  return summary;
970
212
  }
@@ -1005,47 +247,6 @@ function cancelQueuedAnonymousExitEvents() {
1005
247
  return removed;
1006
248
  }
1007
249
 
1008
- function cancelPendingManualApplyEvents(pageUrl, reason = 'manual_edit_discarded') {
1009
- const canceledById = new Map();
1010
- const shouldCancel = (event) => event?.type === 'manual_edit_apply' && (!pageUrl || event.pageUrl === pageUrl);
1011
-
1012
- for (let i = state.pendingEvents.length - 1; i >= 0; i -= 1) {
1013
- const event = state.pendingEvents[i]?.event;
1014
- if (!shouldCancel(event)) continue;
1015
- state.pendingEvents.splice(i, 1);
1016
- removeManualApplyEvidence(event.evidencePath);
1017
- canceledById.set(event.id, {
1018
- id: event.id,
1019
- pageUrl: event.pageUrl,
1020
- entryCount: event.batch?.entries?.length || 0,
1021
- });
1022
- }
1023
-
1024
- for (const [eventId, deferred] of [...state.pendingApplyDeferreds.entries()]) {
1025
- if (!shouldCancel(deferred.event)) continue;
1026
- state.pendingApplyDeferreds.delete(eventId);
1027
- clearTimeout(deferred.timer);
1028
- const rollback = rollbackApplySnapshot(deferred.batch, deferred.rollbackSnapshot, [], reason);
1029
- tombstoneTimedOutApplyId(eventId, {
1030
- batch: deferred.batch,
1031
- rollbackSnapshot: deferred.rollbackSnapshot,
1032
- reason,
1033
- });
1034
- removeManualApplyEvidence(deferred.event?.evidencePath);
1035
- canceledById.set(eventId, {
1036
- id: eventId,
1037
- pageUrl: deferred.pageUrl,
1038
- entryCount: deferred.batch?.entries?.length || 0,
1039
- rolledBackFiles: rollback.rolledBackFiles,
1040
- rollbackFailures: rollback.rollbackFailures,
1041
- });
1042
- deferred.reject(new Error(reason));
1043
- }
1044
-
1045
- if (canceledById.size > 0) flushPendingPolls();
1046
- return [...canceledById.values()];
1047
- }
1048
-
1049
250
  function scheduleLeaseFlush() {
1050
251
  if (state.leaseTimer) {
1051
252
  clearTimeout(state.leaseTimer);
@@ -1137,61 +338,6 @@ function getManualEditStatus() {
1137
338
  }
1138
339
  }
1139
340
 
1140
- function summarizePendingManualEditBatch(pageUrl = null) {
1141
- try {
1142
- const buffer = readManualEditsBuffer(process.cwd());
1143
- const entries = (buffer.entries || [])
1144
- .filter((entry) => !pageUrl || entry.pageUrl === pageUrl);
1145
- return {
1146
- pendingEntryCount: entries.length,
1147
- pendingOpCount: entries.reduce((sum, entry) => sum + (entry.ops?.length || 0), 0),
1148
- };
1149
- } catch (err) {
1150
- return { pendingSummaryError: err.message || String(err) };
1151
- }
1152
- }
1153
-
1154
- function summarizeManualApplyFailures(failed) {
1155
- if (!Array.isArray(failed)) return [];
1156
- return failed.slice(0, 20).map((item) => ({
1157
- id: item.id || item.entryId || null,
1158
- reason: item.reason || item.message || 'failed',
1159
- message: compactManualLogText(item.message, 300),
1160
- files: Array.isArray(item.files) ? item.files.slice(0, 12).map(summarizeManualLogFile).filter(Boolean) : undefined,
1161
- checks: summarizeManualDiagnostics(item.checks),
1162
- failures: summarizeManualDiagnostics(item.failures),
1163
- candidates: summarizeManualDiagnostics(item.candidates),
1164
- }));
1165
- }
1166
-
1167
- function summarizeManualDiagnostics(items) {
1168
- if (!Array.isArray(items) || items.length === 0) return undefined;
1169
- return items.slice(0, 12).map((item) => ({
1170
- reason: item.reason || item.kind || undefined,
1171
- detail: compactManualLogText(item.detail, 220),
1172
- message: compactManualLogText(item.message, 300),
1173
- file: summarizeManualLogFile(item.file || item.relativeFile),
1174
- line: item.line || undefined,
1175
- ref: compactManualLogText(item.ref, 180),
1176
- marker: compactManualLogText(item.marker, 120),
1177
- files: Array.isArray(item.files) ? item.files.slice(0, 8).map(summarizeManualLogFile).filter(Boolean) : undefined,
1178
- }));
1179
- }
1180
-
1181
- function summarizeManualLogFile(file) {
1182
- if (!file || typeof file !== 'string') return undefined;
1183
- if (!path.isAbsolute(file)) return file;
1184
- const relative = path.relative(process.cwd(), file);
1185
- return relative && !relative.startsWith('..') && !path.isAbsolute(relative) ? relative : file;
1186
- }
1187
-
1188
- function compactManualLogText(value, max = 200) {
1189
- if (typeof value !== 'string') return undefined;
1190
- const normalized = value.replace(/\s+/g, ' ').trim();
1191
- if (normalized.length <= max) return normalized;
1192
- return normalized.slice(0, max) + `... [truncated ${normalized.length - max} chars]`;
1193
- }
1194
-
1195
341
  // ---------------------------------------------------------------------------
1196
342
  // Load scripts
1197
343
  // ---------------------------------------------------------------------------
@@ -1211,29 +357,25 @@ function loadBrowserScripts() {
1211
357
  try { detectScript = fs.readFileSync(p, 'utf-8'); break; } catch { /* try next */ }
1212
358
  }
1213
359
 
1214
- // live-browser.js: DO NOT cache. Return the path so the /live.js handler
1215
- // can re-read on every request. Editing the browser script during iteration
1216
- // should land on the next tab reload, not require a server restart.
1217
- const sessionPath = path.join(__dirname, 'live-browser-session.js');
1218
- const livePath = path.join(__dirname, 'live-browser.js');
1219
- for (const p of [sessionPath, livePath]) {
1220
- if (!fs.existsSync(p)) {
1221
- process.stderr.write('Error: live browser script not found at ' + p + '\n');
1222
- process.exit(1);
1223
- }
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);
1224
369
  }
1225
370
 
1226
- return { detectScript, sessionPath, livePath };
371
+ return { detectScript, liveScriptParts };
1227
372
  }
1228
373
 
1229
374
  function hasProjectContext() {
1230
375
  // PRODUCT.md carries brand voice / anti-references — that's what determines
1231
376
  // whether variants are brand-aware. DESIGN.md (visual tokens) is a separate
1232
377
  // concern, surfaced by the design panel's own empty state.
1233
- try {
1234
- fs.accessSync(path.join(CONTEXT_DIR, 'PRODUCT.md'), fs.constants.R_OK);
1235
- return true;
1236
- } catch { return false; }
378
+ return !!PROJECT_CONTEXT.hasProduct;
1237
379
  }
1238
380
 
1239
381
  function statOrNull(filePath) {
@@ -1243,7 +385,7 @@ function statOrNull(filePath) {
1243
385
  // HTTP request handler
1244
386
  // ---------------------------------------------------------------------------
1245
387
 
1246
- function createRequestHandler({ detectScript, sessionPath, livePath }) {
388
+ function createRequestHandler({ detectScript, liveScriptParts }) {
1247
389
  return (req, res) => {
1248
390
  const url = new URL(req.url, `http://localhost:${state.port}`);
1249
391
  res.setHeader('Access-Control-Allow-Origin', '*');
@@ -1259,21 +401,20 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) {
1259
401
  // the next tab reload. No-store headers prevent browser caching across
1260
402
  // sessions — during iteration, a cached old script silently breaks
1261
403
  // every subsequent session.
1262
- let sessionScript;
1263
- let liveScript;
404
+ let parts;
1264
405
  try {
1265
- sessionScript = fs.readFileSync(sessionPath, 'utf-8');
1266
- liveScript = fs.readFileSync(livePath, 'utf-8');
406
+ parts = readLiveBrowserScriptParts(liveScriptParts);
1267
407
  } catch (err) {
1268
408
  res.writeHead(500, { 'Content-Type': 'text/plain' });
1269
409
  res.end('Error reading live browser scripts: ' + err.message);
1270
410
  return;
1271
411
  }
1272
- const body =
1273
- `window.__IMPECCABLE_TOKEN__ = '${state.token}';\n` +
1274
- `window.__IMPECCABLE_PORT__ = ${state.port};\n` +
1275
- sessionScript + '\n' +
1276
- liveScript;
412
+ const body = assembleLiveBrowserScript({
413
+ token: state.token,
414
+ port: state.port,
415
+ vocabulary: LIVE_COMMANDS,
416
+ parts,
417
+ });
1277
418
  res.writeHead(200, {
1278
419
  'Content-Type': 'application/javascript',
1279
420
  'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0',
@@ -1409,8 +550,8 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) {
1409
550
  const token = url.searchParams.get('token');
1410
551
  if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
1411
552
 
1412
- const mdPath = path.join(CONTEXT_DIR, 'DESIGN.md');
1413
- 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());
1414
555
  const mdStat = statOrNull(mdPath);
1415
556
  const jsonStat = statOrNull(jsonPath);
1416
557
 
@@ -1510,334 +651,7 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) {
1510
651
  return;
1511
652
  }
1512
653
 
1513
- // --- Manual copy edits: Save stages entries, Apply commits the staged
1514
- // page batch through the local AI copy-edit runner.
1515
- if (p === '/manual-edit-stash' && req.method === 'POST') {
1516
- let body = '';
1517
- req.on('data', (c) => { body += c; });
1518
- req.on('end', () => {
1519
- let msg;
1520
- try { msg = JSON.parse(body); } catch {
1521
- res.writeHead(400, { 'Content-Type': 'application/json' });
1522
- res.end(JSON.stringify({ error: 'Invalid JSON' }));
1523
- return;
1524
- }
1525
- if (msg.token !== state.token) {
1526
- res.writeHead(401, { 'Content-Type': 'application/json' });
1527
- res.end(JSON.stringify({ error: 'Unauthorized' }));
1528
- return;
1529
- }
1530
- const error = validateEvent({ ...msg, type: 'manual_edits' });
1531
- if (error) {
1532
- res.writeHead(400, { 'Content-Type': 'application/json' });
1533
- res.end(JSON.stringify({ error }));
1534
- return;
1535
- }
1536
- try {
1537
- stageManualEditEntry(process.cwd(), {
1538
- id: msg.id,
1539
- pageUrl: msg.pageUrl,
1540
- element: msg.element,
1541
- ops: msg.ops,
1542
- });
1543
- } catch (err) {
1544
- res.writeHead(500, { 'Content-Type': 'application/json' });
1545
- res.end(JSON.stringify({ error: 'stash_write_failed', message: err.message }));
1546
- return;
1547
- }
1548
- const { totalCount, perPage } = countPendingByPage(process.cwd());
1549
- const pendingCount = perPage[msg.pageUrl] || 0;
1550
- recordManualEditActivity('manual_edit_stashed', {
1551
- id: msg.id,
1552
- pageUrl: msg.pageUrl,
1553
- opCount: msg.ops.length,
1554
- pendingCount,
1555
- totalCount,
1556
- hintedFileCount: new Set((msg.ops || []).map((op) => summarizeManualLogFile(op.sourceHint?.file)).filter(Boolean)).size,
1557
- });
1558
- res.writeHead(200, { 'Content-Type': 'application/json' });
1559
- res.end(JSON.stringify({ ok: true, pendingCount, totalCount, perPage }));
1560
- });
1561
- return;
1562
- }
1563
-
1564
- // GET /manual-edit-stash?pageUrl=<url> → { count, totalCount, perPage, entries }
1565
- if (p === '/manual-edit-stash' && req.method === 'GET') {
1566
- const token = url.searchParams.get('token');
1567
- if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
1568
- const pageUrl = url.searchParams.get('pageUrl') || '';
1569
- const { totalCount, perPage } = countPendingByPage(process.cwd());
1570
- const buffer = readManualEditsBuffer(process.cwd());
1571
- const entriesForPage = pageUrl ? buffer.entries.filter((e) => e.pageUrl === pageUrl) : buffer.entries;
1572
- res.writeHead(200, { 'Content-Type': 'application/json' });
1573
- res.end(JSON.stringify({
1574
- count: pageUrl ? (perPage[pageUrl] || 0) : totalCount,
1575
- totalCount,
1576
- perPage,
1577
- entries: entriesForPage,
1578
- }));
1579
- return;
1580
- }
1581
-
1582
- // POST /manual-edit-commit?pageUrl=<url> → ask the AI to apply the staged page batch.
1583
- if (p === '/manual-edit-commit' && req.method === 'POST') {
1584
- const token = url.searchParams.get('token');
1585
- if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
1586
- const pageUrl = url.searchParams.get('pageUrl');
1587
- const asyncMode = /^(1|true|yes)$/i.test(url.searchParams.get('async') || '');
1588
- const repairOnly = /^(1|true|yes)$/i.test(url.searchParams.get('repair') || '');
1589
- const existingTransaction = readManualApplyTransaction(process.cwd());
1590
- if (repairOnly && !existingTransaction) {
1591
- res.writeHead(409, { 'Content-Type': 'application/json' });
1592
- res.end(JSON.stringify({ error: 'manual_edit_repair_transaction_missing' }));
1593
- return;
1594
- }
1595
- const recoveredTransaction = repairOnly ? null : rollbackManualApplyTransaction({
1596
- cwd: process.cwd(),
1597
- pageUrl,
1598
- reason: 'manual_edit_commit_recovered_abandoned_transaction',
1599
- });
1600
- const before = getManualEditStatus();
1601
- const pendingCount = pageUrl ? (before.perPage[pageUrl] || 0) : before.totalCount;
1602
- recordManualEditActivity('manual_edit_commit_started', {
1603
- pageUrl,
1604
- repairOnly,
1605
- pendingCount,
1606
- totalCount: before.totalCount,
1607
- recoveredTransaction: recoveredTransaction ? {
1608
- id: recoveredTransaction.id,
1609
- reason: recoveredTransaction.reason,
1610
- skipped: recoveredTransaction.skipped,
1611
- rolledBackFiles: recoveredTransaction.rolledBackFiles,
1612
- rollbackFailures: summarizeManualDiagnostics(recoveredTransaction.rollbackFailures),
1613
- } : null,
1614
- ...summarizePendingManualEditBatch(pageUrl),
1615
- });
1616
- if (asyncMode) {
1617
- res.writeHead(202, { 'Content-Type': 'application/json' });
1618
- res.end(JSON.stringify({
1619
- status: 'started',
1620
- pendingCount,
1621
- totalCount: before.totalCount,
1622
- perPage: before.perPage,
1623
- }));
1624
- }
1625
- (async () => {
1626
- let result;
1627
- let routedProvider = 'subprocess';
1628
- let transaction = null;
1629
- let commitBatch = null;
1630
- try {
1631
- if (pendingCount > 0) {
1632
- const transactionBatch = buildManualEditEvidence({ cwd: process.cwd(), pageUrl });
1633
- commitBatch = transactionBatch;
1634
- if (!repairOnly && countManualApplyOps(transactionBatch) > 0) {
1635
- transaction = writeManualApplyTransaction({
1636
- cwd: process.cwd(),
1637
- pageUrl,
1638
- batch: transactionBatch,
1639
- });
1640
- } else if (repairOnly && existingTransaction) {
1641
- transaction = existingTransaction;
1642
- }
1643
- }
1644
- const requestedMode = (process.env.IMPECCABLE_LIVE_COPY_AGENT || 'auto').trim().toLowerCase();
1645
- const useChatRoute = requestedMode === 'chat'
1646
- || (requestedMode === 'auto' && chatAgentLikelyActive());
1647
- if (useChatRoute) {
1648
- routedProvider = 'chat';
1649
- const timeoutMs = Number(process.env.IMPECCABLE_LIVE_COPY_AGENT_TIMEOUT_MS || 120000);
1650
- result = await commitManualEdits({
1651
- cwd: process.cwd(),
1652
- pageUrl,
1653
- provider: 'chat',
1654
- env: process.env,
1655
- timeoutMs,
1656
- chatAvailable: chatAgentLikelyActive,
1657
- applyBatchToSource: (batch, context) => pushApplyBatchInChunksAndWait(batch, pageUrl, context),
1658
- repairOnly,
1659
- transactionId: transaction?.id || existingTransaction?.id || null,
1660
- batch: commitBatch,
1661
- });
1662
- } else {
1663
- const timeoutMs = Number(process.env.IMPECCABLE_LIVE_COPY_AGENT_TIMEOUT_MS || 120000);
1664
- const provider = ['codex', 'claude', 'mock'].includes(requestedMode) ? requestedMode : undefined;
1665
- result = await commitManualEdits({
1666
- cwd: process.cwd(),
1667
- pageUrl,
1668
- provider,
1669
- env: process.env,
1670
- timeoutMs,
1671
- chatAvailable: chatAgentLikelyActive,
1672
- repairOnly,
1673
- transactionId: transaction?.id || existingTransaction?.id || null,
1674
- batch: commitBatch,
1675
- });
1676
- }
1677
- } catch (err) {
1678
- if (transaction) {
1679
- rollbackManualApplyTransaction({
1680
- cwd: process.cwd(),
1681
- pageUrl,
1682
- reason: 'manual_edit_commit_exception',
1683
- });
1684
- }
1685
- const message = err.stderr?.toString?.() || err.message;
1686
- recordManualEditActivity('manual_edit_commit_failed', {
1687
- pageUrl,
1688
- provider: routedProvider,
1689
- error: 'manual_edit_commit_failed',
1690
- message,
1691
- transactionId: transaction?.id || null,
1692
- });
1693
- if (!asyncMode) {
1694
- res.writeHead(500, { 'Content-Type': 'application/json' });
1695
- res.end(JSON.stringify({
1696
- error: 'manual_edit_commit_failed',
1697
- message,
1698
- }));
1699
- }
1700
- return;
1701
- } finally {
1702
- if (transaction) {
1703
- const shouldKeepTransaction = result?.needsManualDecision === true;
1704
- if (!shouldKeepTransaction) clearManualApplyTransaction(process.cwd(), transaction.id);
1705
- }
1706
- }
1707
- const { totalCount, perPage } = countPendingByPage(process.cwd());
1708
- if (result?.needsManualDecision) {
1709
- recordManualEditActivity('manual_edit_repair_needs_decision', {
1710
- pageUrl,
1711
- provider: routedProvider,
1712
- transactionId: transaction?.id || existingTransaction?.id || null,
1713
- repair: result.repair || null,
1714
- failed: summarizeManualApplyFailures(result.failed),
1715
- files: Array.isArray(result.files) ? result.files.slice(0, 20).map(summarizeManualLogFile).filter(Boolean) : [],
1716
- remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount,
1717
- totalCount,
1718
- });
1719
- } else {
1720
- recordManualEditActivity('manual_edit_commit_done', {
1721
- pageUrl,
1722
- provider: routedProvider,
1723
- reason: result.reason || null,
1724
- repair: result.repair || null,
1725
- appliedCount: Array.isArray(result.applied) ? result.applied.length : 0,
1726
- failedCount: Array.isArray(result.failed) ? result.failed.length : 0,
1727
- failed: summarizeManualApplyFailures(result.failed),
1728
- files: Array.isArray(result.files) ? result.files.slice(0, 20).map(summarizeManualLogFile).filter(Boolean) : [],
1729
- warnings: summarizeManualDiagnostics(result.warnings),
1730
- rolledBackFiles: Array.isArray(result.rolledBackFiles) ? result.rolledBackFiles.slice(0, 20).map(summarizeManualLogFile).filter(Boolean) : [],
1731
- rollbackFailures: summarizeManualDiagnostics(result.rollbackFailures),
1732
- unreportedFiles: Array.isArray(result.unreportedFiles) ? result.unreportedFiles.slice(0, 20).map(summarizeManualLogFile).filter(Boolean) : undefined,
1733
- noteCount: Array.isArray(result.notes) ? result.notes.length : 0,
1734
- cleared: result.cleared || 0,
1735
- remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount,
1736
- totalCount,
1737
- });
1738
- }
1739
- if (!asyncMode) {
1740
- res.writeHead(200, { 'Content-Type': 'application/json' });
1741
- res.end(JSON.stringify({ ...result, totalCount, perPage }));
1742
- }
1743
- })();
1744
- return;
1745
- }
1746
-
1747
- // POST /manual-edit-repair-decision → user resolves an exhausted repair loop.
1748
- if (p === '/manual-edit-repair-decision' && req.method === 'POST') {
1749
- let body = '';
1750
- req.on('data', (chunk) => { body += chunk; });
1751
- req.on('end', () => {
1752
- let payload = {};
1753
- try { payload = body ? JSON.parse(body) : {}; } catch {
1754
- res.writeHead(400, { 'Content-Type': 'application/json' });
1755
- res.end(JSON.stringify({ error: 'Invalid JSON' }));
1756
- return;
1757
- }
1758
- const token = payload.token || url.searchParams.get('token');
1759
- if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
1760
- const pageUrl = payload.pageUrl || url.searchParams.get('pageUrl') || null;
1761
- const action = String(payload.action || url.searchParams.get('action') || '').trim().toLowerCase();
1762
- if (action !== 'rollback') {
1763
- res.writeHead(400, { 'Content-Type': 'application/json' });
1764
- res.end(JSON.stringify({ error: 'unsupported_manual_edit_repair_decision', action }));
1765
- return;
1766
- }
1767
- const rollback = rollbackManualApplyTransaction({
1768
- cwd: process.cwd(),
1769
- pageUrl,
1770
- reason: 'manual_edit_user_requested_rollback',
1771
- });
1772
- const { totalCount, perPage } = countPendingByPage(process.cwd());
1773
- const response = {
1774
- action,
1775
- pageUrl,
1776
- rollback,
1777
- remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount,
1778
- totalCount,
1779
- perPage,
1780
- };
1781
- recordManualEditActivity('manual_edit_repair_rollback_done', response);
1782
- res.writeHead(200, { 'Content-Type': 'application/json' });
1783
- res.end(JSON.stringify(response));
1784
- });
1785
- return;
1786
- }
1787
-
1788
- // POST /manual-edit-discard?pageUrl=<url> → drops entries (all if no pageUrl)
1789
- if (p === '/manual-edit-discard' && req.method === 'POST') {
1790
- const token = url.searchParams.get('token');
1791
- if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
1792
- const pageUrl = url.searchParams.get('pageUrl');
1793
- let discarded;
1794
- let discardedEntries = [];
1795
- let canceledApplyEvents = [];
1796
- let transactionRollback = null;
1797
- try {
1798
- const buffer = readManualEditsBuffer(process.cwd());
1799
- transactionRollback = rollbackManualApplyTransaction({
1800
- cwd: process.cwd(),
1801
- pageUrl,
1802
- reason: 'manual_edit_discarded',
1803
- });
1804
- if (pageUrl) {
1805
- discardedEntries = buffer.entries.filter((entry) => entry.pageUrl === pageUrl);
1806
- discarded = removeManualEditEntries(process.cwd(), (entry) => entry.pageUrl === pageUrl);
1807
- } else {
1808
- discardedEntries = buffer.entries;
1809
- discarded = truncateManualEditsBuffer(process.cwd());
1810
- }
1811
- canceledApplyEvents = cancelPendingManualApplyEvents(pageUrl);
1812
- } catch (err) {
1813
- res.writeHead(500, { 'Content-Type': 'application/json' });
1814
- res.end(JSON.stringify({ error: 'discard_failed', message: err.message }));
1815
- return;
1816
- }
1817
- const { totalCount, perPage } = countPendingByPage(process.cwd());
1818
- recordManualEditActivity('manual_edit_discarded', {
1819
- pageUrl,
1820
- discarded,
1821
- canceledApplyIds: canceledApplyEvents.map((event) => event.id),
1822
- transactionRollback: transactionRollback ? {
1823
- id: transactionRollback.id,
1824
- rolledBackFiles: transactionRollback.rolledBackFiles?.map(summarizeManualLogFile).filter(Boolean) || [],
1825
- rollbackFailures: summarizeManualDiagnostics(transactionRollback.rollbackFailures),
1826
- skipped: transactionRollback.skipped,
1827
- } : undefined,
1828
- totalCount,
1829
- });
1830
- res.writeHead(200, { 'Content-Type': 'application/json' });
1831
- res.end(JSON.stringify({ discarded, entries: discardedEntries, canceledApplyEvents, totalCount, perPage }));
1832
- return;
1833
- }
1834
-
1835
- // Defense in depth: redirect any stragglers from the old /manual-edit endpoint.
1836
- if (p === '/manual-edit' && req.method === 'POST') {
1837
- res.writeHead(410, { 'Content-Type': 'application/json' });
1838
- res.end(JSON.stringify({ error: '/manual-edit is removed; use /manual-edit-stash and /manual-edit-commit for staged copy edits.' }));
1839
- return;
1840
- }
654
+ if (manualEditRoutes(req, res, url)) return;
1841
655
 
1842
656
  // --- Browser→server events (replaces WebSocket messages) ---
1843
657
  if (p === '/events' && req.method === 'POST') {
@@ -2008,9 +822,9 @@ function handlePollPost(req, res) {
2008
822
  res.end(JSON.stringify({ error: 'Unauthorized' }));
2009
823
  return;
2010
824
  }
2011
- const pendingApplyDeferred = state.pendingApplyDeferreds.get(msg.id);
825
+ const pendingApplyDeferred = manualApply.getDeferred(msg.id);
2012
826
  if (pendingApplyDeferred) {
2013
- const validation = validateManualApplyResultMessage(msg, pendingApplyDeferred);
827
+ const validation = manualApply.validateResultMessage(msg, pendingApplyDeferred);
2014
828
  if (!validation.ok) {
2015
829
  recordManualEditActivity('manual_edit_apply_reply_invalid', {
2016
830
  id: msg.id,
@@ -2035,15 +849,15 @@ function handlePollPost(req, res) {
2035
849
  fileCount: validation.result.files.length,
2036
850
  noteCount: validation.result.notes.length,
2037
851
  });
2038
- resolveApplyDeferred(msg.id, validation.result);
852
+ manualApply.resolveDeferred(msg.id, validation.result);
2039
853
  acknowledgePendingEvent(msg.id);
2040
854
  flushPendingPolls();
2041
855
  res.writeHead(200, { 'Content-Type': 'application/json' });
2042
856
  res.end(JSON.stringify({ ok: true }));
2043
857
  return;
2044
858
  }
2045
- if (state.timedOutApplyIds.has(msg.id)) {
2046
- const rollback = rollbackTimedOutApplyReply(msg);
859
+ if (manualApply.hasTimedOutId(msg.id)) {
860
+ const rollback = manualApply.rollbackTimedOutReply(msg);
2047
861
  recordManualEditActivity('manual_edit_apply_stale_reply_rejected', {
2048
862
  id: msg.id,
2049
863
  rolledBackFileCount: rollback.rolledBackFiles?.length || 0,
@@ -2289,13 +1103,12 @@ if (existingRecord?.info) {
2289
1103
 
2290
1104
  state.token = randomUUID();
2291
1105
  state.sessionStore = createLiveSessionStore({ cwd: process.cwd() });
2292
- rollbackManualApplyTransaction({
2293
- cwd: process.cwd(),
1106
+ manualApply.rollbackTransaction({
2294
1107
  reason: 'manual_edit_server_start_recovered_abandoned_transaction',
2295
1108
  });
2296
1109
  applyLegacyDeferredAcceptsOnStartup();
2297
1110
  restorePendingEventsFromStore();
2298
- pruneStaleManualApplyEvidence(process.cwd());
1111
+ manualApply.pruneStaleEvidence();
2299
1112
  const portArg = args.find(a => a.startsWith('--port='));
2300
1113
  state.port = portArg ? parseInt(portArg.split('=')[1], 10) : await findOpenPort();
2301
1114
  // Annotation screenshots live in the project root so the agent's Read tool
@@ -2305,8 +1118,8 @@ const annotRoot = getLiveAnnotationsDir(process.cwd());
2305
1118
  fs.mkdirSync(annotRoot, { recursive: true });
2306
1119
  state.sessionDir = fs.mkdtempSync(path.join(annotRoot, 'session-'));
2307
1120
 
2308
- const { detectScript, sessionPath, livePath } = loadBrowserScripts();
2309
- httpServer = http.createServer(createRequestHandler({ detectScript, sessionPath, livePath }));
1121
+ const { detectScript, liveScriptParts } = loadBrowserScripts();
1122
+ httpServer = http.createServer(createRequestHandler({ detectScript, liveScriptParts }));
2310
1123
 
2311
1124
  httpServer.listen(state.port, '127.0.0.1', () => {
2312
1125
  writeLiveServerInfo(process.cwd(), { pid: process.pid, port: state.port, token: state.token });