heyquilt 0.3.121 → 0.3.138

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 (129) hide show
  1. package/RELEASES.md +292 -17
  2. package/bin/quilt.js +72 -5
  3. package/desktop/main.js +16 -2
  4. package/package.json +3 -1
  5. package/src/agent-task-workflow.js +2 -2
  6. package/src/agents/common.js +13 -0
  7. package/src/ai-vm.js +9 -1
  8. package/src/api/activity.js +5 -0
  9. package/src/api/memory-store.js +21 -10
  10. package/src/api/notify.js +134 -0
  11. package/src/api/routes/browsers.js +29 -0
  12. package/src/api/routes/github.js +0 -13
  13. package/src/api/routes/relay.js +26 -3
  14. package/src/api/routes/seam-connect.js +103 -0
  15. package/src/api/routes/session-migrate.js +39 -0
  16. package/src/api/routes/sessions.js +73 -1
  17. package/src/api/server.js +46 -12
  18. package/src/api/supabase-store.js +53 -4
  19. package/src/autowork.js +182 -16
  20. package/src/connection.js +39 -9
  21. package/src/control.js +139 -13
  22. package/src/force-date.js +27 -0
  23. package/src/fsutil.js +13 -3
  24. package/src/gitstate.js +38 -2
  25. package/src/history.js +25 -1
  26. package/src/mcp.js +93 -7
  27. package/src/merge3.js +11 -3
  28. package/src/now-summary.js +113 -0
  29. package/src/pathrules.js +12 -0
  30. package/src/protocol.js +10 -2
  31. package/src/quilt-default.js +84 -0
  32. package/src/quilt-history.js +106 -0
  33. package/src/quilt-remote.js +78 -0
  34. package/src/relay-commit.js +5 -0
  35. package/src/relay-lease.js +6 -2
  36. package/src/relay-mcp.js +172 -8
  37. package/src/relay-outbound.js +1 -1
  38. package/src/relay-prune.js +4 -1
  39. package/src/release-words.js +2 -2
  40. package/src/runner.js +20 -7
  41. package/src/seam-access.js +97 -0
  42. package/src/seam-claims.js +53 -0
  43. package/src/seam-url.js +15 -0
  44. package/src/server.js +1767 -33
  45. package/src/session-follow.js +515 -0
  46. package/src/session-layout3.js +466 -0
  47. package/src/session-migrate.js +123 -0
  48. package/src/session-quilt.js +145 -0
  49. package/src/session-seam.js +284 -0
  50. package/src/session.js +899 -46
  51. package/src/source-live/bring-in.js +283 -0
  52. package/src/source-live/live-block.js +507 -35
  53. package/src/source-live/live-square.js +17 -6
  54. package/src/status.js +5 -0
  55. package/src/ui/agent-guide.js +1 -1
  56. package/src/ui/app.css +85 -20
  57. package/src/ui/app.js +4 -1
  58. package/src/ui/chat.js +9 -3
  59. package/src/ui/cloud-ai-model.js +13 -0
  60. package/src/ui/cloud-ai.js +10 -2
  61. package/src/ui/common.js +6 -2
  62. package/src/ui/fileview.js +20 -7
  63. package/src/ui/force-words.js +19 -0
  64. package/src/ui/git-rule.js +9 -0
  65. package/src/ui/home.js +0 -147
  66. package/src/ui/loom-model.js +15 -1
  67. package/src/ui/quiltview.js +213 -0
  68. package/src/ui/seam-model.js +251 -0
  69. package/src/ui/session.js +413 -31
  70. package/src/ui/voice-call.js +83 -14
  71. package/src/ui/voice-model.js +39 -0
  72. package/src/ui/voice.js +167 -17
  73. package/src/ui-server.js +80 -28
  74. package/src/update-check.js +25 -1
  75. package/src/voice.js +13 -6
  76. package/src/git.js +0 -147
  77. package/src/quilts/AGENTS.md +0 -1
  78. package/src/quilts/CLAUDE.md +0 -1
  79. package/src/quilts/archiver.js +0 -175
  80. package/src/quilts/autorevert.js +0 -49
  81. package/src/quilts/bisect-run.js +0 -31
  82. package/src/quilts/blame.js +0 -103
  83. package/src/quilts/block.js +0 -347
  84. package/src/quilts/bucket.js +0 -25
  85. package/src/quilts/cbor.js +0 -92
  86. package/src/quilts/claims.js +0 -144
  87. package/src/quilts/cli.js +0 -449
  88. package/src/quilts/client.js +0 -128
  89. package/src/quilts/conflict-format.js +0 -190
  90. package/src/quilts/describe.js +0 -266
  91. package/src/quilts/dogfood.js +0 -82
  92. package/src/quilts/errors.js +0 -8
  93. package/src/quilts/events.js +0 -160
  94. package/src/quilts/export-git.js +0 -130
  95. package/src/quilts/export.js +0 -81
  96. package/src/quilts/fastmerge.js +0 -41
  97. package/src/quilts/hash.js +0 -44
  98. package/src/quilts/history.js +0 -266
  99. package/src/quilts/ids.js +0 -12
  100. package/src/quilts/import-git.js +0 -291
  101. package/src/quilts/import.js +0 -134
  102. package/src/quilts/index.js +0 -72
  103. package/src/quilts/limits.js +0 -67
  104. package/src/quilts/lines.js +0 -49
  105. package/src/quilts/live.js +0 -50
  106. package/src/quilts/merge-smart.js +0 -114
  107. package/src/quilts/merge.js +0 -486
  108. package/src/quilts/objects.js +0 -144
  109. package/src/quilts/pack.js +0 -118
  110. package/src/quilts/records.js +0 -211
  111. package/src/quilts/resolutions.js +0 -69
  112. package/src/quilts/reviews.js +0 -195
  113. package/src/quilts/routes.js +0 -126
  114. package/src/quilts/service.js +0 -923
  115. package/src/quilts/setup.js +0 -34
  116. package/src/quilts/signing.js +0 -149
  117. package/src/quilts/squares.js +0 -239
  118. package/src/quilts/store-memory.js +0 -683
  119. package/src/quilts/store-rpc.js +0 -79
  120. package/src/quilts/store-supabase.js +0 -13
  121. package/src/quilts/tags.js +0 -64
  122. package/src/quilts/textdiff.js +0 -96
  123. package/src/quilts/tokens.js +0 -66
  124. package/src/quilts/tools.js +0 -375
  125. package/src/quilts/tree.js +0 -351
  126. package/src/quilts/webhook.js +0 -116
  127. package/src/quilts/window.js +0 -119
  128. package/src/quilts/xdiff.js +0 -435
  129. package/src/ui/branches.js +0 -111
@@ -1,5 +1,5 @@
1
1
  // Quilt's side of seam: live squares in a Quilt session. Seam (src/quilts/) never imports this.
2
- // The folder side of a live square (plans/quilt-source-control/05): a block whose files mirror the
2
+ // The folder side of a live square (Seam's plans/quilt-source-control/05): a block whose files mirror the
3
3
  // square's live document on the relay (live-square.js). Disk under the block is the snapshot at
4
4
  // `block.at` plus the overlay; every edit seen on disk goes into the live document at once, so the
5
5
  // relay always knows the square's uncommitted work, and every change another member of the square
@@ -9,18 +9,41 @@
9
9
  // It reuses the session's sync engine: the relay connection (connection.js: sign-in, passes, the
10
10
  // branch-document protocol), the relay's branch documents themselves, and the text diff into a
11
11
  // Y.Text (textdiff.js). There is no git: every change on disk is an edit (decision 18).
12
+ //
13
+ // In place (`inPlace`): a session's own folder becomes the block at the layout-3 switch (Seam's
14
+ // plans/quilt-source-control/16: the first rebuild is a no-op, because the folder already equals
15
+ // the square's live document). Nothing is checked out: the record is written beside the session's
16
+ // state (.quilt/block.json), what disk holds is compared with the live document file by file, and
17
+ // a difference is the person's edit (in it goes) unless the session says the folder was not
18
+ // touched since it last synced (then the live document's newer version comes to disk). Paths the
19
+ // session never syncs (`skip`) are never read or written. Reads can go through the relay
20
+ // (`reader`), so a member needs no Seam sign-in to follow; uploads and commits are the member's.
12
21
  import fs from 'node:fs'
13
22
  import path from 'node:path'
14
23
  import * as Y from 'yjs'
15
24
  import { Connection, REMOTE } from '../connection.js'
25
+ import { FEATURES } from '../protocol.js'
16
26
  import { applyTextDiff } from '../textdiff.js'
17
- import { blobId, under } from '../quilts/records.js'
18
- import { isText } from '../quilts/textdiff.js'
19
- import { checkout, findBlock, saveBlock, localChanges, BLOCK_DIR } from '../quilts/block.js'
27
+ import { mergeAuto, mergeSmart } from '../merge3.js'
28
+ // Seam's block folders and ids come from Seam's client package (@heyquilt/seam), never Seam's server code.
29
+ import { blobId, under } from '@heyquilt/seam/lib/quilts/records.js'
30
+ import { isText } from '@heyquilt/seam/lib/quilts/textdiff.js'
31
+ import { checkout, findBlock, saveBlock, localChanges, conflictStyle, BLOCK_DIR } from '@heyquilt/seam/lib/quilts/block.js'
20
32
  import { liveKey, liveMaps, liveHead, entryOf, entryId, overlay } from './live-square.js'
21
33
 
22
34
  export const MAX_LIVE_TEXT = 2 * 1024 * 1024
23
35
  const SCAN_MS = 40
36
+ // On macOS, Node's fs.watch shares one FSEvents stream per process and rebuilds it whenever a watch
37
+ // is added or removed (another folder's block opening, this folder's next square after a merge), so
38
+ // an edit in that gap is never delivered. The block looks at the folder this often too, as the
39
+ // session does before the switch (session.js RECONCILE_MS).
40
+ export const RECONCILE_MS = 1000
41
+ // After a watcher starts, the folder is scanned again at these times (watch).
42
+ const WATCH_SETTLE_MS = [250, 1000, 3000]
43
+ // And this often the folder is brought in step with the square even with no news from the relay (a
44
+ // write-out that failed, or one that never ran, is tried again): disk and square never disagree for
45
+ // long, nor without a word (2026-11-01: a folder kept a file the square had deleted).
46
+ export const STEP_MS = 15000
24
47
 
25
48
  const waitUntil = async (fn, ms = 10000, what = 'the relay') => {
26
49
  const t = Date.now()
@@ -40,10 +63,23 @@ export class LiveBlock {
40
63
  * @param {string[]} [o.paths]
41
64
  * @param {object} o.relay { server, room, secret, name, identity, kind?, passes?, tool? }: the session
42
65
  */
43
- constructor ({ dir, client, quilt, square, paths = [''], relay, api = '' }) {
44
- Object.assign(this, { dir: path.resolve(dir), client, quilt, squareRef: square, paths, relayOpts: relay, api })
66
+ /**
67
+ * In place, also: `inPlace` { edited(path) }: whether the session saw the folder's copy change
68
+ * since it last synced it; `skip(path)`: the session never syncs it (`skipDir(path)`: nor
69
+ * anything in that folder); `record`: where block.json
70
+ * goes; `reader`: a client for reads (square, tree, blobs, diff), else `client`; `log`;
71
+ * `onEdits(list, doc, held)`: the edits this folder made, as each scan takes them into the live
72
+ * document `doc` ([{ path, kind: created|edited|deleted, detail, before?, after? }]), so the session
73
+ * keeps them on record (who changed what) as it did on its branch; `held()` resolves once the relay
74
+ * holds them.
75
+ */
76
+ constructor ({ dir, client, quilt, square, paths = [''], relay, api = '', inPlace = null, skip = null, skipDir = null, record = null, reader = null, log = null, onEdits = null, started = null, stepMs = STEP_MS }) {
77
+ Object.assign(this, { dir: path.resolve(dir), client, quilt, squareRef: square, paths, relayOpts: relay, api, inPlace, skip, skipDir, record, reader: reader || client, logFn: log, onEdits, stepMs })
78
+ // `started(block)`: told as it starts to open, so whoever waits for it can stop it part-way (abandon).
79
+ if (started) started(this)
45
80
  this.known = {} // path -> { blob, mode, size, mtimeMs }: what is on disk as this block last read or wrote it
46
81
  this.cache = new Map() // blob id -> bytes (the snapshot's files this block wrote out)
82
+ this.lastText = new Map() // path -> { id, text }: a live text as disk last had it (bytes no server holds), for combining (scan)
47
83
  this.queue = Promise.resolve()
48
84
  this.closed = false
49
85
  this.errors = []
@@ -57,6 +93,15 @@ export class LiveBlock {
57
93
  }
58
94
 
59
95
  async open () {
96
+ // A block that fails part-way lets go of what it opened: its relay connection would otherwise
97
+ // reconnect for good, one more with each try the folder makes to follow its square.
98
+ try { return await (this.inPlace ? this.openInPlace() : this.openCheckout()) } catch (err) {
99
+ this.abandon()
100
+ throw err
101
+ }
102
+ }
103
+
104
+ async openCheckout () {
60
105
  const found = findBlock(this.dir)
61
106
  if (found && found.dir === this.dir) this.block = found.block
62
107
  else {
@@ -67,29 +112,302 @@ export class LiveBlock {
67
112
  if (!b.square) throw new Error('a live block needs a square')
68
113
  b.live = liveKey(b.square)
69
114
  for (const [p, f] of Object.entries(b.files)) this.known[p] = { ...f }
70
- saveBlock(this.dir, b)
115
+ this.save()
116
+ await this.connect()
117
+ await this.run(async () => { await this.scan(); await this.pull() })
118
+ this.watch()
119
+ return this
120
+ }
121
+
122
+ /** The relay connection on the square's live document, with its head set. */
123
+ async connect () {
124
+ const b = this.block
71
125
  this.roomDoc = new Y.Doc()
72
126
  this.doc = new Y.Doc()
73
127
  const r = this.relayOpts
74
- this.conn = new Connection({ ...r, kind: r.kind || 'human', doc: this.roomDoc, branch: { key: b.live, doc: this.doc } })
128
+ // quilt-block: the relay counts it as part of the app's visit, not a visit of its own (the audit trail).
129
+ // Only the square's live document: the app's own connection has the room's (chat, tasks, activity).
130
+ this.conn = new Connection({ ...r, kind: r.kind || 'human', features: `${r.features || FEATURES},quilt-block`, roomDoc: false, doc: this.roomDoc, branch: { key: b.live, doc: this.doc } })
75
131
  this.conn.on('fatal', (err) => this.errors.push(err))
76
- await this.conn.waitForSync()
77
- await this.conn.waitForBranchSync()
132
+ // Stopped while it opens (the session stopped, or the folder moved on): its waits for the relay end with it.
133
+ const stopped = new Promise((resolve, reject) => { this.stopOpening = () => reject(new Error('the block was closed while it opened')) })
134
+ stopped.catch(() => {})
135
+ if (this.closed) this.stopOpening()
136
+ await Promise.race([this.conn.waitForSync(), stopped])
137
+ await Promise.race([this.conn.waitForBranchSync(), stopped])
78
138
  if (!liveHead(this.doc)) {
79
- const sq = await this.client.square(b.quilt, b.square)
139
+ const sq = await this.reader.square(b.quilt, b.square)
80
140
  await this.conn.branchRequest({ op: 'start', branch: b.live, head: sq.head || sq.base })
81
141
  await waitUntil(() => liveHead(this.doc), 10000, 'the live head')
82
142
  }
83
143
  this.doc.on('update', (u, origin) => { if (origin === REMOTE && !this.closed) this.run(() => this.pull()) })
84
- await this.run(async () => { await this.scan(); await this.pull() })
144
+ }
145
+
146
+ watch () {
85
147
  this.watcher = fs.watch(this.dir, { recursive: true }, (ev, name) => {
86
- if (this.closed || !name || String(name).split(path.sep)[0] === BLOCK_DIR) return
148
+ if (this.closed || !name) return
149
+ const rel = String(name).split(path.sep).join('/')
150
+ if (rel.split('/')[0] === BLOCK_DIR || (this.skip && this.skip(rel))) return
87
151
  clearTimeout(this.scanTimer)
88
152
  this.scanTimer = setTimeout(() => this.run(() => this.scan()), SCAN_MS)
89
153
  })
154
+ this.reconcileTimer = setInterval(() => {
155
+ if (this.closed || this.reconciling) return
156
+ this.reconciling = true
157
+ this.run(() => this.scan()).catch(() => {}).finally(() => { this.reconciling = false })
158
+ }, RECONCILE_MS)
159
+ this.reconcileTimer.unref?.()
160
+ this.stepTimer = setInterval(() => { if (!this.closed) this.run(() => this.pull()).catch(() => {}) }, this.stepMs)
161
+ this.stepTimer.unref?.()
162
+ // A watcher just started does not hear everything at once (macOS's FSEvents takes a moment): a
163
+ // save in that moment would wait for the next change in the folder. Looked at again, to be sure.
164
+ this.settleTimers = WATCH_SETTLE_MS.map((ms) => {
165
+ const t = setTimeout(() => { if (!this.closed) this.run(() => this.scan()) }, ms)
166
+ t.unref?.()
167
+ return t
168
+ })
169
+ }
170
+
171
+ /**
172
+ * The block's record: Seam's .seam/block.json, or (in place) the file the session names, which
173
+ * also keeps what disk held as the block last read or wrote it (`known`): after a restart, a file
174
+ * that differs from it is an edit made while the app was closed, and any other difference is the
175
+ * square's newer version.
176
+ */
177
+ save () {
178
+ clearTimeout(this.saveTimer)
179
+ if (!this.record) return saveBlock(this.dir, this.block)
180
+ fs.mkdirSync(path.dirname(this.record), { recursive: true })
181
+ fs.writeFileSync(this.record + '.tmp', JSON.stringify({ ...this.block, known: this.known }))
182
+ fs.renameSync(this.record + '.tmp', this.record)
183
+ }
184
+
185
+ saveSoon () {
186
+ if (!this.record || this.saveTimer || this.closed) return
187
+ this.saveTimer = setTimeout(() => { this.saveTimer = null; try { this.save() } catch {} }, 2000)
188
+ this.saveTimer.unref?.()
189
+ }
190
+
191
+ /**
192
+ * The in-place record of an earlier run in this quilt: its `known`, or null. On this square (the
193
+ * app restarted), or on the branch's earlier square (a merge moved the folder on to the next one).
194
+ */
195
+ priorKnown () {
196
+ try {
197
+ const r = JSON.parse(fs.readFileSync(this.record, 'utf8'))
198
+ return r && r.inPlace && r.quilt === this.quilt && r.known && typeof r.known === 'object' ? r.known : null
199
+ } catch { return null }
200
+ }
201
+
202
+ /**
203
+ * The session's own folder as the block, nothing checked out (see the top of this file). What
204
+ * happened is logged: { same, taken, brought }, the files that matched, the person's edits that
205
+ * went in, and the live document's newer versions written to disk.
206
+ */
207
+ async openInPlace () {
208
+ const sq = await this.reader.square(this.quilt, this.squareRef)
209
+ const prior = this.inPlace.fresh ? null : this.priorKnown()
210
+ // Followed before (the app restarted, or the folder moved on to its branch's next square): edits
211
+ // are what changed since the block last saw the folder, not since the session's last sync, and
212
+ // a file only the square changed (its new head: what a merge took in from main) is the square's.
213
+ if (prior) this.inPlace = { ...this.inPlace, edited: (p, blob) => (blob ?? null) !== ((prior[p] && prior[p].blob) ?? null), base: null, baseBlob: (p) => (prior[p] && prior[p].blob) || null, seen: true }
214
+ this.block = { api: this.api, quilt: this.quilt, square: sq.id, square_short: sq.short || null, line: sq.line || null, paths: [''], at: null, head: null, files: {}, inPlace: true, live: liveKey(sq.id) }
215
+ await this.connect()
216
+ const head = this.head
217
+ const t = await this.reader.tree(this.quilt, `c:${head}`)
218
+ const b = this.block
219
+ for (const e of (t && t.entries) || []) b.files[e.path] = { blob: e.blob || null, mode: e.mode, size: e.size, ...(e.term ? { term: e.term } : {}) }
220
+ b.at = head
221
+ b.head = sq.head || null // a commit's expect_head: none while the square has no commit of its own (its live head is then its base)
222
+ this.save()
223
+ this.summary = await this.run(() => this.adopt())
224
+ this.watch()
90
225
  return this
91
226
  }
92
227
 
228
+ /** What the live document says `p` holds, as a blob id (null: absent), and whether it says so itself (not only by its head). */
229
+ wantedId (p) {
230
+ const e = entryOf(this.doc, p)
231
+ if (e) return { id: entryId(e), mode: e.op === 'delete' ? null : e.mode, named: true }
232
+ const s = this.block.files[p]
233
+ const m = liveMaps(this.doc)
234
+ return { id: s ? s.blob : null, mode: s ? s.mode : null, named: m.clean.has(p) }
235
+ }
236
+
237
+ /**
238
+ * In place, once: each file on disk against the live document. Equal: nothing to do. Different
239
+ * and the session saw the folder's copy change since it last synced, or, the first time the
240
+ * folder follows a square, a path the live document does not name that the folder's branch
241
+ * changed since it left main (`inPlace.own`, from git: in its commits, or not committed and not
242
+ * synced by the session, whose own changes are others' work too; without git: a file the folder
243
+ * has that the session never synced): the person's, which the scan after this takes in;
244
+ * when the square changed the same text since the session last synced it, both changes are
245
+ * combined first (combineAway). Otherwise the live document's version (another member's, since
246
+ * the folder's last sync, or the square's head) is written: a file the folder lacks, or has as git
247
+ * left it, is never taken for the person's deletion or edit (a folder whose git was behind main,
248
+ * a branch that left main before main's newer work, or a member back after the switch would
249
+ * undo main's work in the square, and on main at the next merge).
250
+ */
251
+ async adopt () {
252
+ const m = liveMaps(this.doc)
253
+ const firstLook = !this.inPlace.seen && !this.inPlace.fresh
254
+ const own = firstLook && this.inPlace.own ? await this.inPlace.own() : null
255
+ const disk = new Map(this.diskChanges({}).filter((c) => !this.skipped(c.path)).map((c) => [c.path, c]))
256
+ const paths = new Set([...disk.keys(), ...Object.keys(this.block.files), ...m.files.keys(), ...m.blobs.keys(), ...m.gone.keys(), ...m.clean.keys()])
257
+ const out = { same: 0, taken: [], brought: [], combined: [] }
258
+ for (const p of [...paths].sort()) {
259
+ if (this.skipped(p) || !this.block.paths.some((x) => under(p, x)) || (this.block.files[p] || {}).mode === 'gitlink' || (this.inPlace && this.nested(p))) continue
260
+ const d = disk.get(p)
261
+ const w = this.wantedId(p)
262
+ if (entryOf(this.doc, p) && this.staleCopy(p, d, own, firstLook)) {
263
+ await this.headStands(p, m)
264
+ out.brought.push(p)
265
+ continue
266
+ }
267
+ if ((d ? d.blob : null) === w.id) {
268
+ // The same bytes: a different mode on disk (made executable here) goes in as an edit too.
269
+ if (d) { const st = fs.lstatSync(path.join(this.dir, ...p.split('/'))); this.known[p] = w.mode && w.mode !== d.mode ? { blob: d.blob, mode: w.mode, size: -1, mtimeMs: -1 } : { blob: d.blob, mode: d.mode, size: st.size, mtimeMs: st.mtimeMs } }
270
+ out.same++
271
+ continue
272
+ }
273
+ // A file the session held back from this folder (someone else's box, or a change waiting for
274
+ // whoever held the file): the folder never had the session's copy, so lacking it is no deletion.
275
+ // The square's copy comes to disk (2026-11-01: a folder's first look deleted such a file).
276
+ if (firstLook && !d && w.id && this.inPlace.heldBack && this.inPlace.heldBack(p)) {
277
+ this.write(p, await this.wanted(p))
278
+ out.brought.push(p)
279
+ continue
280
+ }
281
+ const synced = !!(this.inPlace.synced && this.inPlace.synced(p))
282
+ const branchs = firstLook && !w.named && (own ? own.committed.has(p) || (own.uncommitted.has(p) && !synced) : !!d && !synced)
283
+ if (this.inPlace.edited(p, d ? d.blob : null) || branchs) {
284
+ // The square changed it too since the folder last had it (a merge while the app was closed):
285
+ // only the folder's own edit goes in, combined with the square's, never its old copy over it.
286
+ const merged = d && w.id ? await this.combineAway(p, d) : null
287
+ if (merged) { this.write(p, { bytes: merged, mode: d.mode }); out.combined.push(p) }
288
+ // The person's: the scan sees disk differ from this and takes it in (a deletion when disk has nothing).
289
+ if (w.id) this.known[p] = { blob: w.id, mode: w.mode, size: -1, mtimeMs: -1 }
290
+ out.taken.push(p)
291
+ continue
292
+ }
293
+ this.write(p, await this.wanted(p))
294
+ out.brought.push(p)
295
+ }
296
+ await this.scan()
297
+ this.save()
298
+ if (this.logFn && (out.taken.length || out.brought.length)) {
299
+ const list = (a) => `${a.slice(0, 5).join(', ')}${a.length > 5 ? `, and ${a.length - 5} more` : ''}`
300
+ this.logFn(`${out.same} file(s) already matched the square${out.taken.length ? `; ${out.taken.length} edit(s) of yours went in (${list(out.taken)})` : ''}${out.combined.length ? `, combined with the square's changes to them since this folder last synced (${list(out.combined)})` : ''}${out.brought.length ? `; ${out.brought.length} newer version(s) came from the square (${list(out.brought)})` : ''}.`)
301
+ }
302
+ return out
303
+ }
304
+
305
+ /**
306
+ * Whether the live document's entry for `p` is the branch's older copy of main's file, not work: in
307
+ * a square the move started at main's tip for want of the branch's own commit (`inPlace.atTip`), a
308
+ * full branch document names every file as the branch has it, and a file main changed (or deleted,
309
+ * or added) after the branch left it, that the branch never changed (git: not in its commits since
310
+ * it left main, nothing uncommitted here, no edit the session saw), would undo main's change at the
311
+ * square's merge. Only at the first look, with git to ask.
312
+ */
313
+ staleCopy (p, d, own, firstLook) {
314
+ if (!firstLook || !this.inPlace.atTip || !own) return false
315
+ if (own.committed.has(p) || own.uncommitted.has(p)) return false
316
+ return !this.inPlace.edited(p, d ? d.blob : null)
317
+ }
318
+
319
+ /** The square's head version of `p` stands: the live document's entry goes, and disk gets the head's file (or loses it). */
320
+ async headStands (p, m) {
321
+ const s = this.block.files[p]
322
+ this.write(p, s ? await this.headFile(p, s) : null)
323
+ this.doc.transact(() => {
324
+ m.files.delete(p); m.blobs.delete(p); m.gone.delete(p); m.modes.delete(p)
325
+ if (s) m.clean.set(p, { at: this.block.at }); else m.clean.delete(p)
326
+ })
327
+ }
328
+
329
+ /**
330
+ * The folder's copy of `p` (disk entry `d`) combined with the square's, against what the folder last
331
+ * had (the session's last sync of it, `inPlace.base`; after a restart, the blob the block last saw):
332
+ * the bytes to write, or null when there is nothing to combine (the square did not change it since,
333
+ * or that base is not known, or a side is not text). Where both changed the same lines, the folder's
334
+ * edit is kept (mergeAuto, the folder's side newer) and the square's lines it replaced are said.
335
+ */
336
+ async combineAway (p, d) {
337
+ if (!d.bytes || d.mode === 'symlink' || !isText(d.bytes)) return null
338
+ let base = this.inPlace.base ? await this.inPlace.base(p) : null
339
+ if (typeof base !== 'string' && this.inPlace.baseBlob) {
340
+ const id = this.inPlace.baseBlob(p)
341
+ if (id) { try { const b = await this.bytesOf(id); if (isText(b)) base = b.toString('utf8') } catch {} }
342
+ }
343
+ if (typeof base !== 'string') return null
344
+ const ours = d.bytes.toString('utf8')
345
+ // Someone else's box on it before the move (inPlace.boxed): the folder kept its own copy (git's,
346
+ // moved on by pulls) while the session held theirs, and `base` is what everyone had when the box
347
+ // began. Their change goes onto the folder's copy, never their whole older file over it.
348
+ const boxed = !!(this.inPlace.boxed && this.inPlace.boxed(p))
349
+ // The folder's copy is what it last had: nothing of its own to combine. It is taken as it is (an
350
+ // edit the session took in that the relay never held, a path the square does not name at the switch).
351
+ if (base === ours && !boxed) return null
352
+ const want = await this.wanted(p)
353
+ if (!want || want.mode === 'symlink' || !isText(want.bytes)) return null
354
+ const theirs = want.bytes.toString('utf8')
355
+ if (theirs === ours) return null
356
+ // A box on a file the folder never moved past: the box's work is the file now.
357
+ if (base === ours) return want.bytes
358
+ if (theirs === base) return null
359
+ const r = mergeAuto(base, ours, theirs, { oursNewer: true })
360
+ if (r.lost.length && this.logFn) this.logFn(`${p}: your edit made while away and the square's both changed the same lines; yours were kept, and the square's (${r.lost.reduce((n, x) => n + x.lines.length, 0)} line(s)) are in its history`)
361
+ return r.text === ours ? null : Buffer.from(r.text, 'utf8')
362
+ }
363
+
364
+ skipped (p) { return !!(this.skip && this.skip(p)) }
365
+
366
+ /** Whether `p` is inside a folder with a .git of its own (a submodule): never this block's to read or write. */
367
+ nested (p) {
368
+ const parts = p.split('/')
369
+ for (let i = 1; i < parts.length; i++) if (fs.existsSync(path.join(this.dir, ...parts.slice(0, i), '.git'))) return true
370
+ return false
371
+ }
372
+
373
+ /**
374
+ * What changed on disk against `files` (path -> { blob, mode, size, mtimeMs }), as Seam's
375
+ * localChanges answers. In place, the walk never enters a folder the session never syncs
376
+ * (`skipDir`: .git, .quilt with the git folder set aside in it, node_modules, ignored ones), so a
377
+ * big folder is not read on every scan, nor one that is a repository of its own (a submodule).
378
+ */
379
+ diskChanges (files) {
380
+ if (!this.inPlace) return localChanges(this.dir, { paths: this.block.paths, files })
381
+ const out = []
382
+ const seen = new Set()
383
+ const visit = (rel) => {
384
+ let names
385
+ try { names = fs.readdirSync(path.join(this.dir, rel), { withFileTypes: true }) } catch { return }
386
+ for (const d of names) {
387
+ const r = rel ? `${rel}/${d.name}` : d.name
388
+ if (d.name === '.git' || d.name === BLOCK_DIR) continue
389
+ // A folder with a .git of its own (a submodule, a repository inside this one) is its own: the square holds it as a gitlink at most.
390
+ if (d.isDirectory()) { if (!(this.skipDir && this.skipDir(r)) && !fs.existsSync(path.join(this.dir, r, '.git'))) visit(r); continue }
391
+ if (!(d.isFile() || d.isSymbolicLink()) || this.skipped(r)) continue
392
+ const abs = path.join(this.dir, r)
393
+ let st
394
+ try { st = fs.lstatSync(abs) } catch { continue }
395
+ seen.add(r)
396
+ const k = files[r]
397
+ if (k && k.size === st.size && k.mtimeMs === st.mtimeMs) continue
398
+ let bytes
399
+ try { bytes = st.isSymbolicLink() ? Buffer.from(fs.readlinkSync(abs)) : fs.readFileSync(abs) } catch { continue }
400
+ const mode = st.isSymbolicLink() ? 'symlink' : (st.mode & 0o111) && process.platform !== 'win32' ? 'exec' : 'file'
401
+ const id = blobId(bytes)
402
+ if (k && k.blob === id && k.mode === mode) continue
403
+ out.push({ path: r, status: k ? 'modified' : 'added', blob: id, mode, size: bytes.length, bytes })
404
+ }
405
+ }
406
+ visit('')
407
+ for (const p of Object.keys(files)) if (!seen.has(p) && !this.skipped(p) && !this.nested(p)) out.push({ path: p, status: 'deleted' })
408
+ return out.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
409
+ }
410
+
93
411
  get head () { return liveHead(this.doc) }
94
412
  get by () { return this.relayOpts.name || '' }
95
413
 
@@ -98,48 +416,142 @@ export class LiveBlock {
98
416
  if (this.block.at !== this.head) return // 05: no ingest against the wrong base; pull re-projects first
99
417
  const m = liveMaps(this.doc)
100
418
  const snap = this.block.files
101
- const changes = localChanges(this.dir, { paths: this.block.paths, files: this.known })
419
+ let changes = this.diskChanges(this.known)
420
+ // What the session never syncs is never taken in; noting its size and time spares reading it again.
421
+ for (const c of changes) {
422
+ if (!this.skipped(c.path)) continue
423
+ if (c.status === 'deleted') { delete this.known[c.path]; continue }
424
+ try { const st = fs.lstatSync(path.join(this.dir, ...c.path.split('/'))); this.known[c.path] = { blob: c.blob, mode: c.mode, size: st.size, mtimeMs: st.mtimeMs } } catch {}
425
+ }
426
+ changes = changes.filter((c) => !this.skipped(c.path))
102
427
  if (!changes.length) return
103
- const uploads = []
428
+ let uploads = []
104
429
  for (const c of changes) {
105
430
  if (c.status === 'deleted') continue
106
431
  if (c.mode === 'symlink' || !isText(c.bytes) || c.bytes.length > MAX_LIVE_TEXT) uploads.push({ kind: 'blob', id: c.blob, bytes: c.bytes })
107
432
  }
108
- if (uploads.length) await this.client.upload(this.block.quilt, uploads)
433
+ if (uploads.length) {
434
+ // A binary needs Seam to hold its bytes first, as this member. Without that, the texts still go in, and the binaries wait for the next scan.
435
+ try {
436
+ if (!this.client) throw new Error('sign in to Seam (quilt login) so binary files reach the square')
437
+ await this.client.upload(this.block.quilt, uploads)
438
+ this.problem = null
439
+ } catch (err) {
440
+ const ids = new Set(uploads.map((u) => u.id))
441
+ const waiting = changes.filter((c) => c.status !== 'deleted' && ids.has(c.blob)).map((c) => c.path)
442
+ this.problem = `${waiting.length} binary file(s) are not in the square yet (${waiting.slice(0, 3).join(', ')}): ${err.message}`
443
+ if (this.logFn) this.logFn(this.problem)
444
+ changes = changes.filter((c) => !waiting.includes(c.path))
445
+ uploads = []
446
+ }
447
+ }
448
+ // What each file held before, for the record of who changed what (onEdits): the live document's text, else the head's.
449
+ const before = new Map()
450
+ if (this.onEdits) {
451
+ for (const c of changes) {
452
+ const t = m.files.get(c.path)
453
+ if (t) { before.set(c.path, t.toString()); continue }
454
+ const b = snap[c.path]
455
+ if (!b || !b.blob || m.blobs.has(c.path) || m.gone.has(c.path) || (b.size || 0) > MAX_LIVE_TEXT) continue
456
+ try { const bytes = await this.bytesOf(b.blob); if (isText(bytes)) before.set(c.path, bytes.toString('utf8')) } catch {}
457
+ }
458
+ }
459
+ // A file changed on disk and in the square since disk last had it (someone's edit, or main brought in,
460
+ // reached the live document or the head before this edit was read): what disk last had is the base
461
+ // the two are combined against, so the edit goes in as a change and nothing the square has is undone.
462
+ const both = new Map()
463
+ for (const c of changes) {
464
+ if (c.status === 'deleted' || c.mode === 'symlink' || c.bytes.length > MAX_LIVE_TEXT || !isText(c.bytes)) continue
465
+ const k = this.known[c.path]
466
+ if (!k || !k.blob || k.blob === c.blob) continue
467
+ const e = entryOf(this.doc, c.path)
468
+ const s = snap[c.path]
469
+ const now = e ? entryId(e) : s && s.mode !== 'conflict' ? s.blob : null
470
+ if (!now || now === k.blob) continue
471
+ try {
472
+ const last = this.lastText.get(c.path)
473
+ const base = last && last.id === k.blob ? last.text : (await this.bytesOf(k.blob)).toString('utf8')
474
+ const head = e ? null : (await this.bytesOf(s.blob))
475
+ if (head && !isText(head)) continue
476
+ both.set(c.path, { base, head: head ? head.toString('utf8') : null })
477
+ } catch {}
478
+ }
479
+ const taken = []
480
+ const rewrite = []
109
481
  this.doc.transact(() => {
110
482
  for (const c of changes) {
111
483
  const p = c.path
112
484
  const base = snap[p]
485
+ const had = !!(base && !m.gone.has(p)) || m.files.has(p) || m.blobs.has(p)
486
+ // Whose work this path is now (on going back to git, its box is theirs: Session.leaveSquare), and the
487
+ // head it was made on: the relay combines an edit made on a head it has moved on from (bring-in.js lateEdits).
488
+ m.writers.set(p, { by: this.by, ts: Date.now(), at: this.block.at })
113
489
  if (c.status === 'deleted') {
114
490
  if (base) { m.clean.delete(p); m.gone.set(p, { by: this.by, ts: Date.now() }) } else m.clean.set(p, { at: this.block.at })
115
491
  delete this.known[p]
492
+ if (had) taken.push({ path: p, kind: 'deleted', detail: '', ...(before.has(p) ? { before: before.get(p), after: '' } : {}) })
116
493
  continue
117
494
  }
118
- if (base && base.blob === c.blob && base.mode === c.mode) { m.clean.set(p, { at: this.block.at }); m.gone.delete(p) } else if (uploads.some((u) => u.id === c.blob)) {
495
+ const kind = had ? 'edited' : 'created'
496
+ if (base && base.blob === c.blob && base.mode === c.mode) {
497
+ m.clean.set(p, { at: this.block.at }); m.gone.delete(p)
498
+ // Back as the head has it: an edit too (undoing one), unless it never left.
499
+ if (before.has(p) && before.get(p) !== c.bytes.toString('utf8')) taken.push({ path: p, kind, detail: '', before: before.get(p), after: c.bytes.toString('utf8') })
500
+ } else if (uploads.some((u) => u.id === c.blob)) {
119
501
  m.clean.delete(p); m.gone.delete(p)
120
502
  m.blobs.set(p, { blob: c.blob, size: c.bytes.length, mode: c.mode })
503
+ taken.push({ path: p, kind, detail: `${c.bytes.length} bytes` })
121
504
  } else {
122
505
  m.clean.delete(p); m.gone.delete(p); m.blobs.delete(p)
123
506
  let t = m.files.get(p)
507
+ const fresh = !t
124
508
  if (!t) { t = new Y.Text(); m.files.set(p, t) }
125
- applyTextDiff(t, c.bytes.toString('utf8'))
509
+ let after = c.bytes.toString('utf8')
510
+ const w = both.get(p)
511
+ const cur = w ? (fresh ? w.head : t.toString()) : null
512
+ if (w && cur !== null && cur !== w.base && cur !== after) {
513
+ const r = mergeSmart(w.base, after, cur)
514
+ // The same lines changed on both sides: the newer edit, this one, wins them (as everywhere in Quilt).
515
+ after = r.conflicts.length ? mergeAuto(w.base, after, cur, { oursNewer: true }).text : r.text
516
+ if (after !== c.bytes.toString('utf8')) rewrite.push({ path: p, bytes: Buffer.from(after, 'utf8'), mode: c.mode })
517
+ }
518
+ let detail = applyTextDiff(t, after)
519
+ this.lastText.set(p, { id: blobId(Buffer.from(after, 'utf8')), text: after })
520
+ // A file new to the live document took its whole text: what changed is measured from the head's.
521
+ if (fresh && before.has(p)) { const probe = new Y.Doc().getText(); probe.insert(0, before.get(p)); detail = applyTextDiff(probe, after) }
126
522
  if (c.mode === 'exec') m.modes.set(p, 'exec'); else if (m.modes.has(p)) m.modes.delete(p)
523
+ if (detail || !had) taken.push({ path: p, kind, detail, before: before.get(p) ?? '', after })
127
524
  }
128
525
  const st = fs.lstatSync(path.join(this.dir, ...p.split('/')))
129
526
  this.known[p] = { blob: c.blob, mode: c.mode, size: st.size, mtimeMs: st.mtimeMs }
130
527
  }
131
528
  })
529
+ // Disk gets the combination: what the square had that this edit was not made on.
530
+ for (const x of rewrite) this.write(x.path, x)
531
+ this.saveSoon()
532
+ if (this.onEdits && taken.length) { try { this.onEdits(taken, this.doc, () => this.conn.confirmBranch()) } catch (err) { if (this.logFn) this.logFn(`could not record ${taken.length} edit(s): ${err.message}`) } }
132
533
  }
133
534
 
134
535
  /** Bytes of a blob, from this block's cache or the server. */
135
536
  async bytesOf (id) {
136
537
  if (!this.cache.has(id)) {
137
- const { blobs } = await this.client.blobs(this.block.quilt, [id])
538
+ const { blobs } = await this.reader.blobs(this.block.quilt, [id])
138
539
  this.cache.set(id, Buffer.from(blobs[id], 'base64'))
139
540
  }
140
541
  return this.cache.get(id)
141
542
  }
142
543
 
544
+ /**
545
+ * The head's file `s` (block.files) at `p` as disk holds it: { bytes, mode }. A conflict Seam keeps in
546
+ * the square (a term: a rebase or merge that did not combine) has no bytes of its own: it is written
547
+ * with its markers, as Seam's own folders write it, and settled by editing them out and committing.
548
+ */
549
+ async headFile (p, s, rev = `c:${this.block.at}`) {
550
+ if (s.mode !== 'conflict') return { bytes: await this.bytesOf(s.blob), mode: s.mode }
551
+ const r = await this.reader.cat(this.block.quilt, rev, p, { format: conflictStyle() })
552
+ return { bytes: Buffer.from((r && r.content) || '', 'base64'), mode: 'file' }
553
+ }
554
+
143
555
  /** What disk should hold at `p`: { bytes, mode } or null (absent). */
144
556
  async wanted (p) {
145
557
  const e = entryOf(this.doc, p)
@@ -147,12 +559,12 @@ export class LiveBlock {
147
559
  if (e && e.bytes) return { bytes: e.bytes, mode: e.mode }
148
560
  if (e && e.blob) return { bytes: await this.bytesOf(e.blob), mode: e.mode }
149
561
  const s = this.block.files[p]
150
- return s ? { bytes: await this.bytesOf(s.blob), mode: s.mode } : null
562
+ return s ? this.headFile(p, s) : null
151
563
  }
152
564
 
153
565
  write (p, want) {
154
566
  const abs = path.join(this.dir, ...p.split('/'))
155
- if (!want) { fs.rmSync(abs, { force: true }); delete this.known[p]; return }
567
+ if (!want) { fs.rmSync(abs, { force: true }); delete this.known[p]; this.lastText.delete(p); return }
156
568
  fs.mkdirSync(path.dirname(abs), { recursive: true })
157
569
  const tmp = path.join(path.dirname(abs), `.${path.basename(abs)}.quilt-tmp`)
158
570
  if (want.mode === 'symlink') { fs.rmSync(abs, { force: true }); fs.symlinkSync(want.bytes.toString('utf8'), abs) } else {
@@ -161,6 +573,9 @@ export class LiveBlock {
161
573
  }
162
574
  const st = fs.lstatSync(abs)
163
575
  this.known[p] = { blob: blobId(want.bytes), mode: want.mode, size: st.size, mtimeMs: st.mtimeMs }
576
+ if (this.doc && liveMaps(this.doc).files.has(p) && want.bytes.length <= MAX_LIVE_TEXT) this.lastText.set(p, { id: this.known[p].blob, text: want.bytes.toString('utf8') })
577
+ else this.lastText.delete(p)
578
+ this.saveSoon()
164
579
  }
165
580
 
166
581
  /** Moves the block to the document's head (a commit by anyone in the square), then writes the overlay out. */
@@ -170,32 +585,51 @@ export class LiveBlock {
170
585
  await this.scan() // an edit not yet ingested is merged in before a remote change is written over it
171
586
  const m = liveMaps(this.doc)
172
587
  const paths = new Set([...m.files.keys(), ...m.blobs.keys(), ...m.gone.keys(), ...m.clean.keys()])
588
+ // One file that can't be written out (its bytes not readable, the disk refusing) never holds up the
589
+ // others, and is said (outOfStep, in the folder's status), then tried again (STEP_MS).
590
+ const failed = []
173
591
  for (const p of paths) {
174
- if (!this.block.paths.some((x) => under(p, x))) continue
175
- const want = await this.wanted(p)
176
- const id = want ? blobId(want.bytes) : null
177
- const have = this.known[p] ? this.known[p].blob : null
178
- if (id !== have || (want && this.known[p].mode !== want.mode)) this.write(p, want)
592
+ if (!this.block.paths.some((x) => under(p, x)) || this.skipped(p) || (this.inPlace && this.nested(p))) continue
593
+ try {
594
+ const want = await this.wanted(p)
595
+ const id = want ? blobId(want.bytes) : null
596
+ const have = this.known[p] ? this.known[p].blob : null
597
+ if (id !== have || (want && this.known[p].mode !== want.mode)) this.write(p, want)
598
+ } catch (err) { failed.push({ path: p, err }) }
179
599
  }
600
+ this.noteOutOfStep(failed)
601
+ }
602
+
603
+ /** What a write-out could not bring in step, said once while it lasts (and when it is over). */
604
+ noteOutOfStep (failed) {
605
+ const was = this.outOfStep || null
606
+ this.outOfStep = failed.length ? `${failed.length} file(s) here are not as the square has them yet (${failed.slice(0, 3).map((f) => f.path).join(', ')}${failed.length > 3 ? ', …' : ''}): ${String(failed[0].err && failed[0].err.message || failed[0].err).slice(0, 200)}` : null
607
+ if (this.logFn && this.outOfStep && this.outOfStep !== was) this.logFn(this.outOfStep)
608
+ if (this.logFn && was && !this.outOfStep) this.logFn('every file here is as the square has it again.')
180
609
  }
181
610
 
182
611
  /** 05 "Moving block.at": rewrites the files that are Quilt's; files in the overlay are the holder's and stay. */
183
612
  async reproject (to) {
184
613
  const b = this.block
185
- const d = await this.client.diff(b.quilt, { from: `c:${b.at}`, to: `c:${to}`, paths: b.paths, format: 'names' })
186
- const t = d.files.length ? await this.client.tree(b.quilt, `c:${to}`, b.paths) : { entries: [] }
614
+ const d = await this.reader.diff(b.quilt, { from: `c:${b.at}`, to: `c:${to}`, paths: b.paths, format: 'names' })
615
+ const t = d.files.length ? await this.reader.tree(b.quilt, `c:${to}`, b.paths) : { entries: [] }
187
616
  const byPath = new Map(t.entries.map((e) => [e.path, e]))
188
- const local = new Set(localChanges(this.dir, { paths: b.paths, files: this.known }).map((c) => c.path))
617
+ const local = new Set(this.diskChanges(this.known).map((c) => c.path))
189
618
  for (const f of d.files) {
190
619
  const e = byPath.get(f.path)
191
- if (e) b.files[f.path] = { blob: e.blob, mode: e.mode, size: e.size }
620
+ if (e) b.files[f.path] = { blob: e.mode === 'conflict' ? null : e.blob, mode: e.mode, size: e.size, ...(e.term ? { term: e.term } : {}) }
192
621
  else delete b.files[f.path]
193
- if (entryOf(this.doc, f.path) || local.has(f.path)) continue
194
- this.write(f.path, e ? { bytes: await this.bytesOf(e.blob), mode: e.mode } : null)
622
+ if (entryOf(this.doc, f.path) || local.has(f.path) || this.skipped(f.path) || (this.inPlace && this.nested(f.path))) continue
623
+ // Already on disk as the new head has it (the commit was made from this very work): left alone.
624
+ const k = this.known[f.path]
625
+ if (e ? k && e.mode !== 'conflict' && k.blob === e.blob && k.mode === e.mode : !k) continue
626
+ this.write(f.path, e ? await this.headFile(f.path, b.files[f.path], `c:${to}`) : null)
195
627
  }
196
628
  b.at = to
197
- b.head = to
198
- saveBlock(this.dir, b)
629
+ // A square with no commit of its own (a rebase only moved its base): a commit's expect_head is none.
630
+ const rec = this.doc.getMap('live').get('head') || {}
631
+ b.head = rec.commit === to && rec.base ? null : to
632
+ this.save()
199
633
  }
200
634
 
201
635
  /** Every disk change in, and the relay holding all of it. */
@@ -216,10 +650,13 @@ export class LiveBlock {
216
650
  * then the relay moves the head and drops what was committed (live-square.js `seal`).
217
651
  */
218
652
  async commit ({ message = '' } = {}) {
653
+ if (!this.client) throw new Error('sign in to Seam first (quilt login, or set SEAM_TOKEN): a commit is made as you')
219
654
  await this.flush()
220
655
  return this.run(async () => {
221
656
  const head = this.head
222
- const list = overlay(this.doc)
657
+ // An untracked file this folder never syncs (ignored, or a Quilt built-in) is never committed, even if
658
+ // the live document names it (a square from before 0.3.135's switch took agents' scratch files in).
659
+ const list = overlay(this.doc).filter((e) => !(this.skipped(e.path) && !this.block.files[e.path]))
223
660
  if (!list.length) return { nothing: true }
224
661
  const uploads = list.filter((e) => e.bytes).map((e) => ({ kind: 'blob', id: blobId(e.bytes), bytes: e.bytes }))
225
662
  if (uploads.length) await this.client.upload(this.block.quilt, uploads)
@@ -233,17 +670,52 @@ export class LiveBlock {
233
670
  })
234
671
  }
235
672
 
673
+ /**
674
+ * The square's head moved on Seam itself (a rebase, a settled conflict, a merge that kept its
675
+ * conflict in the square): the relay moves the live document onto it, each live file the move
676
+ * changed combined with it (source-live/bring-in.js), and the folder is re-projected onto it.
677
+ * `base`: the square has no commit of its own, and `head` is its new base.
678
+ */
679
+ async moveTo (head, { base = false } = {}) {
680
+ await this.flush()
681
+ return this.run(async () => {
682
+ const was = this.head
683
+ if (!head || head === was) return { moved: false }
684
+ await this.conn.branchRequest({ op: 'live-move', branch: this.block.live, prev: was, head, ...(base ? { base: true } : {}) })
685
+ await waitUntil(() => this.head === head, 10000, 'the moved head')
686
+ await this.reproject(head)
687
+ return { moved: true, head }
688
+ })
689
+ }
690
+
236
691
  /** Merges the square into a line (07), after sealing anything uncommitted. */
237
692
  async merge ({ into = 'main', message } = {}) {
238
693
  await this.commit({ message: message || 'Work in progress' })
239
694
  return this.client.merge(this.block.quilt, into, { square: this.block.square, expect_head: this.block.head, ...(message ? { message } : {}) })
240
695
  }
241
696
 
697
+ /** Stops the block without saving its record: it never opened (open failed part-way). */
698
+ abandon () {
699
+ this.closed = true
700
+ if (this.stopOpening) this.stopOpening()
701
+ clearTimeout(this.scanTimer)
702
+ clearTimeout(this.saveTimer)
703
+ clearInterval(this.reconcileTimer)
704
+ clearInterval(this.stepTimer)
705
+ for (const t of this.settleTimers || []) clearTimeout(t)
706
+ if (this.watcher) this.watcher.close()
707
+ if (this.conn) this.conn.close()
708
+ }
709
+
242
710
  async close () {
243
711
  this.closed = true
244
712
  clearTimeout(this.scanTimer)
713
+ clearInterval(this.reconcileTimer)
714
+ clearInterval(this.stepTimer)
715
+ for (const t of this.settleTimers || []) clearTimeout(t)
245
716
  if (this.watcher) this.watcher.close()
246
717
  await this.queue.catch(() => {})
718
+ if (this.record && this.block && this.block.at) { try { this.save() } catch {} }
247
719
  if (this.conn) this.conn.close()
248
720
  }
249
721
  }