@nynb/sandpaper 0.1.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.
package/src/claude.js ADDED
@@ -0,0 +1,226 @@
1
+ // claude.js — the bridge to Claude Code.
2
+ // Spawns `claude -p` in stream-json mode against the document's folder, maps the
3
+ // event stream to a small set of UI status states, and persists the session id so
4
+ // every turn resumes the same conversation (context survives across turns).
5
+ import { spawn } from 'node:child_process';
6
+ import { dirname, basename, join } from 'node:path';
7
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
8
+ import { homedir } from 'node:os';
9
+
10
+ // Appended per turn instead of a repo-wide CLAUDE.md, so the contract is scoped to
11
+ // the editing job and doesn't pollute the project's own Claude guidance.
12
+ const CONTRACT = [
13
+ 'You are the editing engine behind Sandpaper. A human is refining a single,',
14
+ 'self-contained HTML document live in their browser.',
15
+ '- Edit the target HTML file in place with the Edit tool. Keep it valid and self-contained.',
16
+ '- Make the SMALLEST change that satisfies the request. Never regenerate unrelated parts.',
17
+ '- Preserve every existing data-cid attribute exactly; give new block elements unique ones.',
18
+ '- When told a specific element (by data-cid or CSS selector), change ONLY that element.',
19
+ "- Preserve the document's structure, style, and voice unless asked to change them.",
20
+ ].join('\n');
21
+
22
+ const SESSION_TOOLS = ['Read', 'Edit', 'Write', 'MultiEdit'];
23
+
24
+ // ---- pure helpers (unit-tested in test/parse-test.js) ----
25
+
26
+ // Returns a session id if this event carries one (the init system event), else null.
27
+ export function getSessionId(ev) {
28
+ return ev && ev.type === 'system' && ev.subtype === 'init' && ev.session_id
29
+ ? ev.session_id
30
+ : null;
31
+ }
32
+
33
+ // Summarize an Edit/Write/MultiEdit tool_use block (taken from a COMPLETE assistant
34
+ // message, where `input` is fully formed — never from partial input_json_delta) into a
35
+ // typed `edit` frame for the conversation surface's "what changed" card.
36
+ function summarizeEdit(block, docName) {
37
+ const input = block.input || {};
38
+ const name = block.name;
39
+ let hunks;
40
+ if (name === 'MultiEdit' && Array.isArray(input.edits)) {
41
+ hunks = input.edits.map((e) => ({ oldText: e.old_string || '', newText: e.new_string || '' }));
42
+ } else if (name === 'Write') {
43
+ hunks = [{ oldText: '', newText: input.content || '' }];
44
+ } else {
45
+ hunks = [{ oldText: input.old_string || '', newText: input.new_string || '' }];
46
+ }
47
+ let added = 0, removed = 0;
48
+ const cids = new Set();
49
+ for (const h of hunks) {
50
+ if (h.oldText) removed += h.oldText.split('\n').length;
51
+ if (h.newText) added += h.newText.split('\n').length;
52
+ for (const t of [h.oldText, h.newText]) {
53
+ for (const m of String(t).matchAll(/data-cid="([^"]+)"/g)) cids.add(m[1]);
54
+ }
55
+ }
56
+ const file = input.file_path ? basename(input.file_path) : docName;
57
+ return { type: 'edit', tool: name, file, hunks, added, removed, cids: [...cids] };
58
+ }
59
+
60
+ // Map one stream-json event to an ARRAY of typed frames for the conversation surface:
61
+ // {type:'status', …} → drives the 7-state chip (unchanged behaviour)
62
+ // {type:'assistant_delta', …} → streamed reply text / thinking (previously DROPPED — the bug)
63
+ // {type:'edit', …} → a per-edit change summary
64
+ // Returns [] for events that carry nothing renderable.
65
+ export function mapEvents(ev, docName) {
66
+ if (!ev || !ev.type) return [];
67
+
68
+ if (ev.type === 'system' && ev.subtype === 'init') {
69
+ return [{ type: 'status', state: 'init', label: 'starting…' }];
70
+ }
71
+
72
+ if (ev.type === 'stream_event' && ev.event) {
73
+ const e = ev.event;
74
+ if (e.type === 'message_start') {
75
+ return [{ type: 'status', state: 'thinking', label: 'thinking…' }];
76
+ }
77
+ if (e.type === 'content_block_start' && e.content_block && e.content_block.type === 'tool_use') {
78
+ const tool = e.content_block.name || 'tool';
79
+ const editing = tool === 'Edit' || tool === 'Write' || tool === 'MultiEdit';
80
+ return [editing
81
+ ? { type: 'status', state: 'editing', label: `editing ${docName}` }
82
+ : { type: 'status', state: 'tool_using', label: `${tool.toLowerCase()}…` }];
83
+ }
84
+ if (e.type === 'content_block_delta' && e.delta) {
85
+ if (e.delta.type === 'text_delta' && e.delta.text) {
86
+ return [{ type: 'assistant_delta', kind: 'text', text: e.delta.text }];
87
+ }
88
+ if (e.delta.type === 'thinking_delta' && e.delta.thinking) {
89
+ return [{ type: 'assistant_delta', kind: 'thinking', text: e.delta.thinking }];
90
+ }
91
+ }
92
+ return [];
93
+ }
94
+
95
+ // A COMPLETE assistant message: pull edit summaries from it (text was already streamed
96
+ // via deltas, so we do NOT re-emit it here — that would double-render).
97
+ if (ev.type === 'assistant' && ev.message && Array.isArray(ev.message.content)) {
98
+ const edits = [];
99
+ for (const block of ev.message.content) {
100
+ if (block.type === 'tool_use' && (block.name === 'Edit' || block.name === 'Write' || block.name === 'MultiEdit')) {
101
+ edits.push(summarizeEdit(block, docName));
102
+ }
103
+ }
104
+ return edits;
105
+ }
106
+
107
+ if (ev.type === 'result') {
108
+ if (ev.is_error || (ev.subtype && ev.subtype !== 'success')) {
109
+ return [{ type: 'status', state: 'error', label: 'turn failed', detail: String(ev.result || '').slice(0, 200) }];
110
+ }
111
+ // The toolbar derives "Replied" vs "Saved" from whether any edit frame arrived this turn,
112
+ // so the chip stays neutral here — no more hardcoded "Saved" on pure-discussion turns.
113
+ const cost = typeof ev.total_cost_usd === 'number' ? ev.total_cost_usd : null;
114
+ return [{ type: 'status', state: 'done', label: 'done', cost, done: true }];
115
+ }
116
+
117
+ return [];
118
+ }
119
+
120
+ // ---- session persistence ----
121
+
122
+ function sessionPath(docDir) { return join(docDir, '.sandpaper', 'session.json'); }
123
+
124
+ function loadSession(docDir) {
125
+ try { return JSON.parse(readFileSync(sessionPath(docDir), 'utf8')).sessionId || null; }
126
+ catch { return null; }
127
+ }
128
+
129
+ function saveSession(docDir, id) {
130
+ const dir = join(docDir, '.sandpaper');
131
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
132
+ writeFileSync(sessionPath(docDir), JSON.stringify({ sessionId: id }, null, 2));
133
+ }
134
+
135
+ // ---- environment hygiene (mirrors amux _on_claude_plan / _claude_oneshot env scrub) ----
136
+
137
+ // True if the user is signed into a Claude subscription (Pro/Max) rather than API billing.
138
+ function onClaudePlan() {
139
+ try { return !!JSON.parse(readFileSync(join(homedir(), '.claude.json'), 'utf8')).oauthAccount; }
140
+ catch { return false; }
141
+ }
142
+
143
+ // The child is a fresh, top-level `claude` run — not nested inside whatever process spawned
144
+ // us. Strip the markers that would make it think it's running inside Claude Code, and on a
145
+ // subscription drop any exported ANTHROPIC_API_KEY so turns bill the plan, not the API.
146
+ function childEnv() {
147
+ const env = { ...process.env };
148
+ delete env.CLAUDECODE;
149
+ delete env.CLAUDE_CODE_ENTRYPOINT;
150
+ if (onClaudePlan()) delete env.ANTHROPIC_API_KEY;
151
+ return env;
152
+ }
153
+
154
+ // ---- the turn ----
155
+
156
+ // Run one refinement turn. `onStatus({state,label,detail?,done?})` is called as the
157
+ // stream advances. Returns the child process (or null if spawn failed synchronously).
158
+ export function runTurn(docPath, prompt, onStatus) {
159
+ const docDir = dirname(docPath);
160
+ const docName = basename(docPath);
161
+ const prior = loadSession(docDir);
162
+
163
+ const args = [
164
+ '-p', prompt,
165
+ '--output-format', 'stream-json', '--verbose', '--include-partial-messages',
166
+ '--permission-mode', 'acceptEdits',
167
+ '--allowedTools', SESSION_TOOLS.join(','),
168
+ '--append-system-prompt', CONTRACT,
169
+ ];
170
+ if (prior) args.push('--resume', prior);
171
+
172
+ let child;
173
+ try {
174
+ // cwd = the document's folder: this is what makes --resume's directory-scoped
175
+ // session lookup reliable, and keeps Claude's relative paths anchored to the doc.
176
+ // stdin 'ignore' so a non-interactive `-p` run can never block waiting on input.
177
+ child = spawn('claude', args, { cwd: docDir, env: childEnv(), stdio: ['ignore', 'pipe', 'pipe'] });
178
+ } catch (err) {
179
+ onStatus({ type: 'status', state: 'error', label: 'Could not start claude', detail: err.message });
180
+ return null;
181
+ }
182
+
183
+ onStatus({ type: 'status', state: 'init', label: 'starting…' });
184
+
185
+ let buf = '';
186
+ let errored = false;
187
+
188
+ const processLine = (raw) => {
189
+ const line = raw.trim();
190
+ if (!line) return;
191
+ let ev;
192
+ try { ev = JSON.parse(line); } catch { return; } // ignore non-JSON noise
193
+ const id = getSessionId(ev);
194
+ if (id) saveSession(docDir, id);
195
+ for (const frame of mapEvents(ev, docName)) onStatus(frame);
196
+ };
197
+
198
+ child.stdout.on('data', (chunk) => {
199
+ buf += chunk.toString();
200
+ let nl;
201
+ while ((nl = buf.indexOf('\n')) >= 0) {
202
+ processLine(buf.slice(0, nl));
203
+ buf = buf.slice(nl + 1);
204
+ }
205
+ });
206
+ // Flush a final line that arrives without a trailing newline (else the `result`
207
+ // event — cost + "Saved" confirmation — can be silently dropped on abrupt exit).
208
+ child.stdout.on('end', () => { processLine(buf); buf = ''; });
209
+
210
+ let stderr = '';
211
+ child.stderr.on('data', (d) => { stderr += d.toString(); });
212
+ child.on('error', (err) => {
213
+ errored = true;
214
+ onStatus({ type: 'status', state: 'error', label: 'claude not found — is it installed?', detail: err.message });
215
+ });
216
+ child.on('close', (code) => {
217
+ if (errored) return; // 'error' already surfaced the real failure — don't overwrite it with idle
218
+ if (code && code !== 0) {
219
+ onStatus({ type: 'status', state: 'error', label: `claude exited (${code})`, detail: stderr.slice(0, 300) });
220
+ } else {
221
+ onStatus({ type: 'status', state: 'idle', label: 'idle' });
222
+ }
223
+ });
224
+
225
+ return child;
226
+ }
package/src/edit.js ADDED
@@ -0,0 +1,113 @@
1
+ // edit.js — direct (no-AI) in-place edits.
2
+ // The browser manipulates the rendered DOM of one element; these functions splice that change
3
+ // back into the SOURCE FILE around it, leaving the rest of the file byte-for-byte intact.
4
+ // Pure + dependency-free (Sandpaper ships zero runtime deps) so it can be unit-tested in isolation.
5
+ //
6
+ // All operations locate an element by its data-cid="…" attribute. The tricky parts, all handled in
7
+ // locate(): the literal string can appear in prose/comments (skip those), the element can contain
8
+ // NESTED same-name tags (count depth), and void/self-closing elements have no body (reject).
9
+
10
+ const VOID = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
11
+ 'link', 'meta', 'param', 'source', 'track', 'wbr']);
12
+
13
+ // Find the element carrying data-cid="<cid>" and return ALL its byte boundaries, or null:
14
+ // { tag, openStart, innerStart, innerEnd, closeEnd }
15
+ // openStart — the '<' of the opening tag
16
+ // innerStart — first byte after the opening tag's '>'
17
+ // innerEnd — the '<' of the matching close tag
18
+ // closeEnd — first byte after the matching '</tag>'
19
+ function locate(html, cid) {
20
+ const marker = 'data-cid="' + cid + '"';
21
+ let at = html.indexOf(marker);
22
+ while (at >= 0) {
23
+ const lt = html.lastIndexOf('<', at);
24
+ // genuine attribute ⇒ still INSIDE an opening tag: no '>' sits between '<' and the marker.
25
+ if (lt >= 0 && html.lastIndexOf('>', at) < lt) {
26
+ const name = /^<([a-zA-Z][\w:-]*)/.exec(html.slice(lt));
27
+ if (name) {
28
+ const tag = name[1];
29
+ const openEnd = html.indexOf('>', at);
30
+ if (openEnd >= 0 && html[openEnd - 1] !== '/' && !VOID.has(tag.toLowerCase())) {
31
+ const tok = new RegExp('<' + tag + '(?=[\\s/>])|</' + tag + '\\s*>', 'gi');
32
+ tok.lastIndex = openEnd + 1;
33
+ let depth = 1, m;
34
+ while ((m = tok.exec(html))) {
35
+ if (m[0][1] === '/') {
36
+ if (--depth === 0) {
37
+ return { tag, openStart: lt, innerStart: openEnd + 1, innerEnd: m.index, closeEnd: tok.lastIndex };
38
+ }
39
+ } else depth++;
40
+ }
41
+ }
42
+ }
43
+ }
44
+ at = html.indexOf(marker, at + marker.length); // this occurrence wasn't usable — try the next
45
+ }
46
+ return null;
47
+ }
48
+
49
+ // Range of the element's INNER content (between the tags).
50
+ export function innerRange(html, cid) {
51
+ const r = locate(html, cid);
52
+ return r ? { tag: r.tag, start: r.innerStart, end: r.innerEnd } : null;
53
+ }
54
+
55
+ // Range of the WHOLE element (tags included).
56
+ export function outerRange(html, cid) {
57
+ const r = locate(html, cid);
58
+ return r ? { tag: r.tag, start: r.openStart, end: r.closeEnd } : null;
59
+ }
60
+
61
+ // Replace the element's inner content. Returns the new file string, or null if not located.
62
+ export function replaceInner(html, cid, inner) {
63
+ const r = locate(html, cid);
64
+ return r ? html.slice(0, r.innerStart) + inner + html.slice(r.innerEnd) : null;
65
+ }
66
+
67
+ // True if the bytes from a line start up to `pos` are only spaces/tabs (i.e. `pos` begins a line).
68
+ function leadingIndent(html, pos) {
69
+ const ls = html.lastIndexOf('\n', pos - 1) + 1; // 0 if no preceding newline
70
+ const lead = html.slice(ls, pos);
71
+ return /^[ \t]*$/.test(lead) ? { lineStart: ls, indent: lead } : null;
72
+ }
73
+
74
+ // Remove the element entirely. Returns { html, removed } (removed = the element's outer HTML) or null.
75
+ // If the element sits alone on its line, the line's leading whitespace + the preceding newline go too,
76
+ // so no blank gap is left behind.
77
+ export function removeElement(html, cid) {
78
+ const r = outerRange(html, cid);
79
+ if (!r) return null;
80
+ const removed = html.slice(r.start, r.end);
81
+ let from = r.start, to = r.end;
82
+ const li = leadingIndent(html, r.start);
83
+ if (li) {
84
+ if (li.lineStart > 0) from = li.lineStart - 1; // consume the newline that ended the previous line
85
+ else if (html[to] === '\n') to++; // at file start: there is none — consume the trailing one
86
+ }
87
+ return { html: html.slice(0, from) + html.slice(to), removed };
88
+ }
89
+
90
+ // Move the element to sit `mode` ('before' | 'after') the element with cid=target. Returns { html } or null.
91
+ // The moved element's own bytes are preserved verbatim; only the indentation around the two seams changes.
92
+ export function moveElement(html, cid, target, mode) {
93
+ if (!target || target === cid || (mode !== 'before' && mode !== 'after')) return null;
94
+ const r = outerRange(html, cid);
95
+ if (!r) return null;
96
+ const element = html.slice(r.start, r.end);
97
+ // lift the element out, taking its own line's leading whitespace + the preceding newline with it
98
+ let from = r.start;
99
+ const li = leadingIndent(html, r.start);
100
+ if (li) from = li.lineStart === 0 ? 0 : li.lineStart - 1;
101
+ const without = html.slice(0, from) + html.slice(r.end);
102
+ // locate the target in the reduced document (target must still exist — not a descendant of the moved node)
103
+ const t = outerRange(without, target);
104
+ if (!t) return null;
105
+ const tli = leadingIndent(without, t.start);
106
+ const indent = tli ? tli.indent : '';
107
+ // only break onto a new line when the target is block-indented; inline targets stay inline.
108
+ if (mode === 'before') {
109
+ const at = tli ? tli.lineStart : t.start;
110
+ return { html: without.slice(0, at) + indent + element + (indent ? '\n' : '') + without.slice(at) };
111
+ }
112
+ return { html: without.slice(0, t.end) + (indent ? '\n' + indent : '') + element + without.slice(t.end) };
113
+ }
package/src/server.js ADDED
@@ -0,0 +1,327 @@
1
+ // server.js — the local bridge server.
2
+ // Serves a document OR a whole folder (e.g. the project brain), injecting the on-page
3
+ // toolbar at response time only, relaying page → Claude turns, streaming status over SSE,
4
+ // and live-reloading the affected page when its file changes.
5
+ import { createServer } from 'node:http';
6
+ import { readFile, writeFile, readFileSync, writeFileSync, watch, copyFileSync, mkdirSync, existsSync, realpathSync } from 'node:fs';
7
+ import { join, dirname, basename, extname, normalize, sep } from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { randomUUID, createHash } from 'node:crypto';
10
+ import { runTurn } from './claude.js';
11
+ import { replaceInner, removeElement, moveElement } from './edit.js';
12
+
13
+ const HERE = dirname(fileURLToPath(import.meta.url));
14
+ const PUBLIC = join(HERE, '..', 'public');
15
+
16
+ const MIME = {
17
+ '.html': 'text/html; charset=utf-8', '.js': 'text/javascript', '.css': 'text/css',
18
+ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif',
19
+ '.svg': 'image/svg+xml', '.webp': 'image/webp', '.json': 'application/json',
20
+ };
21
+
22
+ // `target` is a file (single-doc mode) or a directory (folder/brain mode, opts.brain=true).
23
+ export function startServer(target, port, opts = {}) {
24
+ const isDir = !!opts.brain;
25
+ const root = isDir ? target : dirname(target); // the directory we serve from
26
+ const defaultDoc = isDir ? 'index.html' : basename(target); // what "/" resolves to
27
+ const clients = new Set();
28
+ const turnSnapshots = new Map(); // turnId -> the page file it snapshotted (so undo restores the right file)
29
+ let activeTurn = null;
30
+ let activeTurnPage = null; // the URL path the active turn is editing (for the reload frame)
31
+ let reloadPending = false; // a file change happened mid-turn; reload once it ends
32
+ let suppressReloadUntil = 0; // a direct in-place edit we just wrote — the browser already shows it
33
+
34
+ // One-level undo for direct (no-AI) edits: snapshot a page just before we mutate it.
35
+ const directSnapDir = join(root, '.sandpaper', 'snapshots', 'direct');
36
+ const directSnaps = new Map(); // pageFile -> its pre-edit snapshot file (recorded only after a successful write)
37
+ const takeDirectSnap = (pageFile) => { // copy the current file aside; return the snapshot path, or null
38
+ try {
39
+ if (!existsSync(directSnapDir)) mkdirSync(directSnapDir, { recursive: true });
40
+ const rel = pageFile.startsWith(root + sep) ? pageFile.slice(root.length + 1) : basename(pageFile);
41
+ const snap = join(directSnapDir, createHash('sha1').update(rel).digest('hex').slice(0, 16) + '.html');
42
+ copyFileSync(pageFile, snap);
43
+ return snap;
44
+ } catch { return null; } // best-effort; never block the edit
45
+ };
46
+
47
+ // Apply a direct (no-AI) edit ATOMICALLY: sync read → compute → write, with no await points in between
48
+ // (so two rapid edits can't interleave). compute(src) -> new HTML string, or null if not located.
49
+ // Refuses to write while an AI turn is editing the same page (it would clobber Claude's in-progress edit).
50
+ const applyDirect = (pageFile, compute) => {
51
+ if (activeTurn && (!isDir || resolveUnder(activeTurnPage) === pageFile)) return { code: 409, body: '{"error":"an AI turn is editing this page"}' };
52
+ let src;
53
+ try { src = readFileSync(pageFile, 'utf8'); } catch { return { code: 404, body: '{"error":"unreadable"}' }; }
54
+ const out = compute(src);
55
+ if (out == null) return { code: 409, body: '{"error":"element not found"}' };
56
+ if (out === src) return { code: 200, body: '{"ok":true,"noop":true}' };
57
+ const snap = takeDirectSnap(pageFile); // snapshot the pre-edit file
58
+ try { writeFileSync(pageFile, out); } catch { return { code: 500, body: '{"error":"write failed"}' }; }
59
+ if (snap) directSnaps.set(pageFile, snap); // record undo ONLY after the write succeeds
60
+ suppressReloadUntil = Date.now() + 800; // the browser already shows the change
61
+ return { code: 200, body: '{"ok":true}' };
62
+ };
63
+
64
+ const broadcast = (obj) => {
65
+ const frame = `data: ${JSON.stringify(obj)}\n\n`;
66
+ for (const res of clients) {
67
+ try { res.write(frame); } catch { clients.delete(res); }
68
+ }
69
+ };
70
+
71
+ const injectToolbar = (html) => {
72
+ const tag =
73
+ '\n<link rel="stylesheet" href="/__sandpaper/toolbar.css">' +
74
+ '\n<script type="module" src="/__sandpaper/toolbar.js"></script>\n';
75
+ return html.includes('</body>') ? html.replace('</body>', tag + '</body>') : html + tag;
76
+ };
77
+
78
+ const serveFile = (file, res) => {
79
+ readFile(file, (err, data) => {
80
+ if (err) { res.writeHead(404); return res.end('not found'); }
81
+ res.writeHead(200, { 'Content-Type': MIME[extname(file)] || 'application/octet-stream' });
82
+ res.end(data);
83
+ });
84
+ };
85
+
86
+ // Resolve a URL path to an absolute file UNDER root, or null if it escapes (traversal guard).
87
+ const resolveUnder = (reqPath) => {
88
+ const rel = (reqPath === '/' || reqPath === '') ? defaultDoc : reqPath.replace(/^\/+/, '');
89
+ if (rel === '.git' || rel.startsWith('.git/')) return null; // never serve VCS internals
90
+ const file = normalize(join(root, rel));
91
+ if (file !== root && !file.startsWith(root + sep)) return null; // lexical traversal guard
92
+ // also resolve symlinks: a link inside root pointing outside must not escape.
93
+ try { const real = realpathSync(file); if (real !== root && !real.startsWith(root + sep)) return null; }
94
+ catch { /* path not yet on disk — the lexical guard already passed */ }
95
+ return file;
96
+ };
97
+
98
+ const server = createServer((req, res) => {
99
+ const url = new URL(req.url, `http://${req.headers.host}`);
100
+ let path;
101
+ try { path = decodeURIComponent(url.pathname); }
102
+ catch { res.writeHead(400); return res.end('bad request'); }
103
+
104
+ // Root convenience: serving a repo whose brain lives in brain/ — send "/" to the cover so
105
+ // localhost:<port> just works, no need to know the /brain/index.html path.
106
+ if (isDir && (path === '/' || path === '') && !existsSync(join(root, 'index.html')) && existsSync(join(root, 'brain', 'index.html'))) {
107
+ res.writeHead(302, { Location: '/brain/index.html' });
108
+ return res.end();
109
+ }
110
+
111
+ // --- SSE status/reload channel ---
112
+ if (path === '/__sandpaper/events') {
113
+ res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' });
114
+ res.write(`data: ${JSON.stringify({ type: 'status', state: 'idle', label: 'idle' })}\n\n`);
115
+ clients.add(res);
116
+ req.on('close', () => clients.delete(res));
117
+ return;
118
+ }
119
+
120
+ // --- a refinement turn (page-aware) ---
121
+ if (path === '/__sandpaper/turn' && req.method === 'POST') {
122
+ if (activeTurn) {
123
+ res.writeHead(409, { 'Content-Type': 'application/json' });
124
+ return res.end('{"error":"a turn is already in progress"}');
125
+ }
126
+ let body = '';
127
+ req.on('data', (d) => { body += d; if (body.length > 1e6) req.destroy(); });
128
+ req.on('end', () => {
129
+ let payload = {};
130
+ try { payload = JSON.parse(body || '{}'); } catch {}
131
+ // Never trust the client page: re-resolve it under root and require an existing .html.
132
+ const turnPage = typeof payload.page === 'string' ? payload.page : '/';
133
+ const pageFile = resolveUnder(turnPage);
134
+ if (!pageFile || extname(pageFile) !== '.html' || !existsSync(pageFile)) {
135
+ res.writeHead(400, { 'Content-Type': 'application/json' });
136
+ return res.end('{"error":"unknown page"}');
137
+ }
138
+ const turnId = randomUUID();
139
+ snapshot(pageFile, root, turnId);
140
+ turnSnapshots.set(turnId, pageFile);
141
+ activeTurnPage = turnPage;
142
+ activeTurn = runTurn(pageFile, buildPrompt(payload, basename(pageFile)), (frame) => {
143
+ frame.turnId = turnId;
144
+ frame.page = activeTurnPage; // page-scope every frame so other-page windows ignore this turn
145
+ broadcast(frame);
146
+ const ended = frame.type === 'status' && (frame.done || frame.state === 'idle' || frame.state === 'error');
147
+ if (ended) {
148
+ activeTurn = null;
149
+ if (reloadPending) {
150
+ reloadPending = false;
151
+ broadcast(isDir ? { type: 'reload', page: activeTurnPage } : { type: 'reload' });
152
+ }
153
+ activeTurnPage = null;
154
+ }
155
+ });
156
+ res.writeHead(202, { 'Content-Type': 'application/json' });
157
+ res.end(JSON.stringify({ ok: true, turnId }));
158
+ });
159
+ return;
160
+ }
161
+
162
+ // --- undo a turn's edits (restore its pre-turn snapshot to the page it edited) ---
163
+ if (path === '/__sandpaper/undo' && req.method === 'POST') {
164
+ let body = '';
165
+ req.on('data', (d) => { body += d; if (body.length > 1e5) req.destroy(); });
166
+ req.on('end', () => {
167
+ let p = {}; try { p = JSON.parse(body || '{}'); } catch {}
168
+ const snap = snapshotPath(root, p.turnId);
169
+ const pageFile = turnSnapshots.get(p.turnId); // the file THIS turn snapshotted — not the client's claim
170
+ if (snap && existsSync(snap) && pageFile && extname(pageFile) === '.html') {
171
+ try {
172
+ copyFileSync(snap, pageFile); // → watcher → reload
173
+ turnSnapshots.delete(p.turnId);
174
+ res.writeHead(200, { 'Content-Type': 'application/json' });
175
+ return res.end('{"ok":true}');
176
+ } catch {}
177
+ }
178
+ res.writeHead(404, { 'Content-Type': 'application/json' });
179
+ res.end('{"error":"no snapshot for that turn"}');
180
+ });
181
+ return;
182
+ }
183
+
184
+ // --- a direct (no-AI) in-place edit: the browser edited one element; persist it to the file ---
185
+ // The browser owns the new content; we splice ONLY that element's inner HTML back into the
186
+ // source by data-cid, leaving the rest of the file untouched. No Claude, no turn, no snapshot.
187
+ if (path === '/__sandpaper/write' && req.method === 'POST') {
188
+ let body = '';
189
+ req.on('data', (d) => { body += d; if (body.length > 2e6) req.destroy(); });
190
+ req.on('end', () => {
191
+ let p = {}; try { p = JSON.parse(body || '{}'); } catch {}
192
+ const pageFile = resolveUnder(typeof p.page === 'string' ? p.page : '/');
193
+ const cid = typeof p.cid === 'string' ? p.cid : '';
194
+ if (!pageFile || extname(pageFile) !== '.html' || !existsSync(pageFile) ||
195
+ !/^[\w:-]{1,64}$/.test(cid) || typeof p.html !== 'string') {
196
+ res.writeHead(400, { 'Content-Type': 'application/json' });
197
+ return res.end('{"error":"bad write request"}');
198
+ }
199
+ const r = applyDirect(pageFile, (src) => replaceInner(src, cid, p.html));
200
+ res.writeHead(r.code, { 'Content-Type': 'application/json' });
201
+ res.end(r.body);
202
+ });
203
+ return;
204
+ }
205
+
206
+ // --- a direct (no-AI) STRUCTURAL edit: delete or move an element by data-cid (the "Hands") ---
207
+ if (path === '/__sandpaper/dom' && req.method === 'POST') {
208
+ let body = '';
209
+ req.on('data', (d) => { body += d; if (body.length > 1e5) req.destroy(); });
210
+ req.on('end', () => {
211
+ let p = {}; try { p = JSON.parse(body || '{}'); } catch {}
212
+ const okCid = (c) => typeof c === 'string' && /^[\w:-]{1,64}$/.test(c);
213
+ const pageFile = resolveUnder(typeof p.page === 'string' ? p.page : '/');
214
+ if (!pageFile || extname(pageFile) !== '.html' || !existsSync(pageFile) || !okCid(p.cid) ||
215
+ (p.op !== 'delete' && p.op !== 'move') || (p.op === 'move' && !okCid(p.target))) {
216
+ res.writeHead(400, { 'Content-Type': 'application/json' });
217
+ return res.end('{"error":"bad dom request"}');
218
+ }
219
+ const mode = p.mode === 'after' ? 'after' : 'before';
220
+ const r = applyDirect(pageFile, (src) => {
221
+ const out = p.op === 'delete' ? removeElement(src, p.cid) : moveElement(src, p.cid, p.target, mode);
222
+ return out ? out.html : null;
223
+ });
224
+ res.writeHead(r.code, { 'Content-Type': 'application/json' });
225
+ res.end(r.body);
226
+ });
227
+ return;
228
+ }
229
+
230
+ // --- undo the LAST direct edit on a page (restore its pre-edit snapshot) ---
231
+ if (path === '/__sandpaper/undo-direct' && req.method === 'POST') {
232
+ let body = '';
233
+ req.on('data', (d) => { body += d; if (body.length > 1e4) req.destroy(); });
234
+ req.on('end', () => {
235
+ let p = {}; try { p = JSON.parse(body || '{}'); } catch {}
236
+ const pageFile = resolveUnder(typeof p.page === 'string' ? p.page : '/');
237
+ const snap = pageFile && directSnaps.get(pageFile);
238
+ if (snap && existsSync(snap)) {
239
+ try {
240
+ suppressReloadUntil = 0; // we WANT the restore to reload the page
241
+ copyFileSync(snap, pageFile); // → watcher → reload
242
+ directSnaps.delete(pageFile);
243
+ res.writeHead(200, { 'Content-Type': 'application/json' });
244
+ return res.end('{"ok":true}');
245
+ } catch { /* fall through to 404 */ }
246
+ }
247
+ res.writeHead(404, { 'Content-Type': 'application/json' });
248
+ res.end('{"error":"nothing to undo"}');
249
+ });
250
+ return;
251
+ }
252
+
253
+ // --- toolbar assets (dev chrome, served from the package; never written to disk) ---
254
+ if (path.startsWith('/__sandpaper/') && (path.endsWith('.js') || path.endsWith('.css'))) {
255
+ return serveFile(join(PUBLIC, basename(path)), res); // toolbar.js / toolbar.css / sp-markdown.js
256
+ }
257
+
258
+ // --- any file under root: .html gets the toolbar injected, everything else served raw ---
259
+ const file = resolveUnder(path);
260
+ if (!file) { res.writeHead(404); return res.end('not found'); }
261
+ if (extname(file) === '.html') {
262
+ return readFile(file, 'utf8', (err, html) => {
263
+ if (err) { res.writeHead(404); return res.end('not found'); }
264
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
265
+ res.end(injectToolbar(html));
266
+ });
267
+ }
268
+ return serveFile(file, res);
269
+ });
270
+
271
+ // Watch the served tree; reload the page whose .html changed.
272
+ let debounce = null;
273
+ watch(root, { persistent: true, recursive: isDir }, (_evt, fname) => {
274
+ if (!fname) { if (isDir) return; fname = defaultDoc; } // null filename: only act in single-doc mode
275
+ const rel = fname.split(sep).join('/');
276
+ if (rel.startsWith('.sandpaper')) return; // our own snapshots/session
277
+ if (!rel.endsWith('.html')) return; // only reload on document changes
278
+ if (!isDir && basename(rel) !== defaultDoc) return; // single-doc mode: just the doc
279
+ const page = (isDir && rel === defaultDoc) ? '/' : '/' + rel; // map the root doc back to '/' like the URL
280
+ clearTimeout(debounce);
281
+ debounce = setTimeout(() => {
282
+ // A direct in-place edit we just wrote: the editing browser already shows it — don't reload.
283
+ if (Date.now() < suppressReloadUntil) return;
284
+ // Defer ONLY the active turn's own edit (so its reply isn't cut mid-stream); reload any
285
+ // other (external) change immediately.
286
+ if (activeTurn && (!isDir || page === activeTurnPage)) { reloadPending = true; return; }
287
+ broadcast(isDir ? { type: 'reload', page } : { type: 'reload' });
288
+ }, 120);
289
+ });
290
+
291
+ // Listen on `port`, or the next free port if it's taken — so several repos' Sandpapers
292
+ // can run at once without colliding on 4848. Resolves with the URL of the port we landed on.
293
+ return new Promise((resolve, reject) => {
294
+ let p = port, tries = 0;
295
+ server.on('error', (e) => {
296
+ if (e.code === 'EADDRINUSE' && tries++ < 50) { p++; setTimeout(() => server.listen(p, '127.0.0.1'), 0); }
297
+ else reject(e);
298
+ });
299
+ server.on('listening', () => resolve(`http://127.0.0.1:${p}/`));
300
+ server.listen(p, '127.0.0.1');
301
+ });
302
+ }
303
+
304
+ // Pre-turn snapshot of the edited page, so a turn's edits can be undone and recovered.
305
+ function snapshotPath(root, turnId) {
306
+ const safe = String(turnId || '').replace(/[^a-fA-F0-9-]/g, ''); // turnId is a uuid; reject anything else
307
+ return safe ? join(root, '.sandpaper', 'snapshots', safe + '.html') : null;
308
+ }
309
+ function snapshot(pageFile, root, turnId) {
310
+ try {
311
+ const dir = join(root, '.sandpaper', 'snapshots');
312
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
313
+ copyFileSync(pageFile, snapshotPath(root, turnId));
314
+ } catch { /* best-effort; never block a turn */ }
315
+ }
316
+
317
+ // Turn a toolbar payload into a scoped prompt for Claude.
318
+ function buildPrompt(payload, docName) {
319
+ const { prompt = '', cid, selector, snippet } = payload;
320
+ if (cid || selector) {
321
+ const where = cid ? `the element with data-cid="${cid}"` : `the element matching CSS selector \`${selector}\``;
322
+ const ctx = snippet ? `\nFor reference, its current content begins: "${snippet}"` : '';
323
+ return `In ${docName}, edit ONLY ${where}.${ctx}\n\nRequested change: ${prompt}\n\n` +
324
+ 'Make the smallest edit that satisfies this and leave the rest of the document unchanged.';
325
+ }
326
+ return `In ${docName}: ${prompt}\n\nMake the smallest edit that satisfies this; do not regenerate unrelated parts of the document.`;
327
+ }