@heyquilt/seam 0.26.0 → 0.130.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/bin/seam.js +118 -17
  2. package/lib/quilts/agent-tokens.js +148 -0
  3. package/lib/quilts/archiver.js +241 -0
  4. package/lib/quilts/attest.js +212 -0
  5. package/lib/quilts/bisect-run.js +3 -2
  6. package/lib/quilts/block.js +497 -55
  7. package/lib/quilts/cas.js +78 -0
  8. package/lib/quilts/cbor.js +2 -0
  9. package/lib/quilts/claims.js +409 -0
  10. package/lib/quilts/cli.js +623 -69
  11. package/lib/quilts/client.js +307 -19
  12. package/lib/quilts/conflict-format.js +266 -0
  13. package/lib/quilts/daemon.js +174 -0
  14. package/lib/quilts/describe.js +281 -38
  15. package/lib/quilts/dict.js +34 -0
  16. package/lib/quilts/exit-archive.js +184 -0
  17. package/lib/quilts/export-format.js +52 -0
  18. package/lib/quilts/export-git.js +27 -3
  19. package/lib/quilts/export-lfs.js +52 -0
  20. package/lib/quilts/export-sign.js +49 -0
  21. package/lib/quilts/fastcdc.js +186 -0
  22. package/lib/quilts/hash.js +17 -2
  23. package/lib/quilts/ignored.js +46 -0
  24. package/lib/quilts/import-git.js +54 -2
  25. package/lib/quilts/journal.js +105 -0
  26. package/lib/quilts/limits.js +197 -0
  27. package/lib/quilts/linepolicy.js +109 -0
  28. package/lib/quilts/pack.js +146 -18
  29. package/lib/quilts/persona.js +70 -0
  30. package/lib/quilts/records.js +7 -3
  31. package/lib/quilts/recover.js +37 -0
  32. package/lib/quilts/runner.js +79 -12
  33. package/lib/quilts/sealed.js +231 -0
  34. package/lib/quilts/secrets.js +86 -0
  35. package/lib/quilts/signing.js +7 -3
  36. package/lib/quilts/tarball.js +107 -0
  37. package/lib/quilts/textdiff.js +7 -5
  38. package/lib/quilts/tools.js +412 -83
  39. package/lib/quilts/tree.js +351 -0
  40. package/lib/quilts/want.js +51 -0
  41. package/lib/quilts/webhook.js +116 -0
  42. package/package.json +3 -2
@@ -0,0 +1,266 @@
1
+ // 07's default conflict materialisation, `diff` (Jujutsu's style): inside each marker block one
2
+ // side as a snapshot (`+++++++`), then each other side as a diff from its base (`%%%%%%%`). With
3
+ // two sides (diff3), more sides on one base (an N-way merge, `nway`) or on different bases (each
4
+ // side's change laid onto the first side, `ontoFirst`) only the clashing regions are marked and the
5
+ // rest of the file is merged text; a deleted side is an empty side labelled "(deleted by <who>)". Also the `git` format (diff3 markers with the `|||||||` base section, two sides
6
+ // only). Every block's first line carries the term's tag, so a
7
+ // file committed with these markers is still conflicted (service.js, block.js).
8
+ import { diff3Merge } from 'node-diff3'
9
+ import { hunks, splitLines } from './textdiff.js'
10
+
11
+ /** `old` → `now` as `-`/`+`/` ` lines (every line of both, in order). */
12
+ export function lineDiff (old, now) {
13
+ const out = []
14
+ let i = 0
15
+ let j = 0
16
+ for (const h of hunks(old, now)) {
17
+ while (i < h.a[0]) { out.push(' ' + old[i++]); j++ }
18
+ for (let k = 0; k < h.a[1]; k++) out.push('-' + old[i++])
19
+ for (let k = 0; k < h.b[1]; k++) out.push('+' + now[j++])
20
+ }
21
+ while (i < old.length) { out.push(' ' + old[i++]); j++ }
22
+ return out
23
+ }
24
+
25
+ /**
26
+ * The term as `diff` markers. `sides` are [{ text, label, by }] (adds, text null for a delete);
27
+ * `bases` the removes' texts (bases[i] is the base of sides[i + 1]). `tag` is
28
+ * `quilt-conflict <hash>`, `kind` the term kind.
29
+ */
30
+ export function diffMarkers ({ sides, bases, tag, kind }) {
31
+ const L = (t) => splitLines(t || '')
32
+ // Ruling (2026-10-24): a side that deleted the file, or the region, is an empty side labelled so.
33
+ const name = (s, lines, base) => s.text == null || (!lines.length && base && base.length) ? `${s.label} (deleted by ${s.by || 'someone'})` : s.label
34
+ const block = (snap, others) => [
35
+ `<<<<<<< ${tag} ${kind}`,
36
+ `+++++++ ${name(snap.side, snap.lines, snap.base)}`, ...snap.lines,
37
+ ...others.flatMap((o) => [`%%%%%%% ${name(o.side, o.lines, o.base)} (diff from base)`, ...lineDiff(o.base, o.lines)]),
38
+ `>>>>>>> ${tag}`
39
+ ]
40
+ const whole = () => block({ side: sides[0], lines: L(sides[0].text), base: L(bases[0]) },
41
+ sides.slice(1).map((s, i) => ({ side: s, base: L(bases[i]), lines: L(s.text) })))
42
+ if (sides.length < 2) return whole().join('\n') + '\n'
43
+ const out = []
44
+ let marked = false
45
+ if (sides.length === 2) {
46
+ for (const r of diff3Merge(L(sides[0].text), L(bases[0]), L(sides[1].text), { excludeFalseConflicts: true })) {
47
+ if (r.ok) { out.push(...r.ok); continue }
48
+ const c = r.conflict
49
+ marked = true
50
+ out.push(...block({ side: sides[0], lines: c.a, base: c.o }, [{ side: sides[1], base: c.o, lines: c.b }]))
51
+ }
52
+ } else {
53
+ const regions = bases.some((b) => (b || '') !== (bases[0] || '')) ? ontoFirst(L(sides[0].text), sides.slice(1).map((s, i) => ({ base: L(bases[i]), lines: L(s.text) }))) : null
54
+ if (regions) {
55
+ for (const r of regions) {
56
+ if (r.ok) { out.push(...r.ok); continue }
57
+ marked = true
58
+ out.push(...block({ side: sides[0], lines: r.snap }, sides.slice(1).map((s, i) => ({ side: s, base: r.others[i].base, lines: r.others[i].lines }))))
59
+ }
60
+ } else for (const r of nway(L(bases[0]), sides.map((s) => L(s.text)))) {
61
+ if (r.ok) { out.push(...r.ok); continue }
62
+ marked = true
63
+ out.push(...block({ side: sides[0], lines: r.sides[0], base: r.base }, sides.slice(1).map((s, i) => ({ side: s, base: r.base, lines: r.sides[i + 1] }))))
64
+ }
65
+ }
66
+ return (marked ? out : whole()).join('\n') + '\n'
67
+ }
68
+
69
+ /**
70
+ * An N-way line merge of `sides` (arrays of lines) that all start from `base`: regions
71
+ * `{ ok: lines }` where at most one distinct change touches the base lines, and
72
+ * `{ base, sides }` where two sides changed the same (or touching) base lines differently.
73
+ */
74
+ export function nway (base, sides) {
75
+ const all = []
76
+ sides.forEach((lines, s) => { for (const h of hunks(base, lines)) all.push({ s, a0: h.a[0], a1: h.a[0] + h.a[1], d: h.b[1] - h.a[1] }) })
77
+ all.sort((x, y) => x.a0 - y.a0 || x.a1 - y.a1)
78
+ const groups = []
79
+ for (const h of all) {
80
+ const g = groups[groups.length - 1]
81
+ if (g && h.a0 <= g.hi) { g.hi = Math.max(g.hi, h.a1); g.hunks.push(h) } else groups.push({ lo: h.a0, hi: h.a1, hunks: [h] })
82
+ }
83
+ const shift = new Array(sides.length).fill(0) // each side's line offset before the current group
84
+ const out = []
85
+ let at = 0
86
+ const ok = (lines) => { if (lines.length) out.push({ ok: lines }) }
87
+ for (const g of groups) {
88
+ ok(base.slice(at, g.lo))
89
+ const changed = new Set(g.hunks.map((h) => h.s))
90
+ const slice = sides.map((lines, s) => {
91
+ const d = g.hunks.filter((h) => h.s === s).reduce((n, h) => n + h.d, 0)
92
+ const part = lines.slice(g.lo + shift[s], g.hi + shift[s] + d)
93
+ shift[s] += d
94
+ return part
95
+ })
96
+ const versions = new Set([...changed].map((s) => slice[s].join('\n') + '\u0000' + slice[s].length))
97
+ if (versions.size === 1) ok(slice[[...changed][0]])
98
+ else out.push({ base: base.slice(g.lo, g.hi), sides: slice })
99
+ at = g.hi
100
+ }
101
+ ok(base.slice(at))
102
+ return out
103
+ }
104
+
105
+ /** Maps line positions of `a` to `b` through their hunks; inside a changed run (or on an insertion), `lo` snaps to its start and `hi` to its end. */
106
+ function mapper (a, b) {
107
+ const hs = hunks(a, b)
108
+ const at = (p, end) => {
109
+ let off = 0
110
+ for (const h of hs) {
111
+ const [a0, an] = h.a
112
+ if (end && p === a0 && an === 0) return h.b[0] + h.b[1] // an end on an insertion takes it in
113
+ if (p <= a0) return p + off
114
+ if (p < a0 + an) return end ? h.b[0] + h.b[1] : h.b[0]
115
+ off += h.b[1] - an
116
+ }
117
+ return p + off
118
+ }
119
+ return { hunks: hs, lo: (p) => at(p, false), hi: (p) => at(p, true) }
120
+ }
121
+
122
+ /**
123
+ * Sides on different bases, each laid onto the first side (the snapshot): a side's change lands
124
+ * where its base lines sit in the first side. Regions `{ ok: lines }` where at most one change
125
+ * lands and it does not touch lines the first side changed from that base, and `{ snap, others }`
126
+ * (each other side's base and text over the region) where they clash.
127
+ */
128
+ export function ontoFirst (first, others) {
129
+ const regions = []
130
+ others.forEach((o, i) => {
131
+ const toFirst = mapper(o.base, first)
132
+ const toSide = mapper(o.base, o.lines)
133
+ for (const h of toSide.hunks) {
134
+ let x0 = h.a[0]; let x1 = h.a[0] + h.a[1]; let clash = false
135
+ for (let grew = true; grew;) {
136
+ grew = false
137
+ for (const c of toFirst.hunks) {
138
+ const c0 = c.a[0]; const c1 = c.a[0] + c.a[1]
139
+ if (c0 > x1 || x0 > c1) continue
140
+ clash = true
141
+ if (c0 < x0 || c1 > x1) { x0 = Math.min(x0, c0); x1 = Math.max(x1, c1); grew = true }
142
+ }
143
+ }
144
+ regions.push({ i, lo: toFirst.lo(x0), hi: toFirst.hi(x1), clash, text: o.lines.slice(toSide.lo(x0), toSide.hi(x1)) })
145
+ }
146
+ })
147
+ regions.sort((x, y) => x.lo - y.lo || x.hi - y.hi)
148
+ const groups = []
149
+ for (const r of regions) {
150
+ const g = groups[groups.length - 1]
151
+ if (g && r.lo <= g.hi) { g.hi = Math.max(g.hi, r.hi); g.rs.push(r) } else groups.push({ lo: r.lo, hi: r.hi, rs: [r] })
152
+ }
153
+ const out = []
154
+ let at = 0
155
+ const ok = (lines) => { if (lines.length) out.push({ ok: lines }) }
156
+ for (const g of groups) {
157
+ ok(first.slice(at, g.lo))
158
+ const same = g.rs.every((r) => !r.clash && r.lo === g.lo && r.hi === g.hi && r.text.join('\n') === g.rs[0].text.join('\n') && r.text.length === g.rs[0].text.length)
159
+ if (same) ok(g.rs[0].text)
160
+ else {
161
+ out.push({
162
+ snap: first.slice(g.lo, g.hi),
163
+ others: others.map((o) => {
164
+ const back = mapper(first, o.base)
165
+ const fwd = mapper(o.base, o.lines)
166
+ const [b0, b1] = [back.lo(g.lo), back.hi(g.hi)]
167
+ return { base: o.base.slice(b0, b1), lines: o.lines.slice(fwd.lo(b0), fwd.hi(b1)) }
168
+ })
169
+ })
170
+ }
171
+ at = g.hi
172
+ }
173
+ ok(first.slice(at))
174
+ return out
175
+ }
176
+
177
+ /** The term whole (07's `snapshot` format): every side, then every base, each as its full text. */
178
+ export function snapshotMarkers ({ sides, bases, tag, kind }) {
179
+ const parts = [`<<<<<<< ${tag} ${kind}`]
180
+ sides.forEach((s) => parts.push(`+++++++ ${s.label}`, s.text || ''))
181
+ bases.forEach((b, i) => parts.push(`------- base ${i}`, b || ''))
182
+ parts.push(`>>>>>>> ${tag}`)
183
+ return parts.join('\n') + '\n'
184
+ }
185
+
186
+ /** The term as git's diff3 markers (07's `git` format): two sides, with the `|||||||` base. */
187
+ export function gitMarkers ({ ours, base, theirs, tag }) {
188
+ const L = (t) => splitLines(t || '')
189
+ const name = (s, lines, o) => s.text == null || (!lines.length && o.length) ? `${s.label} (deleted by ${s.by || 'someone'})` : s.label
190
+ const block = (a, o, b) => [`<<<<<<< ${name(ours, a, o)} ${tag}`, ...a, `||||||| base ${tag}`, ...o, '=======', ...b, `>>>>>>> ${name(theirs, b, o)} ${tag}`]
191
+ const out = []
192
+ let marked = false
193
+ for (const r of diff3Merge(L(ours.text), L(base), L(theirs.text), { excludeFalseConflicts: true })) {
194
+ if (r.ok) { out.push(...r.ok); continue }
195
+ marked = true
196
+ out.push(...block(r.conflict.a, r.conflict.o, r.conflict.b))
197
+ }
198
+ return (marked ? out : block(L(ours.text), L(base), L(theirs.text))).join('\n') + '\n'
199
+ }
200
+
201
+ /**
202
+ * Reads a file with conflict markers back (any of the three formats): `[{ ok: lines }` for merged
203
+ * text, and `{ format, tag, kind, sides: [{ label, deleted, lines, base }] }` for each block]`.
204
+ * `base` is a side's base lines where the markers show it (a `diff` side after the first, a `git`
205
+ * block's two sides). A `snapshot` block also gives each side's exact `text` and the bases as
206
+ * `removes`, so it reads back to the term it was written from. Only blocks whose first line carries `quilt-conflict` are read; lines that merely look
207
+ * like markers outside them are text. A block left open or out of order throws `markers_malformed`.
208
+ */
209
+ export function parseMarkers (text) {
210
+ const lines = splitLines(text || '')
211
+ const out = []
212
+ const bad = (why) => Object.assign(new Error(`conflict markers malformed: ${why}`), { code: 'markers_malformed' })
213
+ const label = (s) => { const m = /^(.*) \(deleted by [^)]*\)$/.exec(s); return m ? { label: m[1], deleted: true } : { label: s, deleted: false } }
214
+ let ok = []
215
+ const flush = () => { if (ok.length) out.push({ ok }); ok = [] }
216
+ for (let i = 0; i < lines.length; i++) {
217
+ const open = /^<<<<<<< (quilt-conflict \S+) (\S+)$/.exec(lines[i])
218
+ const gopen = !open && /^<<<<<<< (.*) (quilt-conflict \S+)$/.exec(lines[i])
219
+ if (!open && !gopen) { ok.push(lines[i]); continue }
220
+ flush()
221
+ const tag = open ? open[1] : gopen[2]
222
+ const close = open ? `>>>>>>> ${tag}` : null
223
+ const sides = []
224
+ const removes = []
225
+ let cur = null
226
+ let mode = null
227
+ for (i++; ; i++) {
228
+ if (i >= lines.length) throw bad(`block ${tag} is never closed`)
229
+ const l = lines[i]
230
+ if (open) {
231
+ if (l === close) break
232
+ if (l.startsWith('+++++++ ')) { cur = { ...label(l.slice(8)), lines: [] }; mode = 'snap'; sides.push(cur); continue }
233
+ if (/^------- base \d+$/.test(l) && mode !== 'diff' && sides.length) { cur = { base: true, lines: [] }; mode = 'snap'; removes.push(cur); continue }
234
+ if (l.startsWith('%%%%%%% ') && l.endsWith(' (diff from base)') && !removes.length) { cur = { ...label(l.slice(8, -17)), lines: [], base: [] }; mode = 'diff'; sides.push(cur); continue }
235
+ if (!cur) throw bad(`block ${tag} has text before its first side`)
236
+ if (mode === 'snap') { cur.lines.push(l); continue }
237
+ const op = l[0]
238
+ if (op === ' ') { cur.lines.push(l.slice(1)); cur.base.push(l.slice(1)) } else if (op === '+') cur.lines.push(l.slice(1))
239
+ else if (op === '-') cur.base.push(l.slice(1))
240
+ else throw bad(`block ${tag} has a diff line that is not -, + or space`)
241
+ } else {
242
+ if (mode === null) { cur = { ...label(gopen[1]), lines: [], base: [] }; sides.push(cur); mode = 'ours' }
243
+ if (mode === 'ours' && l === `||||||| base ${tag}`) { mode = 'base'; continue }
244
+ if (mode === 'base' && l === '=======') { mode = 'theirs'; sides.push(cur = { label: '', deleted: false, lines: [], base: sides[0].base }); continue }
245
+ if (mode === 'theirs' && l.startsWith('>>>>>>> ') && l.endsWith(' ' + tag)) { Object.assign(cur, label(l.slice(8, -tag.length - 1))); break }
246
+ if (mode === 'base') sides[0].base.push(l)
247
+ else cur.lines.push(l)
248
+ }
249
+ }
250
+ if (open && !sides.length) throw bad(`block ${tag} has no sides`)
251
+ if (removes.length) {
252
+ // A snapshot block holds each text whole: the lines between two markers are the text plus
253
+ // the newline that joined it to the next marker, so joining them gives the exact bytes.
254
+ for (const s of sides) { s.text = s.lines.join('\n'); s.lines = splitLines(s.text) }
255
+ out.push({ format: 'snapshot', tag, kind: open[2], sides, removes: removes.map((r) => r.lines.join('\n')) })
256
+ } else out.push(open ? { format: 'diff', tag, kind: open[2], sides } : { format: 'git', tag, kind: null, sides })
257
+ }
258
+ flush()
259
+ return out
260
+ }
261
+
262
+ /** The file with every block resolved to side `k` (what someone who picks that side keeps). */
263
+ export function resolveMarkers (regions, k) {
264
+ const lines = regions.flatMap((r) => r.ok || r.sides[k].lines)
265
+ return lines.length ? lines.join('\n') + '\n' : ''
266
+ }
@@ -0,0 +1,174 @@
1
+ // The daemon for one block folder (05 "Cache tiers", slice 1): it watches the folder, sends each
2
+ // changed path to the square's live work as a PATCH /live (the folder's head named, unchanged
3
+ // paths as `clean`), and answers a control API on 127.0.0.1. The API's port and token are in
4
+ // ~/.seam/daemon/<folder id>.json (0600), which only the folder's owner can read; every request
5
+ // carries `Authorization: Bearer <token>`. Routes: GET /status, POST /flush, POST /rebuild, POST /stop.
6
+ // On start it runs block.rebuild and shares every edit made while it was not running.
7
+ import fs from 'node:fs'
8
+ import os from 'node:os'
9
+ import path from 'node:path'
10
+ import http from 'node:http'
11
+ import crypto from 'node:crypto'
12
+ import { findBlock, livePatch, rebuild, localChanges, touchFolder } from './block.js'
13
+
14
+ const seamHome = () => process.env.SEAM_HOME || path.join(os.homedir(), '.seam')
15
+ const SKIP = new Set(['.seam', '.git', 'node_modules'])
16
+
17
+ /** Where the daemon for a block folder keeps its port and token. */
18
+ export const daemonStatusFile = (dir) => path.join(seamHome(), 'daemon', crypto.createHash('sha256').update(path.resolve(dir)).digest('hex').slice(0, 16) + '.json')
19
+
20
+ /** At most this many daemons on one machine (05 "Several blocks": each costs a native watcher). */
21
+ export const MAX_DAEMONS = 200
22
+
23
+ /** The daemons running on this machine: their status files whose process is alive. */
24
+ export function runningDaemons () {
25
+ const d = path.join(seamHome(), 'daemon')
26
+ const out = []
27
+ for (const n of fs.existsSync(d) ? fs.readdirSync(d) : []) {
28
+ if (!n.endsWith('.json')) continue
29
+ try {
30
+ const info = JSON.parse(fs.readFileSync(path.join(d, n), 'utf8'))
31
+ process.kill(info.pid, 0)
32
+ out.push(info)
33
+ } catch (err) { if (err.code === 'EPERM') out.push({ file: n }) }
34
+ }
35
+ return out
36
+ }
37
+
38
+ /** Asks the daemon running for a folder (null when none answers). */
39
+ export async function askDaemon (dir, route = 'status', method = 'GET') {
40
+ let info
41
+ try { info = JSON.parse(fs.readFileSync(daemonStatusFile(dir), 'utf8')) } catch { return null }
42
+ try {
43
+ const res = await fetch(`http://127.0.0.1:${info.port}/${route}`, { method, headers: { authorization: `Bearer ${info.token}` }, signal: AbortSignal.timeout(3000) })
44
+ return res.ok ? await res.json() : null
45
+ } catch { return null }
46
+ }
47
+
48
+ /**
49
+ * Starts the daemon for the block folder `dir`: { port, status(), flush(), stop() }. A path is sent
50
+ * `debounceMs` after its last change. A `409 head_moved` keeps the paths pending (state
51
+ * `head_moved`) until the folder's head is the square's again (seam rebase or seam checkout);
52
+ * any other failure keeps them for the next try.
53
+ */
54
+ export async function startDaemon ({ client, dir, debounceMs = 1000, codec = null, touchMs = 10 * 60 * 1000 }) {
55
+ const found = findBlock(dir)
56
+ if (!found) throw new Error(`${dir} is not a block folder: run seam checkout first.`)
57
+ dir = found.dir
58
+ if (!found.block.square) throw new Error('This block is not on a square, so it has no live work to share: open one with seam square open.')
59
+ if (found.block.read_only) throw new Error('This block is read-only, so it has no live work to share.')
60
+ const others = runningDaemons().filter((i) => i.dir !== dir)
61
+ const max = Number(process.env.SEAM_MAX_DAEMONS) || MAX_DAEMONS
62
+ if (others.length >= max) throw Object.assign(new Error(`${others.length} seam daemons already run on this machine (at most ${max}): stop one with seam daemon stop in its folder.`), { code: 'daemons_limit' })
63
+ const pending = new Set()
64
+ const st = { dir, quilt: found.block.quilt, square: found.block.square, head: found.block.head || null, state: 'watching', sent: 0, last_sent_at: null, last_error: null, pid: process.pid, started_at: new Date().toISOString() }
65
+ let timer = null
66
+ let running = null
67
+ let stopped = false
68
+ let retryAt = 0
69
+ let inflight = []
70
+ let lastRebuild = null
71
+
72
+ // block.rebuild (05): finish a move that stopped part way, then queue every edit made while no
73
+ // daemon watched, so it is shared like one made now.
74
+ const rebuildNow = async () => {
75
+ const { block } = findBlock(dir)
76
+ if (codec) block._codec = codec
77
+ lastRebuild = await rebuild(client, dir, block)
78
+ const fresh = findBlock(dir).block
79
+ for (const c of localChanges(dir, fresh)) pending.add(c.path)
80
+ for (const p of lastRebuild.kept) pending.add(p)
81
+ return lastRebuild
82
+ }
83
+
84
+ const flush = async () => {
85
+ if (running) { await running; if (!pending.size) return }
86
+ if (!pending.size) return
87
+ const paths = [...pending].sort()
88
+ pending.clear()
89
+ inflight = paths
90
+ running = (async () => {
91
+ const { block } = findBlock(dir)
92
+ st.head = block.head || null
93
+ try {
94
+ const patch = livePatch(dir, block, paths.map((p) => path.join(dir, p)))
95
+ if (patch.changes.length) await client.patchLive(block.quilt, block.square, patch)
96
+ st.sent += 1
97
+ st.last_sent_at = new Date().toISOString()
98
+ st.state = 'watching'
99
+ st.last_error = null
100
+ } catch (err) {
101
+ for (const p of paths) pending.add(p)
102
+ st.state = err.code === 'head_moved' ? 'head_moved' : 'retrying'
103
+ retryAt = Date.now() + 10000
104
+ st.last_error = err.code === 'head_moved' ? `the square's head is ${String(err.head || '').slice(0, 12)}, not this folder's: run seam rebase` : err.message
105
+ }
106
+ })()
107
+ try { await running } finally { running = null; inflight = [] }
108
+ }
109
+
110
+ // The block row's last_seen is touched every 10 minutes while the daemon runs (05); a row the
111
+ // server closed while the machine was away is opened again on the same square and paths.
112
+ const touch = async () => {
113
+ try { const r = await touchFolder(client, dir, findBlock(dir).block); st.block_id = r.block_id; st.last_touch_at = new Date().toISOString(); if (r.reopened) st.reopened_at = st.last_touch_at } catch (err) { st.last_error = err.message }
114
+ }
115
+ await rebuildNow()
116
+ await touch()
117
+ const schedule = () => { if (stopped) return; clearTimeout(timer); timer = setTimeout(() => { flush().catch(() => {}) }, debounceMs) }
118
+ const watcher = fs.watch(dir, { recursive: true }, (_ev, name) => {
119
+ if (!name) return
120
+ const rel = String(name).split(path.sep).join('/')
121
+ if (SKIP.has(rel.split('/')[0])) return
122
+ pending.add(rel)
123
+ schedule()
124
+ })
125
+ if (pending.size) schedule()
126
+ // A failed send tries again every 10 seconds; a head_moved one once the folder's head changes.
127
+ const retry = setInterval(() => {
128
+ if (!pending.size || running) return
129
+ if (st.state === 'head_moved' && (findBlock(dir)?.block.head || null) === st.head) return
130
+ if (st.state === 'retrying' && Date.now() < retryAt) return
131
+ flush().catch(() => {})
132
+ }, Math.max(debounceMs, 200))
133
+ retry.unref?.()
134
+ const toucher = setInterval(() => { touch() }, touchMs)
135
+ toucher.unref?.()
136
+
137
+ const token = crypto.randomBytes(24).toString('base64url')
138
+ // Waiting = not sent yet, including paths in a send that has not answered.
139
+ const status = () => ({ ...st, pending: [...new Set([...pending, ...inflight])].sort(), last_rebuild: lastRebuild })
140
+ const server = http.createServer(async (req, res) => {
141
+ const send = (code, body) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(body)) }
142
+ const given = Buffer.from(String(req.headers.authorization || ''))
143
+ const want = Buffer.from(`Bearer ${token}`)
144
+ if (given.length !== want.length || !crypto.timingSafeEqual(given, want)) return send(401, { error: 'This daemon needs its token (in ~/.seam/daemon).', code: 'unauthorized' })
145
+ const route = `${req.method} ${req.url.split('?')[0]}`
146
+ if (route === 'GET /status') return send(200, status())
147
+ if (route === 'POST /flush') { await flush(); return send(200, status()) }
148
+ if (route === 'POST /rebuild') { try { const r = await rebuildNow(); schedule(); return send(200, { ...r, status: status() }) } catch (err) { return send(500, { error: err.message, code: err.code || 'rebuild_failed' }) } }
149
+ if (route === 'POST /stop') { send(200, { stopping: true }); setImmediate(() => stop()); return }
150
+ send(404, { error: `No ${route} here: GET /status, POST /flush, POST /rebuild, POST /stop.`, code: 'not_found' })
151
+ })
152
+ await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve) })
153
+ const port = server.address().port
154
+ const file = daemonStatusFile(dir)
155
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 })
156
+ fs.writeFileSync(file, JSON.stringify({ pid: process.pid, port, token, dir }), { mode: 0o600 })
157
+ fs.chmodSync(file, 0o600)
158
+
159
+ let done = null
160
+ let ended
161
+ const endedP = new Promise((resolve) => { ended = resolve })
162
+ const stop = () => done || (done = (async () => {
163
+ stopped = true
164
+ clearTimeout(timer)
165
+ clearInterval(retry)
166
+ clearInterval(toucher)
167
+ watcher.close()
168
+ if (running) await running.catch(() => {})
169
+ try { if (JSON.parse(fs.readFileSync(file, 'utf8')).token === token) fs.rmSync(file) } catch {}
170
+ await new Promise((resolve) => { server.close(() => resolve()); server.closeAllConnections?.() })
171
+ ended()
172
+ })())
173
+ return { port, status, flush, stop, stopped: () => endedP }
174
+ }