heyquilt 0.0.1 → 0.3.90

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 (204) hide show
  1. package/LICENSE +85 -0
  2. package/README.md +630 -2
  3. package/RELEASES.md +733 -0
  4. package/assets/logo.svg +1 -0
  5. package/bin/quilt.js +816 -0
  6. package/desktop/main.js +315 -0
  7. package/package.json +163 -7
  8. package/src/account.js +563 -0
  9. package/src/admit-policy.js +32 -0
  10. package/src/agent-join.js +370 -0
  11. package/src/agent-task-workflow.js +176 -0
  12. package/src/agents/actions.js +85 -0
  13. package/src/agents/claude-code.js +180 -0
  14. package/src/agents/common.js +21 -0
  15. package/src/agents/cursor-transcripts.js +170 -0
  16. package/src/agents/cursor.js +404 -0
  17. package/src/agents/index.js +69 -0
  18. package/src/ai-vm-signin.js +98 -0
  19. package/src/ai-vm.js +380 -0
  20. package/src/api/access.js +75 -0
  21. package/src/api/activity.js +181 -0
  22. package/src/api/agent-auth.js +130 -0
  23. package/src/api/agent-placement.js +157 -0
  24. package/src/api/audit.js +83 -0
  25. package/src/api/auth.js +17 -0
  26. package/src/api/domains.js +30 -0
  27. package/src/api/file-paths.js +40 -0
  28. package/src/api/file-store.js +99 -0
  29. package/src/api/fly-machines.js +76 -0
  30. package/src/api/github-app.js +172 -0
  31. package/src/api/http.js +34 -0
  32. package/src/api/invite-email.js +53 -0
  33. package/src/api/issues.js +110 -0
  34. package/src/api/join-text.js +78 -0
  35. package/src/api/mailer.js +47 -0
  36. package/src/api/memory-store.js +1083 -0
  37. package/src/api/org-access.js +54 -0
  38. package/src/api/permissions.js +71 -0
  39. package/src/api/relay-cluster.js +251 -0
  40. package/src/api/relay-machines.js +60 -0
  41. package/src/api/routes/access-types.js +54 -0
  42. package/src/api/routes/agent-invites.js +115 -0
  43. package/src/api/routes/agents.js +157 -0
  44. package/src/api/routes/ai-vm.js +369 -0
  45. package/src/api/routes/github.js +380 -0
  46. package/src/api/routes/grants.js +70 -0
  47. package/src/api/routes/invites.js +188 -0
  48. package/src/api/routes/issues.js +47 -0
  49. package/src/api/routes/join.js +167 -0
  50. package/src/api/routes/members.js +110 -0
  51. package/src/api/routes/orgs.js +177 -0
  52. package/src/api/routes/relay-cluster.js +90 -0
  53. package/src/api/routes/relay.js +76 -0
  54. package/src/api/routes/session-invites.js +119 -0
  55. package/src/api/routes/sessions.js +92 -0
  56. package/src/api/routes/teams.js +132 -0
  57. package/src/api/routes/workspace-agents.js +332 -0
  58. package/src/api/routes/workspace-files.js +277 -0
  59. package/src/api/routes/workspace-invites.js +196 -0
  60. package/src/api/routes/workspaces.js +225 -0
  61. package/src/api/server.js +623 -0
  62. package/src/api/slugs.js +20 -0
  63. package/src/api/supabase-store.js +765 -0
  64. package/src/api/team-access.js +30 -0
  65. package/src/api/tokens.js +31 -0
  66. package/src/api/workspace-access.js +62 -0
  67. package/src/api/workspace-reach.js +60 -0
  68. package/src/autowork-tools.js +166 -0
  69. package/src/autowork.js +387 -0
  70. package/src/blobstore.js +99 -0
  71. package/src/boxes.js +37 -0
  72. package/src/branchdocs.js +235 -0
  73. package/src/branches.js +194 -0
  74. package/src/catchup.js +111 -0
  75. package/src/chat-archive.js +85 -0
  76. package/src/chat-links.js +923 -0
  77. package/src/clash.js +140 -0
  78. package/src/cloud-ai.js +50 -0
  79. package/src/commit-request.js +59 -0
  80. package/src/commit.js +213 -0
  81. package/src/connection.js +569 -0
  82. package/src/control.js +245 -0
  83. package/src/conversation.js +125 -0
  84. package/src/doctor.js +156 -0
  85. package/src/duties.js +303 -0
  86. package/src/editors.js +228 -0
  87. package/src/fsutil.js +87 -0
  88. package/src/git-hook.js +48 -0
  89. package/src/git.js +147 -0
  90. package/src/github-commit.js +320 -0
  91. package/src/gitignore.js +49 -0
  92. package/src/gitstate.js +900 -0
  93. package/src/history.js +307 -0
  94. package/src/hooks.js +371 -0
  95. package/src/hosted-agents.js +24 -0
  96. package/src/hosted-client.js +112 -0
  97. package/src/identity.js +99 -0
  98. package/src/inbox.js +237 -0
  99. package/src/integrations.js +221 -0
  100. package/src/key-cli.js +115 -0
  101. package/src/largefiles.js +91 -0
  102. package/src/legacy.js +25 -0
  103. package/src/mcp-proxy.js +40 -0
  104. package/src/mcp-room.js +32 -0
  105. package/src/mcp.js +1261 -0
  106. package/src/merge3.js +158 -0
  107. package/src/merges.js +148 -0
  108. package/src/pass-source.js +176 -0
  109. package/src/passes.js +64 -0
  110. package/src/pathrules.js +127 -0
  111. package/src/persona.js +101 -0
  112. package/src/presence.js +360 -0
  113. package/src/private-file.js +41 -0
  114. package/src/procs.js +70 -0
  115. package/src/project-rules.js +178 -0
  116. package/src/protocol.js +92 -0
  117. package/src/quiet-warnings.js +9 -0
  118. package/src/relay-ai-vm.js +82 -0
  119. package/src/relay-ask.js +141 -0
  120. package/src/relay-ci.js +111 -0
  121. package/src/relay-clash.js +597 -0
  122. package/src/relay-cluster.js +454 -0
  123. package/src/relay-commit.js +887 -0
  124. package/src/relay-filekey.js +46 -0
  125. package/src/relay-git.js +274 -0
  126. package/src/relay-githttp.js +137 -0
  127. package/src/relay-github.js +109 -0
  128. package/src/relay-lease.js +137 -0
  129. package/src/relay-lfs.js +37 -0
  130. package/src/relay-mcp.js +1679 -0
  131. package/src/relay-metrics.js +75 -0
  132. package/src/relay-outbound.js +1144 -0
  133. package/src/relay-sync.js +719 -0
  134. package/src/relay-turn-sync.js +307 -0
  135. package/src/relay-upstream.js +934 -0
  136. package/src/relay-webhooks.js +111 -0
  137. package/src/releases.js +131 -0
  138. package/src/report.js +129 -0
  139. package/src/runner.js +306 -0
  140. package/src/same-edit.js +18 -0
  141. package/src/server.js +4187 -0
  142. package/src/session-access.js +170 -0
  143. package/src/session-name.js +15 -0
  144. package/src/session.js +7361 -0
  145. package/src/settings.js +46 -0
  146. package/src/setup.js +184 -0
  147. package/src/shell-path.js +46 -0
  148. package/src/status.js +199 -0
  149. package/src/summarize.js +109 -0
  150. package/src/sync-words.js +42 -0
  151. package/src/task-comments.js +92 -0
  152. package/src/tasks.js +670 -0
  153. package/src/textdiff.js +20 -0
  154. package/src/turn-sync-plan.js +142 -0
  155. package/src/turn-sync.js +191 -0
  156. package/src/ui/access-form.js +37 -0
  157. package/src/ui/agent-env-key.js +76 -0
  158. package/src/ui/agent-guide.js +127 -0
  159. package/src/ui/agent-kinds.js +175 -0
  160. package/src/ui/agent-place.js +265 -0
  161. package/src/ui/app.css +2309 -0
  162. package/src/ui/app.js +542 -0
  163. package/src/ui/board.js +465 -0
  164. package/src/ui/boxes.js +76 -0
  165. package/src/ui/branches.js +111 -0
  166. package/src/ui/catchup.js +77 -0
  167. package/src/ui/changes.js +159 -0
  168. package/src/ui/chat.js +266 -0
  169. package/src/ui/cloud-ai-model.js +107 -0
  170. package/src/ui/cloud-ai.js +221 -0
  171. package/src/ui/common.js +431 -0
  172. package/src/ui/csv.js +21 -0
  173. package/src/ui/diffview.js +74 -0
  174. package/src/ui/feed-convs.js +39 -0
  175. package/src/ui/feed.js +174 -0
  176. package/src/ui/files.js +579 -0
  177. package/src/ui/fileview.js +123 -0
  178. package/src/ui/git-rule.js +60 -0
  179. package/src/ui/home.js +1323 -0
  180. package/src/ui/index.html +16 -0
  181. package/src/ui/invite.js +97 -0
  182. package/src/ui/invites.js +79 -0
  183. package/src/ui/loom-model.js +516 -0
  184. package/src/ui/loom.css +349 -0
  185. package/src/ui/loom.js +753 -0
  186. package/src/ui/mark.js +104 -0
  187. package/src/ui/merges.js +158 -0
  188. package/src/ui/rail-model.js +78 -0
  189. package/src/ui/rail.js +173 -0
  190. package/src/ui/releases.js +182 -0
  191. package/src/ui/schedule.js +156 -0
  192. package/src/ui/session.js +3394 -0
  193. package/src/ui/signin.js +75 -0
  194. package/src/ui/sounds.js +110 -0
  195. package/src/ui/tool-logo.js +86 -0
  196. package/src/ui/tree.js +276 -0
  197. package/src/ui/workspaces.js +542 -0
  198. package/src/ui-server.js +1272 -0
  199. package/src/update-check.js +67 -0
  200. package/src/upstream.js +155 -0
  201. package/src/webhooks.js +235 -0
  202. package/src/workspace-cli.js +62 -0
  203. package/src/workspace-tools.js +340 -0
  204. package/index.js +0 -2
@@ -0,0 +1,307 @@
1
+ // The relay's side of `quilt sync` (turn-sync.js): a sandboxed agent's folder synced in one shot,
2
+ // over the hosted MCP (the quilt_sync tool, relay-mcp.js), with the sync state kept here rather
3
+ // than in the folder. Each of the agent's sandboxes (a slot, from its QUILT_AGENT_KEY) has a
4
+ // checkpoint per branch: what its folder held at the end of its last sync, texts included, so the
5
+ // next sync can tell who changed what and merge. The rules are turn-sync-plan.js.
6
+ //
7
+ // Steps, one request each: plan (the folder's manifest in; what to upload, pull, remove and keep
8
+ // aside out), push (contents in; written, deleted or a merge record, decided again against the
9
+ // session as it is now), pull (the session's version of some paths), done (the folder's manifest
10
+ // after writing; the checkpoint saved).
11
+ import fs from 'node:fs'
12
+ import path from 'node:path'
13
+ import zlib from 'node:zlib'
14
+ import crypto from 'node:crypto'
15
+ import * as Y from 'yjs'
16
+ import { applyTextDiff } from './textdiff.js'
17
+ import { isSafeRelPath, LARGE_FILE_BYTES } from './pathrules.js'
18
+ import { openMerge, MAX_RECORD_TEXT } from './merges.js'
19
+ import { decide, planSync, nextCheckpoint, mergeTexts, behind, sessionEntry, SKIP } from './turn-sync-plan.js'
20
+
21
+ export const SLOT = /^[A-Za-z0-9_-]{8,64}$/
22
+ const HASH = /^[0-9a-f]{40}$/
23
+ /** A text pushed by quilt sync: at most this long (as quilt_write_file). */
24
+ export const MAX_SYNC_TEXT = 1024 * 1024
25
+ /** One pull answer carries at most this much file content; the rest is asked for again. */
26
+ export const MAX_PULL_BYTES = 8 * 1024 * 1024
27
+ /** Checkpoints unused this long are forgotten. */
28
+ const KEEP_MS = 90 * 24 * 60 * 60 * 1000
29
+ export const ROOM_KEY = '∅room' // a room without branch documents
30
+
31
+ const sha1 = (x) => crypto.createHash('sha1').update(x).digest('hex')
32
+
33
+ /**
34
+ * Checkpoints, per room, agent and slot: { [branch]: { epoch, at, files } }. On disk under
35
+ * <dataDir>/turn-sync/<room>/ (gzipped JSON, one file per agent and slot), in memory without one.
36
+ */
37
+ export class CheckpointStore {
38
+ constructor ({ dir = null } = {}) {
39
+ this.dir = dir
40
+ this.mem = new Map()
41
+ }
42
+
43
+ file (account, slot) { return path.join(this.dir, `${sha1(`${account}\0${slot}`)}.json.gz`) }
44
+
45
+ get (account, slot) {
46
+ const k = `${account}\0${slot}`
47
+ if (this.mem.has(k)) return this.mem.get(k)
48
+ let v = {}
49
+ if (this.dir) {
50
+ try { v = JSON.parse(zlib.gunzipSync(fs.readFileSync(this.file(account, slot))).toString('utf8')) || {} } catch {}
51
+ }
52
+ this.mem.set(k, v)
53
+ return v
54
+ }
55
+
56
+ put (account, slot, value) {
57
+ this.mem.set(`${account}\0${slot}`, value)
58
+ if (!this.dir) return
59
+ fs.mkdirSync(this.dir, { recursive: true })
60
+ const f = this.file(account, slot)
61
+ fs.writeFileSync(`${f}.tmp`, zlib.gzipSync(JSON.stringify(value)))
62
+ fs.renameSync(`${f}.tmp`, f)
63
+ this.prune()
64
+ }
65
+
66
+ /** Forgets checkpoints nobody synced against for KEEP_MS. */
67
+ prune (now = Date.now()) {
68
+ if (!this.dir) return
69
+ let names = []
70
+ try { names = fs.readdirSync(this.dir) } catch { return }
71
+ for (const n of names) {
72
+ const f = path.join(this.dir, n)
73
+ try { if (now - fs.statSync(f).mtimeMs > KEEP_MS) fs.rmSync(f, { force: true }) } catch {}
74
+ }
75
+ }
76
+ }
77
+
78
+ /** The room's checkpoint store, made the first time it is needed. */
79
+ export function checkpointsOf (room) {
80
+ if (!room.turnSync) room.turnSync = new CheckpointStore({ dir: room.dataDirOf ? path.join(room.dataDirOf, 'turn-sync', room.name) : null })
81
+ return room.turnSync
82
+ }
83
+
84
+ /** Removes a room's checkpoints (the room's data is being removed). */
85
+ export function dropCheckpoints (dataDir, room) {
86
+ if (dataDir) fs.rmSync(path.join(dataDir, 'turn-sync', room), { recursive: true, force: true })
87
+ }
88
+
89
+ /** The session's files on a branch: path -> { h, text } (text files and small binaries) or { h, stored }. */
90
+ export function sessionState (files, blobs) {
91
+ const out = new Map()
92
+ for (const [rel, t] of files) {
93
+ if (!isSafeRelPath(rel) || !t) continue
94
+ const text = t.toString()
95
+ out.set(rel, sessionEntry(sha1(text), { text }))
96
+ }
97
+ for (const [rel, b] of blobs) {
98
+ if (!isSafeRelPath(rel) || out.has(rel) || !b || !HASH.test(String(b.hash || ''))) continue
99
+ out.set(rel, b.stored ? { h: b.hash, stored: true } : sessionEntry(b.hash))
100
+ }
101
+ return out
102
+ }
103
+
104
+ /** The folder's manifest from a request: { path: sha1 | 'skip' }, safe paths only. */
105
+ export function cleanManifest (files) {
106
+ const out = {}
107
+ for (const [rel, h] of Object.entries(files || {})) {
108
+ if (!isSafeRelPath(rel)) continue
109
+ if (h === SKIP || HASH.test(String(h))) out[rel] = h
110
+ }
111
+ return out
112
+ }
113
+
114
+ /** A file's content sent in a push: { buf, text, hash } or { gone: true }; null when malformed or too big. */
115
+ function content (v) {
116
+ if (!v || typeof v !== 'object') return null
117
+ if (v.deleted === true) return { gone: true }
118
+ if (typeof v.text === 'string') {
119
+ const buf = Buffer.from(v.text, 'utf8')
120
+ if (buf.length > MAX_SYNC_TEXT) return null
121
+ return { text: v.text, buf, hash: sha1(buf) }
122
+ }
123
+ if (typeof v.base64 === 'string') {
124
+ const buf = Buffer.from(v.base64, 'base64')
125
+ if (buf.length >= LARGE_FILE_BYTES) return null
126
+ return { buf, hash: sha1(buf) }
127
+ }
128
+ return null
129
+ }
130
+
131
+ /**
132
+ * One step of a sync. `args`: { step, slot, files?, paths? }. `ctx`: { room, fdoc, files, blobs,
133
+ * branchKey, epoch, account, me, origin, globalRefusal(), pathRefusal(rel) -> { kind: 'aside' |
134
+ * 'claimed', why, by? } | null, record({ rel, kind, before, after, detail }), lastEditor(rel), ghosts? }.
135
+ * `ghosts`: the paths someone else has boxed (docs/superpowers/specs/2026-10-16-git-boxes-design.md): their
136
+ * unfinished work never reaches this folder. The folder sees each as it last had it (its checkpoint), or as it
137
+ * has it now; an edit to one is refused like one to a held file, and the folder's version comes back.
138
+ * Returns the answer (a plain object, sent as JSON).
139
+ */
140
+ export function turnSyncStep (args, ctx) {
141
+ const slot = String(args.slot || '')
142
+ if (!SLOT.test(slot)) throw new Error('slot must be 8 to 64 letters, numbers, dashes or underscores (from your QUILT_AGENT_KEY)')
143
+ const store = checkpointsOf(ctx.room)
144
+ const all = store.get(ctx.account, slot)
145
+ const saved = all[ctx.branchKey]
146
+ // A checkpoint of an older document of this branch (removed and started again) says nothing about this one.
147
+ const checkpoint = saved && (saved.epoch || null) === (ctx.epoch || null) ? saved : null
148
+ const ghosts = ctx.ghosts instanceof Set ? ctx.ghosts : new Set()
149
+ const deleted = (rel) => !ghosts.has(rel) && !ctx.files.has(rel) && !ctx.blobs.has(rel) && (ctx.files._map.has(rel) || ctx.blobs._map.has(rel))
150
+ const cpFiles = (checkpoint && checkpoint.files) || {}
151
+ // The session as this folder may see it: someone else's box shows as the folder's own version.
152
+ const session = (local = null) => {
153
+ const out = sessionState(ctx.files, ctx.blobs)
154
+ for (const rel of ghosts) {
155
+ const cp = cpFiles[rel]
156
+ if (cp && HASH.test(String(cp.h || ''))) out.set(rel, sessionEntry(cp.h, { text: typeof cp.t === 'string' ? cp.t : null }))
157
+ else if (local && local[rel] && local[rel] !== SKIP) out.set(rel, sessionEntry(local[rel]))
158
+ else out.delete(rel)
159
+ }
160
+ return out
161
+ }
162
+ const step = args.step
163
+
164
+ if (step === 'plan') {
165
+ const local = cleanManifest(args.files)
166
+ const p = planSync({ local, session: session(local), checkpoint, deleted })
167
+ const refused = p.upload.length || p.delete.length ? ctx.globalRefusal() : null
168
+ return { step, ...p, ...(refused ? { refused } : {}) }
169
+ }
170
+
171
+ if (step === 'push') {
172
+ const out = { step, pushed: [], deleted: [], merged: [], merges: [], pull: [], remove: [], aside: [], kept: [], skipped: [] }
173
+ const refusedAll = ctx.globalRefusal()
174
+ const first = !checkpoint
175
+ const base = (checkpoint && checkpoint.files) || {}
176
+ // Each path is only changed once, after it was looked at: one look at the session is enough.
177
+ const state = session()
178
+ for (const [rel, v] of Object.entries(args.files || {})) {
179
+ if (!isSafeRelPath(rel)) continue
180
+ const c = content(v)
181
+ if (!c) { out.skipped.push(rel); continue }
182
+ // Someone else's box this folder never synced: left as it is here, and not taken into the session.
183
+ if (ghosts.has(rel) && !cpFiles[rel]) { const why = ctx.pathRefusal(rel); out.kept.push({ path: rel, why: why ? why.why : `${rel} is someone else's unfinished work` }); continue }
184
+ const now = state.get(rel) || null
185
+ const d = decide({ local: c.gone ? null : c.hash, session: now, base: base[rel] || null, first, deleted: deleted(rel) })
186
+ if (d === 'same') continue
187
+ if (d === 'skip') { out.skipped.push(rel); continue }
188
+ if (d === 'pull' || d === 'remove') { out[d].push(rel); continue }
189
+ if (d === 'aside-pull' || d === 'aside-remove') { out.aside.push(rel); out[d.slice(6)].push(rel); continue }
190
+ // What follows changes the session: refused as a whole (an unanswered message, a full session), or for this file.
191
+ if (refusedAll) { out.kept.push({ path: rel, why: refusedAll }); continue }
192
+ const why = ctx.pathRefusal(rel)
193
+ if (why && why.kind === 'keep') { out.kept.push({ path: rel, why: why.why }); continue }
194
+ if (why || d === 'merge') {
195
+ settle(rel, c, now, base[rel] || null, why, ctx, out)
196
+ continue
197
+ }
198
+ if (d === 'push') { write(rel, c, ctx); out.pushed.push(rel) } else if (d === 'delete') { remove(rel, ctx); out.deleted.push(rel) }
199
+ }
200
+ return out
201
+ }
202
+
203
+ if (step === 'pull') {
204
+ const now = session()
205
+ const files = {}
206
+ const more = []
207
+ let bytes = 0
208
+ for (const rel of Array.isArray(args.paths) ? args.paths : []) {
209
+ if (typeof rel !== 'string' || !isSafeRelPath(rel)) continue
210
+ const s = now.get(rel)
211
+ if (bytes >= MAX_PULL_BYTES) { more.push(rel); continue }
212
+ // Someone else's box: the folder's own last version when it has the text, else nothing (the folder keeps what it has).
213
+ if (ghosts.has(rel)) { if (s && typeof s.text === 'string') { files[rel] = { text: s.text, h: s.h }; bytes += s.text.length } continue }
214
+ if (!s) { files[rel] = { gone: true }; continue }
215
+ if (s.stored) { files[rel] = { stored: true }; continue }
216
+ if (typeof s.text === 'string') { files[rel] = { text: s.text, h: s.h }; bytes += s.text.length; continue }
217
+ const b = ctx.blobs.get(rel)
218
+ files[rel] = { base64: b.data || '', h: s.h }
219
+ bytes += (b.data || '').length
220
+ }
221
+ return { step, files, more }
222
+ }
223
+
224
+ if (step === 'done') {
225
+ const local = cleanManifest(args.files)
226
+ const now = session(local)
227
+ const next = nextCheckpoint(checkpoint, local, now)
228
+ store.put(ctx.account, slot, { ...all, [ctx.branchKey]: { epoch: ctx.epoch || null, ...next } })
229
+ return { step, saved: Object.keys(next.files).length, behind: behind(local, now).slice(0, 200) }
230
+ }
231
+
232
+ throw new Error('step must be plan, push, pull or done')
233
+ }
234
+
235
+ /** Writes the folder's version of rel into the session, as the agent. */
236
+ function write (rel, c, ctx) {
237
+ ctx.fdoc.transact(() => {
238
+ const existed = ctx.files.has(rel) || ctx.blobs.has(rel)
239
+ const before = ctx.files.get(rel)?.toString() ?? ''
240
+ if (typeof c.text === 'string') {
241
+ ctx.blobs.delete(rel)
242
+ let t = ctx.files.get(rel)
243
+ if (!t) { t = new Y.Text(); ctx.files.set(rel, t) }
244
+ const detail = applyTextDiff(t, c.text)
245
+ ctx.record({ rel, kind: existed ? 'edited' : 'created', before, after: c.text, detail })
246
+ } else {
247
+ ctx.files.delete(rel)
248
+ ctx.blobs.set(rel, { hash: c.hash, data: c.buf.toString('base64') })
249
+ ctx.record({ rel, kind: existed ? 'edited' : 'created', detail: `${c.buf.length} bytes` })
250
+ }
251
+ }, ctx.origin)
252
+ }
253
+
254
+ /** Deletes rel from the session, as the agent. */
255
+ function remove (rel, ctx) {
256
+ ctx.fdoc.transact(() => {
257
+ const before = ctx.files.get(rel)?.toString()
258
+ ctx.files.delete(rel)
259
+ ctx.blobs.delete(rel)
260
+ ctx.record({ rel, kind: 'deleted', ...(typeof before === 'string' ? { before, after: '' } : {}), detail: '' })
261
+ }, ctx.origin)
262
+ }
263
+
264
+ /**
265
+ * Both changed rel (or it isn't ours to change): merged when the lines don't overlap and nothing
266
+ * stands in the way; otherwise a merge record (as Session.openConflict: the session's version
267
+ * stays, ours is in the record) and the folder takes the session's version with ours kept aside.
268
+ */
269
+ function settle (rel, c, now, base, why, ctx, out) {
270
+ const theirs = now && typeof now.text === 'string' ? now.text : null
271
+ if (!why && typeof c.text === 'string' && theirs !== null && base && typeof base.t === 'string') {
272
+ const merged = mergeTexts(base.t, c.text, theirs)
273
+ if (merged !== null) {
274
+ write(rel, { text: merged }, ctx)
275
+ out.merged.push(rel)
276
+ if (sha1(Buffer.from(merged, 'utf8')) !== c.hash) out.pull.push(rel)
277
+ return
278
+ }
279
+ }
280
+ if (why && why.kind === 'aside') {
281
+ // Not ours to change (a viewer, a folder outside our scope): the session's version comes back.
282
+ out.aside.push(rel)
283
+ out[now ? 'pull' : 'remove'].push(rel)
284
+ out.kept.push({ path: rel, why: why.why })
285
+ return
286
+ }
287
+ const binary = typeof c.text !== 'string' && !c.gone
288
+ const textOf = (t) => (typeof t === 'string' && t.length <= MAX_RECORD_TEXT ? t : null)
289
+ const theirsKey = now ? (typeof now.text === 'string' ? now.text : `bin:${now.h}`) : null
290
+ const by = ctx.lastEditor ? ctx.lastEditor(rel) : null
291
+ const rec = openMerge(ctx.fdoc, ctx.fdoc.getMap('merges'), {
292
+ path: rel,
293
+ by: ctx.me,
294
+ byId: ctx.account,
295
+ others: by && by !== ctx.me ? [by] : [],
296
+ kind: why && why.kind === 'claimed' ? 'claimed' : 'conflict',
297
+ ours: binary || c.gone ? null : textOf(c.text),
298
+ oursDeleted: !!c.gone,
299
+ base: binary ? null : textOf(base && base.t),
300
+ theirsHash: theirsKey === null ? null : sha1(theirsKey),
301
+ binary,
302
+ claimedBy: why && why.kind === 'claimed' ? why.by || null : null
303
+ }, ctx.origin)
304
+ out.merges.push({ path: rel, id: rec.id, kind: rec.kind })
305
+ if (!c.gone) out.aside.push(rel)
306
+ out[now ? 'pull' : 'remove'].push(rel)
307
+ }