@bastani/atomic 0.8.20 → 0.8.21

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 (124) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/builtin/intercom/package.json +1 -1
  3. package/dist/builtin/mcp/package.json +1 -1
  4. package/dist/builtin/subagents/agents/code-simplifier.md +78 -22
  5. package/dist/builtin/subagents/agents/debugger.md +4 -3
  6. package/dist/builtin/subagents/package.json +1 -1
  7. package/dist/builtin/web-access/package.json +1 -1
  8. package/dist/builtin/workflows/CHANGELOG.md +25 -0
  9. package/dist/builtin/workflows/package.json +1 -1
  10. package/dist/builtin/workflows/skills/create-spec/SKILL.md +169 -125
  11. package/dist/builtin/workflows/skills/impeccable/SKILL.md +89 -80
  12. package/dist/builtin/workflows/skills/impeccable/agents/impeccable_asset_producer.toml +92 -0
  13. package/dist/builtin/workflows/skills/impeccable/agents/impeccable_manual_edit_applier.toml +95 -0
  14. package/dist/builtin/workflows/skills/impeccable/agents/openai.yaml +4 -0
  15. package/dist/builtin/workflows/skills/impeccable/reference/adapt.md +122 -1
  16. package/dist/builtin/workflows/skills/impeccable/reference/animate.md +38 -12
  17. package/dist/builtin/workflows/skills/impeccable/reference/audit.md +5 -5
  18. package/dist/builtin/workflows/skills/impeccable/reference/bolder.md +7 -7
  19. package/dist/builtin/workflows/skills/impeccable/reference/brand.md +4 -14
  20. package/dist/builtin/workflows/skills/impeccable/reference/clarify.md +115 -1
  21. package/dist/builtin/workflows/skills/impeccable/reference/codex.md +3 -3
  22. package/dist/builtin/workflows/skills/impeccable/reference/colorize.md +109 -6
  23. package/dist/builtin/workflows/skills/impeccable/reference/craft.md +7 -7
  24. package/dist/builtin/workflows/skills/impeccable/reference/critique.md +623 -94
  25. package/dist/builtin/workflows/skills/impeccable/reference/delight.md +2 -2
  26. package/dist/builtin/workflows/skills/impeccable/reference/distill.md +2 -2
  27. package/dist/builtin/workflows/skills/impeccable/reference/document.md +16 -14
  28. package/dist/builtin/workflows/skills/impeccable/reference/extract.md +1 -1
  29. package/dist/builtin/workflows/skills/impeccable/reference/harden.md +1 -1
  30. package/dist/builtin/workflows/skills/impeccable/reference/init.md +172 -0
  31. package/dist/builtin/workflows/skills/impeccable/reference/interaction-design.md +0 -6
  32. package/dist/builtin/workflows/skills/impeccable/reference/layout.md +33 -13
  33. package/dist/builtin/workflows/skills/impeccable/reference/live.md +96 -19
  34. package/dist/builtin/workflows/skills/impeccable/reference/onboard.md +1 -1
  35. package/dist/builtin/workflows/skills/impeccable/reference/optimize.md +1 -1
  36. package/dist/builtin/workflows/skills/impeccable/reference/overdrive.md +1 -1
  37. package/dist/builtin/workflows/skills/impeccable/reference/polish.md +3 -4
  38. package/dist/builtin/workflows/skills/impeccable/reference/product.md +1 -3
  39. package/dist/builtin/workflows/skills/impeccable/reference/quieter.md +2 -2
  40. package/dist/builtin/workflows/skills/impeccable/reference/shape.md +5 -5
  41. package/dist/builtin/workflows/skills/impeccable/reference/typeset.md +158 -3
  42. package/dist/builtin/workflows/skills/impeccable/scripts/cleanup-deprecated.mjs +1 -1
  43. package/dist/builtin/workflows/skills/impeccable/scripts/command-metadata.json +2 -2
  44. package/dist/builtin/workflows/skills/impeccable/scripts/context-signals.mjs +225 -0
  45. package/dist/builtin/workflows/skills/impeccable/scripts/context.mjs +266 -0
  46. package/dist/builtin/workflows/skills/impeccable/scripts/critique-storage.mjs +17 -1
  47. package/dist/builtin/workflows/skills/impeccable/scripts/design-parser.mjs +16 -1
  48. package/dist/builtin/workflows/skills/impeccable/scripts/detect.mjs +21 -0
  49. package/dist/builtin/workflows/skills/impeccable/scripts/detector/browser/injected/index.mjs +1725 -0
  50. package/dist/builtin/workflows/skills/impeccable/scripts/detector/cli/main.mjs +244 -0
  51. package/dist/builtin/workflows/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +4543 -0
  52. package/dist/builtin/workflows/skills/impeccable/scripts/detector/detect-antipatterns.mjs +43 -0
  53. package/dist/builtin/workflows/skills/impeccable/scripts/detector/engines/browser/detect-url.mjs +252 -0
  54. package/dist/builtin/workflows/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs +535 -0
  55. package/dist/builtin/workflows/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +986 -0
  56. package/dist/builtin/workflows/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs +208 -0
  57. package/dist/builtin/workflows/skills/impeccable/scripts/detector/engines/visual/screenshot-contrast.mjs +189 -0
  58. package/dist/builtin/workflows/skills/impeccable/scripts/detector/findings.mjs +12 -0
  59. package/dist/builtin/workflows/skills/impeccable/scripts/detector/node/file-system.mjs +198 -0
  60. package/dist/builtin/workflows/skills/impeccable/scripts/detector/profile/profiler.mjs +166 -0
  61. package/dist/builtin/workflows/skills/impeccable/scripts/detector/registry/antipatterns.mjs +419 -0
  62. package/dist/builtin/workflows/skills/impeccable/scripts/detector/rules/checks.mjs +2316 -0
  63. package/dist/builtin/workflows/skills/impeccable/scripts/detector/shared/color.mjs +124 -0
  64. package/dist/builtin/workflows/skills/impeccable/scripts/detector/shared/constants.mjs +101 -0
  65. package/dist/builtin/workflows/skills/impeccable/scripts/detector/shared/page.mjs +7 -0
  66. package/dist/builtin/workflows/skills/impeccable/scripts/impeccable-paths.mjs +17 -1
  67. package/dist/builtin/workflows/skills/impeccable/scripts/is-generated.mjs +2 -2
  68. package/dist/builtin/workflows/skills/impeccable/scripts/live-accept.mjs +139 -96
  69. package/dist/builtin/workflows/skills/impeccable/scripts/live-browser.js +4491 -526
  70. package/dist/builtin/workflows/skills/impeccable/scripts/live-commit-manual-edits.mjs +1241 -0
  71. package/dist/builtin/workflows/skills/impeccable/scripts/live-copy-edit-agent.mjs +683 -0
  72. package/dist/builtin/workflows/skills/impeccable/scripts/live-discard-manual-edits.mjs +51 -0
  73. package/dist/builtin/workflows/skills/impeccable/scripts/live-event-validation.mjs +136 -0
  74. package/dist/builtin/workflows/skills/impeccable/scripts/live-inject.mjs +22 -9
  75. package/dist/builtin/workflows/skills/impeccable/scripts/live-insert-ui.mjs +458 -0
  76. package/dist/builtin/workflows/skills/impeccable/scripts/live-insert.mjs +232 -0
  77. package/dist/builtin/workflows/skills/impeccable/scripts/live-manual-edit-evidence.mjs +363 -0
  78. package/dist/builtin/workflows/skills/impeccable/scripts/live-manual-edits-buffer.mjs +152 -0
  79. package/dist/builtin/workflows/skills/impeccable/scripts/live-poll.mjs +288 -110
  80. package/dist/builtin/workflows/skills/impeccable/scripts/live-resume.mjs +47 -1
  81. package/dist/builtin/workflows/skills/impeccable/scripts/live-server.mjs +1443 -100
  82. package/dist/builtin/workflows/skills/impeccable/scripts/live-session-store.mjs +17 -0
  83. package/dist/builtin/workflows/skills/impeccable/scripts/live-status.mjs +17 -3
  84. package/dist/builtin/workflows/skills/impeccable/scripts/live-wrap.mjs +216 -6
  85. package/dist/builtin/workflows/skills/impeccable/scripts/live.mjs +2 -3
  86. package/dist/builtin/workflows/skills/impeccable/scripts/palette.mjs +633 -0
  87. package/dist/builtin/workflows/skills/impeccable/scripts/pin.mjs +1 -1
  88. package/dist/builtin/workflows/src/extension/index.ts +67 -3
  89. package/dist/builtin/workflows/src/extension/render-result.ts +26 -1
  90. package/dist/builtin/workflows/src/runs/foreground/executor.ts +227 -3
  91. package/dist/builtin/workflows/src/runs/foreground/stage-runner.ts +94 -7
  92. package/dist/builtin/workflows/src/shared/stage-prompt.ts +326 -0
  93. package/dist/builtin/workflows/src/shared/stage-ui-broker.ts +62 -7
  94. package/dist/builtin/workflows/src/shared/store-types.ts +43 -0
  95. package/dist/builtin/workflows/src/shared/store.ts +37 -0
  96. package/dist/builtin/workflows/src/tui/chat-surface-message.ts +22 -4
  97. package/dist/builtin/workflows/src/tui/graph-view.ts +47 -0
  98. package/dist/builtin/workflows/src/tui/overlay-adapter.ts +43 -1
  99. package/dist/builtin/workflows/src/tui/run-detail.ts +10 -4
  100. package/dist/builtin/workflows/src/tui/stage-chat-view.ts +117 -15
  101. package/dist/builtin/workflows/src/tui/workflow-attach-pane.ts +9 -0
  102. package/dist/core/skills.d.ts.map +1 -1
  103. package/dist/core/skills.js +2 -5
  104. package/dist/core/skills.js.map +1 -1
  105. package/dist/core/system-prompt.d.ts.map +1 -1
  106. package/dist/core/system-prompt.js +11 -29
  107. package/dist/core/system-prompt.js.map +1 -1
  108. package/dist/index.d.ts +1 -0
  109. package/dist/index.d.ts.map +1 -1
  110. package/dist/index.js +3 -0
  111. package/dist/index.js.map +1 -1
  112. package/docs/quickstart.md +1 -2
  113. package/package.json +4 -4
  114. package/dist/builtin/workflows/skills/impeccable/reference/cognitive-load.md +0 -106
  115. package/dist/builtin/workflows/skills/impeccable/reference/color-and-contrast.md +0 -105
  116. package/dist/builtin/workflows/skills/impeccable/reference/heuristics-scoring.md +0 -234
  117. package/dist/builtin/workflows/skills/impeccable/reference/motion-design.md +0 -109
  118. package/dist/builtin/workflows/skills/impeccable/reference/personas.md +0 -179
  119. package/dist/builtin/workflows/skills/impeccable/reference/responsive-design.md +0 -114
  120. package/dist/builtin/workflows/skills/impeccable/reference/spatial-design.md +0 -100
  121. package/dist/builtin/workflows/skills/impeccable/reference/teach.md +0 -156
  122. package/dist/builtin/workflows/skills/impeccable/reference/typography.md +0 -159
  123. package/dist/builtin/workflows/skills/impeccable/reference/ux-writing.md +0 -107
  124. package/dist/builtin/workflows/skills/impeccable/scripts/load-context.mjs +0 -141
@@ -21,36 +21,36 @@ import path from 'node:path';
21
21
  import net from 'node:net';
22
22
  import { fileURLToPath } from 'node:url';
23
23
  import { parseDesignMd } from './design-parser.mjs';
24
- import { resolveContextDir } from './load-context.mjs';
24
+ import { resolveContextDir } from './context.mjs';
25
25
  import { createLiveSessionStore } from './live-session-store.mjs';
26
+ import { validateEvent } from './live-event-validation.mjs';
26
27
  import {
27
28
  getDesignSidecarPath,
29
+ getLiveDir,
28
30
  getLiveAnnotationsDir,
29
31
  readLiveServerInfo,
30
32
  removeLiveServerInfo,
31
33
  resolveDesignSidecarPath,
32
34
  writeLiveServerInfo,
33
35
  } from './impeccable-paths.mjs';
36
+ 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';
34
45
 
35
46
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
36
- // PRODUCT.md / DESIGN.md live wherever load-context.mjs resolves. The generated
47
+ // PRODUCT.md / DESIGN.md live wherever context.mjs resolves. The generated
37
48
  // DESIGN sidecar is project-local at .impeccable/design.json, with legacy
38
49
  // DESIGN.json fallback for existing projects.
39
50
  const CONTEXT_DIR = resolveContextDir(process.cwd());
40
51
  const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway
41
- const MIN_POLL_TIMEOUT = 1_000;
42
- const MAX_POLL_TIMEOUT = 600_000;
43
- const DEFAULT_LEASE_MS = 30_000;
44
- const MIN_LEASE_MS = 1_000;
45
- const MAX_LEASE_MS = 300_000;
46
52
  const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s
47
53
 
48
- function readBoundedInteger(value, fallback, min, max) {
49
- const parsed = Number.parseInt(String(value ?? ''), 10);
50
- if (!Number.isSafeInteger(parsed)) return fallback;
51
- return Math.min(max, Math.max(min, parsed));
52
- }
53
-
54
54
  // ---------------------------------------------------------------------------
55
55
  // Port detection
56
56
  // ---------------------------------------------------------------------------
@@ -76,19 +76,802 @@ const state = {
76
76
  sseClients: new Set(), // SSE response objects (server→browser push)
77
77
  pendingEvents: [], // browser events waiting for agent ack ({ event, leaseUntil })
78
78
  pendingPolls: [], // agent poll callbacks waiting for browser events
79
+ nextEventSeq: 1,
80
+ lastAgentPollingBroadcast: null,
79
81
  exitTimer: null,
80
82
  sessionDir: null, // per-session tmp dir for annotation screenshots
81
83
  sessionStore: null,
82
84
  leaseTimer: null,
85
+ manualEditActivity: null,
86
+ nextManualEditSeq: 1,
87
+ // Deferreds for in-flight chat-routed Apply events. Keyed by event id; each
88
+ // entry is resolved when the chat agent POSTs an ack carrying the batch
89
+ // result, or rejected when the hard timeout fires.
90
+ pendingApplyDeferreds: new Map(),
91
+ // Updated whenever a /poll long-poll request arrives or is resolved with an
92
+ // event. Used to detect "a chat agent is likely attached" without requiring
93
+ // a poll to be parked at the exact moment we dispatch.
94
+ lastPollAt: 0,
95
+ timedOutApplyIds: new Map(),
83
96
  };
84
97
 
98
+ 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;
106
+ const DEBUG_MANUAL_EDIT_EVENTS = /^(1|true|yes)$/i.test(process.env.IMPECCABLE_LIVE_DEBUG_EVENTS || '');
107
+
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
+ }
115
+
116
+ function chatAgentLikelyActive() {
117
+ if (state.pendingPolls.length > 0) return true;
118
+ if (!state.lastPollAt) return false;
119
+ return Date.now() - state.lastPollAt < CHAT_POLL_FRESHNESS_MS;
120
+ }
121
+
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
+
85
868
  // Cap per-annotation upload size. A full 1920×1080 PNG is typically <1 MB;
86
869
  // cap at 10 MB to guard against runaway writes from a misbehaving client.
87
870
  const MAX_ANNOTATION_BYTES = 10 * 1024 * 1024;
88
871
 
89
872
  function enqueueEvent(event) {
90
873
  if (!event || (event.id && state.pendingEvents.some((entry) => entry.event?.id === event.id && entry.event?.type === event.type))) return;
91
- state.pendingEvents.push({ event, leaseUntil: 0 });
874
+ state.pendingEvents.push({ event, leaseUntil: 0, seq: state.nextEventSeq++ });
92
875
  flushPendingPolls();
93
876
  }
94
877
 
@@ -100,7 +883,11 @@ function restorePendingEventsFromStore() {
100
883
  }
101
884
 
102
885
  function findAvailablePendingEvent(now = Date.now()) {
103
- return state.pendingEvents.find((entry) => !entry.leaseUntil || entry.leaseUntil <= now);
886
+ for (const entry of state.pendingEvents) {
887
+ if (entry.leaseUntil && entry.leaseUntil > now) continue;
888
+ return entry;
889
+ }
890
+ return null;
104
891
  }
105
892
 
106
893
  function leaseEvent(entry, leaseMs) {
@@ -117,9 +904,96 @@ function acknowledgePendingEvent(id) {
117
904
  if (!id) return false;
118
905
  const idx = state.pendingEvents.findIndex((entry) => entry.event?.id === id);
119
906
  if (idx === -1) return false;
907
+ const acknowledged = state.pendingEvents[idx].event;
120
908
  state.pendingEvents.splice(idx, 1);
121
909
  scheduleLeaseFlush();
122
- return true;
910
+ return acknowledged;
911
+ }
912
+
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
+ };
937
+ }
938
+
939
+ function summarizePendingEventForStatus(entry) {
940
+ const event = entry.event || {};
941
+ const summary = {
942
+ id: event.id,
943
+ type: event.type,
944
+ leased: !!(entry.leaseUntil && entry.leaseUntil > Date.now()),
945
+ leaseUntil: entry.leaseUntil || null,
946
+ };
947
+ if (event.type === 'manual_edit_apply') {
948
+ summary.pageUrl = event.pageUrl || null;
949
+ summary.chunk = event.chunk || null;
950
+ summary.repair = event.repair || null;
951
+ 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);
954
+ }
955
+ return summary;
956
+ }
957
+
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);
961
+
962
+ for (let i = state.pendingEvents.length - 1; i >= 0; i -= 1) {
963
+ const event = state.pendingEvents[i]?.event;
964
+ if (!shouldCancel(event)) continue;
965
+ 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
+ });
972
+ }
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));
993
+ }
994
+
995
+ if (canceledById.size > 0) flushPendingPolls();
996
+ return [...canceledById.values()];
123
997
  }
124
998
 
125
999
  function scheduleLeaseFlush() {
@@ -141,16 +1015,31 @@ function scheduleLeaseFlush() {
141
1015
  }
142
1016
 
143
1017
  function flushPendingPolls() {
1018
+ let changed = false;
144
1019
  while (state.pendingPolls.length > 0) {
145
1020
  const entry = findAvailablePendingEvent();
146
1021
  if (!entry) {
147
1022
  scheduleLeaseFlush();
1023
+ broadcastAgentPollingIfChanged();
148
1024
  return;
149
1025
  }
150
1026
  const poll = state.pendingPolls.shift();
151
1027
  poll.resolve(leaseEvent(entry, poll.leaseMs));
1028
+ changed = true;
152
1029
  }
153
1030
  scheduleLeaseFlush();
1031
+ if (changed) broadcastAgentPollingIfChanged();
1032
+ }
1033
+
1034
+ function agentPollingConnected() {
1035
+ return state.pendingPolls.length > 0;
1036
+ }
1037
+
1038
+ function broadcastAgentPollingIfChanged() {
1039
+ const connected = agentPollingConnected();
1040
+ if (state.lastAgentPollingBroadcast === connected) return;
1041
+ state.lastAgentPollingBroadcast = connected;
1042
+ broadcast({ type: 'agent_polling', connected });
154
1043
  }
155
1044
 
156
1045
  /** Push a message to all connected SSE clients. */
@@ -161,15 +1050,107 @@ function broadcast(msg) {
161
1050
  }
162
1051
  }
163
1052
 
1053
+ function recordManualEditActivity(type, details = {}) {
1054
+ const entry = {
1055
+ seq: state.nextManualEditSeq++,
1056
+ type,
1057
+ ts: new Date().toISOString(),
1058
+ ...details,
1059
+ };
1060
+ state.manualEditActivity = entry;
1061
+ if (DEBUG_MANUAL_EDIT_EVENTS) {
1062
+ try {
1063
+ const filePath = path.join(getLiveDir(process.cwd()), 'manual-edit-events.jsonl');
1064
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
1065
+ fs.appendFileSync(filePath, JSON.stringify(entry) + '\n');
1066
+ } catch {
1067
+ /* diagnostics are best-effort; never block live mode on observability */
1068
+ }
1069
+ }
1070
+ broadcast(entry);
1071
+ return entry;
1072
+ }
1073
+
1074
+ function getManualEditStatus() {
1075
+ try {
1076
+ const { totalCount, perPage } = countPendingByPage(process.cwd());
1077
+ return { totalCount, perPage, lastActivity: state.manualEditActivity };
1078
+ } catch (err) {
1079
+ return {
1080
+ totalCount: null,
1081
+ perPage: {},
1082
+ lastActivity: state.manualEditActivity,
1083
+ error: err.message,
1084
+ };
1085
+ }
1086
+ }
1087
+
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
+
164
1143
  // ---------------------------------------------------------------------------
165
1144
  // Load scripts
166
1145
  // ---------------------------------------------------------------------------
167
1146
 
168
1147
  function loadBrowserScripts() {
169
- // Detection script: look relative to the skill scripts dir, then fall back
170
- // to the npm package location (cli/engine/detect-antipatterns-browser.js).
1148
+ // Detection script: prefer the skill-bundled detector, then fall back to
1149
+ // source/npm package locations for local development and older installs.
171
1150
  // This one IS cached — detect.js rarely changes during a session.
172
1151
  const detectPaths = [
1152
+ path.join(__dirname, 'detector', 'detect-antipatterns-browser.js'),
1153
+ path.join(__dirname, '..', '..', 'cli', 'engine', 'detect-antipatterns-browser.js'),
173
1154
  path.join(__dirname, '..', '..', '..', '..', 'cli', 'engine', 'detect-antipatterns-browser.js'),
174
1155
  path.join(process.cwd(), 'node_modules', 'impeccable', 'cli', 'engine', 'detect-antipatterns-browser.js'),
175
1156
  ];
@@ -196,8 +1177,7 @@ function loadBrowserScripts() {
196
1177
  function hasProjectContext() {
197
1178
  // PRODUCT.md carries brand voice / anti-references — that's what determines
198
1179
  // whether variants are brand-aware. DESIGN.md (visual tokens) is a separate
199
- // concern, surfaced by the design panel's own empty state. Legacy
200
- // .impeccable.md is auto-migrated to PRODUCT.md by load-context.mjs.
1180
+ // concern, surfaced by the design panel's own empty state.
201
1181
  try {
202
1182
  fs.accessSync(path.join(CONTEXT_DIR, 'PRODUCT.md'), fs.constants.R_OK);
203
1183
  return true;
@@ -208,67 +1188,6 @@ function statOrNull(filePath) {
208
1188
  try { return fs.statSync(filePath); } catch { return null; }
209
1189
  }
210
1190
 
211
- // ---------------------------------------------------------------------------
212
- // Validation (inline — no external import needed for self-contained script)
213
- // ---------------------------------------------------------------------------
214
-
215
- const VISUAL_ACTIONS = [
216
- 'impeccable', 'bolder', 'quieter', 'distill', 'polish', 'typeset',
217
- 'colorize', 'layout', 'adapt', 'animate', 'delight', 'overdrive',
218
- ];
219
-
220
- // Browser generates ids via crypto.randomUUID().slice(0, 8) (8 hex chars)
221
- // and variantIds via String(small integer). Restrict to those shapes so
222
- // any value that reaches a downstream child_process or DOM selector is
223
- // inert by construction.
224
- const ID_PATTERN = /^[0-9a-f]{8}$/;
225
- const VARIANT_ID_PATTERN = /^[0-9]{1,3}$/;
226
-
227
- function isValidId(v) { return typeof v === 'string' && ID_PATTERN.test(v); }
228
- function isValidVariantId(v) { return typeof v === 'string' && VARIANT_ID_PATTERN.test(v); }
229
-
230
- function validateEvent(msg) {
231
- if (!msg || typeof msg !== 'object' || !msg.type) return 'Missing or invalid message';
232
- switch (msg.type) {
233
- case 'generate':
234
- if (!isValidId(msg.id)) return 'generate: missing or malformed id';
235
- if (!msg.action || !VISUAL_ACTIONS.includes(msg.action)) return 'generate: invalid action';
236
- if (!Number.isInteger(msg.count) || msg.count < 1 || msg.count > 8) return 'generate: count must be 1-8';
237
- if (!msg.element || !msg.element.outerHTML) return 'generate: missing element context';
238
- // Optional annotation fields (all-or-nothing: if any present, all must be well-formed).
239
- if (msg.screenshotPath !== undefined && typeof msg.screenshotPath !== 'string') return 'generate: screenshotPath must be string';
240
- if (msg.comments !== undefined && !Array.isArray(msg.comments)) return 'generate: comments must be array';
241
- if (msg.strokes !== undefined && !Array.isArray(msg.strokes)) return 'generate: strokes must be array';
242
- return null;
243
- case 'accept':
244
- if (!isValidId(msg.id)) return 'accept: missing or malformed id';
245
- if (!isValidVariantId(msg.variantId)) return 'accept: missing or malformed variantId';
246
- if (msg.paramValues !== undefined) {
247
- if (typeof msg.paramValues !== 'object' || msg.paramValues === null || Array.isArray(msg.paramValues)) {
248
- return 'accept: paramValues must be an object';
249
- }
250
- }
251
- return null;
252
- case 'discard':
253
- return isValidId(msg.id) ? null : 'discard: missing or malformed id';
254
- case 'checkpoint':
255
- if (!isValidId(msg.id)) return 'checkpoint: missing or malformed id';
256
- if (!Number.isInteger(msg.revision) || msg.revision < 0) return 'checkpoint: revision must be a non-negative integer';
257
- if (msg.paramValues !== undefined && (typeof msg.paramValues !== 'object' || msg.paramValues === null || Array.isArray(msg.paramValues))) {
258
- return 'checkpoint: paramValues must be an object';
259
- }
260
- return null;
261
- case 'exit':
262
- return null;
263
- case 'prefetch':
264
- if (!msg.pageUrl || typeof msg.pageUrl !== 'string') return 'prefetch: missing pageUrl';
265
- return null;
266
- default:
267
- return 'Unknown event type: ' + msg.type;
268
- }
269
- }
270
-
271
- // ---------------------------------------------------------------------------
272
1191
  // HTTP request handler
273
1192
  // ---------------------------------------------------------------------------
274
1193
 
@@ -405,13 +1324,10 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) {
405
1324
  status: 'ok',
406
1325
  port: state.port,
407
1326
  connectedClients: state.sseClients.size,
408
- pendingEvents: state.pendingEvents.map((entry) => ({
409
- id: entry.event?.id,
410
- type: entry.event?.type,
411
- leased: !!(entry.leaseUntil && entry.leaseUntil > Date.now()),
412
- leaseUntil: entry.leaseUntil || null,
413
- })),
1327
+ pendingEvents: state.pendingEvents.map((entry) => summarizePendingEventForStatus(entry)),
1328
+ agentPolling: agentPollingConnected(),
414
1329
  activeSessions: sessions,
1330
+ manualEdits: getManualEditStatus(),
415
1331
  }));
416
1332
  return;
417
1333
  }
@@ -515,6 +1431,7 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) {
515
1431
  res.write('data: ' + JSON.stringify({
516
1432
  type: 'connected',
517
1433
  hasProjectContext: hasProjectContext(),
1434
+ agentPolling: agentPollingConnected(),
518
1435
  }) + '\n\n');
519
1436
 
520
1437
  state.sseClients.add(res);
@@ -538,6 +1455,335 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) {
538
1455
  return;
539
1456
  }
540
1457
 
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
+ }
1786
+
541
1787
  // --- Browser→server events (replaces WebSocket messages) ---
542
1788
  if (p === '/events' && req.method === 'POST') {
543
1789
  let body = '';
@@ -554,6 +1800,18 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) {
554
1800
  res.end(JSON.stringify({ error: 'Unauthorized' }));
555
1801
  return;
556
1802
  }
1803
+ // Defense in depth: manual copy edits must use the staged stash/apply
1804
+ // endpoints. The direct Save event path is disabled in the browser.
1805
+ if (msg.type === 'manual_edits') {
1806
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1807
+ res.end(JSON.stringify({ error: 'manual_edits must POST to /manual-edit-stash, not /events' }));
1808
+ return;
1809
+ }
1810
+ if (msg.type === 'manual_edit_apply') {
1811
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1812
+ res.end(JSON.stringify({ error: 'manual_edit_apply is disabled; use /manual-edit-stash then /manual-edit-commit' }));
1813
+ return;
1814
+ }
557
1815
  const error = validateEvent(msg);
558
1816
  if (error) {
559
1817
  res.writeHead(400, { 'Content-Type': 'application/json' });
@@ -569,7 +1827,9 @@ function createRequestHandler({ detectScript, sessionPath, livePath }) {
569
1827
  return;
570
1828
  }
571
1829
  }
572
- if (msg.type !== 'checkpoint') enqueueEvent(msg);
1830
+ if (msg.type !== 'checkpoint') {
1831
+ enqueueEvent(msg);
1832
+ }
573
1833
  res.writeHead(200, { 'Content-Type': 'application/json' });
574
1834
  res.end(JSON.stringify({ ok: true }));
575
1835
  });
@@ -611,8 +1871,9 @@ function handlePollGet(req, res, url) {
611
1871
  res.end(JSON.stringify({ error: 'Unauthorized' }));
612
1872
  return;
613
1873
  }
614
- const timeout = readBoundedInteger(url.searchParams.get('timeout'), DEFAULT_POLL_TIMEOUT, MIN_POLL_TIMEOUT, MAX_POLL_TIMEOUT);
615
- const leaseMs = readBoundedInteger(url.searchParams.get('leaseMs'), DEFAULT_LEASE_MS, MIN_LEASE_MS, MAX_LEASE_MS);
1874
+ state.lastPollAt = Date.now();
1875
+ const timeout = parseInt(url.searchParams.get('timeout') || DEFAULT_POLL_TIMEOUT, 10);
1876
+ const leaseMs = parseInt(url.searchParams.get('leaseMs') || '30000', 10);
616
1877
  const available = findAvailablePendingEvent();
617
1878
  if (available) {
618
1879
  res.writeHead(200, { 'Content-Type': 'application/json' });
@@ -623,20 +1884,24 @@ function handlePollGet(req, res, url) {
623
1884
  const timer = setTimeout(() => {
624
1885
  const idx = state.pendingPolls.indexOf(poll);
625
1886
  if (idx !== -1) state.pendingPolls.splice(idx, 1);
1887
+ broadcastAgentPollingIfChanged();
626
1888
  res.writeHead(200, { 'Content-Type': 'application/json' });
627
1889
  res.end(JSON.stringify({ type: 'timeout' }));
628
1890
  }, timeout);
629
1891
  function resolve(event) {
630
1892
  clearTimeout(timer);
1893
+ state.lastPollAt = Date.now();
631
1894
  res.writeHead(200, { 'Content-Type': 'application/json' });
632
1895
  res.end(JSON.stringify(event));
633
1896
  }
634
1897
  state.pendingPolls.push(poll);
1898
+ broadcastAgentPollingIfChanged();
635
1899
  scheduleLeaseFlush();
636
1900
  req.on('close', () => {
637
1901
  clearTimeout(timer);
638
1902
  const idx = state.pendingPolls.indexOf(poll);
639
1903
  if (idx !== -1) state.pendingPolls.splice(idx, 1);
1904
+ broadcastAgentPollingIfChanged();
640
1905
  });
641
1906
  }
642
1907
 
@@ -655,21 +1920,90 @@ function handlePollPost(req, res) {
655
1920
  res.end(JSON.stringify({ error: 'Unauthorized' }));
656
1921
  return;
657
1922
  }
658
- acknowledgePendingEvent(msg.id);
659
- if (state.sessionStore && msg.id) {
1923
+ const pendingApplyDeferred = state.pendingApplyDeferreds.get(msg.id);
1924
+ if (pendingApplyDeferred) {
1925
+ const validation = validateManualApplyResultMessage(msg, pendingApplyDeferred);
1926
+ if (!validation.ok) {
1927
+ recordManualEditActivity('manual_edit_apply_reply_invalid', {
1928
+ id: msg.id,
1929
+ pageUrl: pendingApplyDeferred.pageUrl,
1930
+ chunk: pendingApplyDeferred.event?.chunk || null,
1931
+ repair: pendingApplyDeferred.event?.repair || null,
1932
+ reason: validation.body?.reason || validation.body?.error || 'invalid_manual_apply_result',
1933
+ status: msg.data?.status || null,
1934
+ });
1935
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1936
+ res.end(JSON.stringify(validation.body));
1937
+ return;
1938
+ }
1939
+ recordManualEditActivity('manual_edit_apply_reply_received', {
1940
+ id: msg.id,
1941
+ pageUrl: pendingApplyDeferred.pageUrl,
1942
+ chunk: pendingApplyDeferred.event?.chunk || null,
1943
+ repair: pendingApplyDeferred.event?.repair || null,
1944
+ status: validation.result.status,
1945
+ appliedCount: validation.result.appliedEntryIds.length,
1946
+ failed: summarizeManualApplyFailures(validation.result.failed),
1947
+ fileCount: validation.result.files.length,
1948
+ noteCount: validation.result.notes.length,
1949
+ });
1950
+ resolveApplyDeferred(msg.id, validation.result);
1951
+ acknowledgePendingEvent(msg.id);
1952
+ flushPendingPolls();
1953
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1954
+ res.end(JSON.stringify({ ok: true }));
1955
+ return;
1956
+ }
1957
+ if (state.timedOutApplyIds.has(msg.id)) {
1958
+ const rollback = rollbackTimedOutApplyReply(msg);
1959
+ recordManualEditActivity('manual_edit_apply_stale_reply_rejected', {
1960
+ id: msg.id,
1961
+ rolledBackFileCount: rollback.rolledBackFiles?.length || 0,
1962
+ rollbackFailureCount: rollback.rollbackFailures?.length || 0,
1963
+ });
1964
+ res.writeHead(409, { 'Content-Type': 'application/json' });
1965
+ res.end(JSON.stringify({ error: 'stale_manual_edit_apply_reply', ...rollback }));
1966
+ return;
1967
+ }
1968
+ const acknowledgedEvent = acknowledgePendingEvent(msg.id);
1969
+ let skipJournalReply = false;
1970
+ let existingSession = null;
1971
+ if (!acknowledgedEvent && state.sessionStore && msg.id) {
1972
+ try {
1973
+ existingSession = state.sessionStore.getSnapshot(msg.id, { includeCompleted: true });
1974
+ if (!existingSession?.updatedAt) existingSession = null;
1975
+ skipJournalReply = existingSession?.phase === 'completed' || existingSession?.phase === 'discarded';
1976
+ } catch { /* fall through and record the reply normally */ }
1977
+ }
1978
+ if (!acknowledgedEvent && !existingSession) {
1979
+ recordManualEditActivity('manual_edit_poll_reply_unknown', {
1980
+ id: msg.id || null,
1981
+ type: msg.type || null,
1982
+ });
1983
+ res.writeHead(msg.id ? 404 : 400, { 'Content-Type': 'application/json' });
1984
+ res.end(JSON.stringify({
1985
+ error: msg.id ? 'unknown_poll_reply_id' : 'missing_poll_reply_id',
1986
+ id: msg.id,
1987
+ }));
1988
+ return;
1989
+ }
1990
+ if (state.sessionStore && msg.id && !skipJournalReply) {
660
1991
  try {
661
- const eventType = msg.type === 'discard' || msg.type === 'discarded'
662
- ? 'discarded'
663
- : msg.type === 'complete'
664
- ? 'complete'
665
- : msg.type === 'error'
666
- ? 'agent_error'
667
- : 'agent_done';
1992
+ const eventType = msg.type === 'steer_done'
1993
+ ? 'steer_done'
1994
+ : msg.type === 'discard' || msg.type === 'discarded'
1995
+ ? 'discarded'
1996
+ : msg.type === 'complete'
1997
+ ? 'complete'
1998
+ : msg.type === 'error'
1999
+ ? 'agent_error'
2000
+ : 'agent_done';
668
2001
  state.sessionStore.appendEvent({
669
2002
  type: eventType,
670
2003
  id: msg.id,
671
2004
  file: msg.file,
672
2005
  message: msg.message,
2006
+ sourceEventType: acknowledgedEvent?.type,
673
2007
  carbonize: msg.data?.carbonize === true,
674
2008
  });
675
2009
  } catch { /* keep reply path best-effort; browser still needs SSE */ }
@@ -732,6 +2066,9 @@ Endpoints:
732
2066
  /annotation POST raw image/png to stage a variant screenshot
733
2067
  /events SSE stream (server→browser) + POST (browser→server)
734
2068
  /poll Long-poll for agent CLI
2069
+ /manual-edit-stash Stage browser copy edits
2070
+ /manual-edit-commit Apply staged browser copy edits
2071
+ /manual-edit-discard Discard staged browser copy edits
735
2072
  /source Raw source file reader (no-HMR fallback)
736
2073
  /status Durable recovery status (token-protected)
737
2074
  /health Health check`);
@@ -821,7 +2158,12 @@ if (existingRecord?.info) {
821
2158
 
822
2159
  state.token = randomUUID();
823
2160
  state.sessionStore = createLiveSessionStore({ cwd: process.cwd() });
2161
+ rollbackManualApplyTransaction({
2162
+ cwd: process.cwd(),
2163
+ reason: 'manual_edit_server_start_recovered_abandoned_transaction',
2164
+ });
824
2165
  restorePendingEventsFromStore();
2166
+ pruneStaleManualApplyEvidence(process.cwd());
825
2167
  const portArg = args.find(a => a.startsWith('--port='));
826
2168
  state.port = portArg ? parseInt(portArg.split('=')[1], 10) : await findOpenPort();
827
2169
  // Annotation screenshots live in the project root so the agent's Read tool
@@ -839,7 +2181,8 @@ httpServer.listen(state.port, '127.0.0.1', () => {
839
2181
  const url = `http://localhost:${state.port}`;
840
2182
  console.log(`\nImpeccable live server running on ${url}`);
841
2183
  console.log(`Token: ${state.token}\n`);
842
- console.log(`Inject: <script src="${url}/live.js"><\/script>`);
2184
+ console.log(`Script: ${url}/live.js`);
2185
+ console.log('Inject: managed by live-inject.mjs; Astro source tags use is:inline automatically.');
843
2186
  console.log(`Stop: node ${path.basename(fileURLToPath(import.meta.url))} stop`);
844
2187
  });
845
2188