heyquilt 0.3.90 → 0.3.106

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 (84) hide show
  1. package/README.md +14 -11
  2. package/RELEASES.md +131 -2
  3. package/bin/quilt.js +45 -12
  4. package/package.json +3 -3
  5. package/src/account.js +28 -0
  6. package/src/agents/claude-code.js +11 -4
  7. package/src/agents/cursor-transcripts.js +11 -4
  8. package/src/ai-vm-signin.js +14 -2
  9. package/src/ai-vm.js +150 -8
  10. package/src/api/agent-auth.js +9 -3
  11. package/src/api/fly-machines.js +72 -15
  12. package/src/api/memory-store.js +141 -5
  13. package/src/api/relay-cluster.js +337 -43
  14. package/src/api/relay-machines.js +35 -3
  15. package/src/api/releases.js +87 -0
  16. package/src/api/routes/agents.js +111 -16
  17. package/src/api/routes/ai-vm.js +143 -17
  18. package/src/api/routes/orgs.js +3 -1
  19. package/src/api/routes/secrets.js +448 -0
  20. package/src/api/routes/workspaces.js +8 -1
  21. package/src/api/sandbox-keys.js +57 -0
  22. package/src/api/secrets-access.js +93 -0
  23. package/src/api/server.js +40 -9
  24. package/src/api/supabase-store.js +120 -10
  25. package/src/autowork.js +3 -15
  26. package/src/catchup.js +1 -1
  27. package/src/clash.js +10 -22
  28. package/src/control.js +18 -15
  29. package/src/fsutil.js +11 -1
  30. package/src/gitstate.js +29 -5
  31. package/src/hooks.js +1 -1
  32. package/src/mcp-proxy.js +7 -2
  33. package/src/mcp.js +22 -25
  34. package/src/merge3.js +94 -12
  35. package/src/pass-source.js +9 -2
  36. package/src/pathrules.js +7 -0
  37. package/src/relay-clash.js +101 -484
  38. package/src/relay-githttp.js +36 -7
  39. package/src/relay-mcp.js +19 -7
  40. package/src/relay-metrics.js +66 -1
  41. package/src/relay-outbound.js +154 -25
  42. package/src/relay-sync.js +133 -136
  43. package/src/relay-turn-sync.js +250 -51
  44. package/src/relay-upstream.js +49 -179
  45. package/src/releases.js +16 -9
  46. package/src/runner.js +3 -2
  47. package/src/secret-import.js +55 -0
  48. package/src/secret-names.js +26 -0
  49. package/src/secret-run.js +80 -0
  50. package/src/secrets-client.js +56 -0
  51. package/src/server.js +71 -44
  52. package/src/session.js +347 -262
  53. package/src/status.js +3 -5
  54. package/src/sync-words.js +1 -1
  55. package/src/turn-sync-plan.js +13 -10
  56. package/src/turn-sync.js +32 -11
  57. package/src/ui/agent-guide.js +4 -4
  58. package/src/ui/app.css +208 -23
  59. package/src/ui/app.js +4 -0
  60. package/src/ui/cloud-ai-model.js +3 -2
  61. package/src/ui/common.js +2 -0
  62. package/src/ui/fileview.js +28 -1
  63. package/src/ui/home.js +32 -33
  64. package/src/ui/loom.css +18 -2
  65. package/src/ui/loom.js +41 -8
  66. package/src/ui/rail-model.js +6 -9
  67. package/src/ui/releases.js +1 -1
  68. package/src/ui/sandbox-keys-model.js +53 -0
  69. package/src/ui/sandbox-keys.js +132 -0
  70. package/src/ui/secret-rules.js +255 -0
  71. package/src/ui/secrets-view.js +162 -0
  72. package/src/ui/secrets.js +283 -0
  73. package/src/ui/session.js +47 -149
  74. package/src/ui/tree.js +23 -18
  75. package/src/ui/workspaces.js +5 -0
  76. package/src/ui-server.js +136 -27
  77. package/src/update-check.js +1 -1
  78. package/src/upstream.js +75 -38
  79. package/src/vault/crypto.js +96 -0
  80. package/src/vault/kms.js +61 -0
  81. package/src/vault/main.js +33 -0
  82. package/src/vault/server.js +279 -0
  83. package/src/vault/sigv4.js +38 -0
  84. package/src/ui/merges.js +0 -158
package/src/session.js CHANGED
@@ -6,7 +6,7 @@ import fs from 'node:fs'
6
6
  import path from 'node:path'
7
7
  import crypto from 'node:crypto'
8
8
  import { fileURLToPath } from 'node:url'
9
- import { execFile } from 'node:child_process'
9
+ import { execFile, execFileSync } from 'node:child_process'
10
10
  import { EventEmitter } from 'node:events'
11
11
  import * as Y from 'yjs'
12
12
  import { watch } from 'chokidar'
@@ -17,7 +17,7 @@ import { MAX_SHARED_FILE_BYTES, FEATURES } from './protocol.js'
17
17
  import { formatBytes, pullAdvice } from './status.js'
18
18
  import {
19
19
  loadIgnore, IGNORE_FILES, isIgnored, isSafeRelPath, resolveInside, looksBinary, sha1, walk,
20
- toPosix, globMatcher, MAX_TEXT_BYTES, MAX_BINARY_BYTES, LARGE_FILE_BYTES, MAX_STORED_BINARY_BYTES
20
+ toPosix, globMatcher, MAX_TEXT_BYTES, MAX_BINARY_BYTES, LARGE_FILE_BYTES, MAX_STORED_BINARY_BYTES, CHAT_FILES, closeWatcher
21
21
  } from './fsutil.js'
22
22
  import { wrapForRelay, relayKeyId, deriveWrapKey, newFileKey, wrapKey, unwrapKey, encryptBlob, decryptBlob, blobId } from './largefiles.js'
23
23
  import { applyTextDiff } from './textdiff.js'
@@ -41,11 +41,11 @@ import { cleanCommitFiles, ownerName, commitRequestMessage, MAX_COMMIT_DESCRIPTI
41
41
  import { cleanFiles, uncommitted, lastCommitted, commitFiles, commitMessage, editorsSince, uncommittedByPerson, requestWarning, REQUEST_SETTLE_MS, MAX_REQUEST_FILES, MAX_MESSAGE } from './commit.js'
42
42
  import { changeRefusal, TALK_REFUSED } from './session-access.js'
43
43
  import { canAdmit } from './admit-policy.js'
44
- import { merge3, withMarkers, hasMarkers } from './merge3.js'
44
+ import { merge3, mergeAuto, hasMarkers } from './merge3.js'
45
45
  import { sameEditClient, asClient } from './same-edit.js'
46
46
  import { openMerge, updateMerge, readMerges, pruneMerges, cleanName } from './merges.js'
47
47
  import { ensureQuiltIgnored } from './gitignore.js'
48
- import { urlOrigin, gitDir, headKey, headRef, gitRuns, askTwice, lastCallTimedOut, busy as gitBusy, leftoverLock, STALE_LOCK_MS, indexStamp, classify, filesAt, changesBetween, commitsBetween, treeState, branchTip, watchGit, unmergedPaths, stashStamp, upstreamAdds, pullState, SETTLE_MS, BURST_PATHS, upstreamOf, fetchUpstream, isAncestor, stagedAgainst, fastForward, resetIndex, blobAt, hasFilesUnder, repoBranches, remoteHost, backWithGit, installGitHelpers, setPersonGit, hasCommit, pushLocal, bundleOf, adoptPublished } from './gitstate.js'
48
+ import { urlOrigin, gitDir, headKey, headRef, gitRuns, askTwice, lastCallTimedOut, busy as gitBusy, leftoverLock, STALE_LOCK_MS, indexStamp, classify, filesAt, changesBetween, commitsBetween, treeState, branchTip, watchGit, unmergedPaths, stashStamp, upstreamAdds, pullState, SETTLE_MS, BURST_PATHS, upstreamOf, fetchUpstream, isAncestor, stagedAgainst, fastForward, resetIndex, blobAt, hasFilesUnder, repoBranches, remoteHost, backWithGit, installGitHelpers, setPersonGit, hasCommit, pushLocal, bundleOf, adoptPublished, commitTimes } from './gitstate.js'
49
49
  import { planCatchUp, catchUpAdvice, publishedOf, notePublished } from './upstream.js'
50
50
  import { agentPrefix, noreply, slug, gitForEveryoneWords } from './github-commit.js'
51
51
  import { parseRepo, upstreamRef } from './relay-upstream.js'
@@ -76,9 +76,6 @@ const CLASH_SETTLE_MS = 2000 // a clash task this member wrote is told to its AI
76
76
  const CLASH_KEEP_MS = 3 * 24 * 60 * 60 * 1000 // a finished clash record is kept this long
77
77
  const CLASH_RECORDS = 100
78
78
  const KEEP_DONE_COMMITS = 100 // finished commit requests kept, newest (the board shows which task's work was committed)
79
- const STILL_MARKED = 'this file has conflict markers in it; finish editing it (or choose Keep mine) first'
80
- // The note on a claim taken to settle a merge (takeMerge).
81
- const MERGE_CLAIM_NOTE = 'settling the merge of'
82
79
  const COLORS = ['#4f4fa0', '#3b6a9a', '#4a7a45', '#855a9c', '#a8701c', '#2e7a80', '#9c4f6b']
83
80
  const RECENT_MS = 2 * 60 * 1000
84
81
  const AGENT_FEED_CAP = 300
@@ -241,7 +238,6 @@ export class Session extends EventEmitter {
241
238
  // Our boxes: what the session had before the edit that opened each (null: no file). Back to that, the box holds
242
239
  // nothing of ours (our edit undone, or taken back) and opens like a committed one.
243
240
  this.boxFrom = new Map()
244
- this.mergeClaims = new Set() // files taken to settle a merge (takeMerge), let go once it is settled
245
241
  this.queueNoticed = new Map() // claim pattern -> when our AI was last told someone waits for it
246
242
  this.autoClaimQuietMs = autoClaimQuietMs
247
243
  this.handoffGraceMs = handoffGraceMs
@@ -832,14 +828,20 @@ export class Session extends EventEmitter {
832
828
  if (this.kind !== 'agent' || !this.git || !this.git.branch || !up || !up.sha) return false
833
829
  const url = await this.gitOut(['config', '--get', `remote.${up.remote || 'origin'}.url`])
834
830
  if (!url || url !== this.relayGitUrl()) return false
835
- const cherry = await this.gitOut(['cherry', up.sha, at])
831
+ // The commit the relay put the session at, when GitHub has moved past it since (publishLocal says why): the rest comes in as any of GitHub's commits do.
832
+ const rs = this.relaySha()
833
+ const to = !this.memberAt(up.sha) && rs && rs !== at && await isAncestor(this.root, rs, up.sha) ? rs : up.sha
834
+ const cherry = await this.gitOut(['cherry', to, at])
836
835
  if (cherry === null || !cherry || cherry.split('\n').some((l) => !l.startsWith('-'))) return false
837
- const r = await adoptPublished(this.root, this.git.branch, at, up.sha)
836
+ if (!(await this.sessionOnDisk(at, to))) { this.askRelayFor(up.sha); this.checkUpstreamSoon({ delay: 500 }); return false }
837
+ const r = await adoptPublished(this.root, this.git.branch, at, to)
838
838
  if (!r.moved) return false
839
- this.gitSeen = { key: this.git.branch, branch: this.git.branch, sha: up.sha }
839
+ this.gitSeen = { key: this.git.branch, branch: this.git.branch, sha: to }
840
840
  this.gitIndex = indexStamp(this.root)
841
- this.log(`⬆️ the relay put your commits on GitHub's newer ${this.git.branch} (${up.sha.slice(0, 7)}); this folder follows it (yours are kept under refs/quilt/original/${this.git.branch}/)`)
842
- this.setUpstream({ ...up, behind: 0, ahead: 0 })
841
+ this.log(`⬆️ the relay put your commits on GitHub's newer ${this.git.branch} (${to.slice(0, 7)}); this folder follows it (yours are kept under refs/quilt/original/${this.git.branch}/)`)
842
+ const behind = to === up.sha ? 0 : ((await commitsBetween(this.root, to, up.sha)) ?? 1)
843
+ this.setUpstream({ ...up, behind, ahead: 0 })
844
+ if (behind) this.checkUpstreamSoon()
843
845
  return true
844
846
  }
845
847
 
@@ -871,7 +873,7 @@ export class Session extends EventEmitter {
871
873
  const wrong = list.filter((r) => !r.remoteRef.startsWith(`refs/heads/${prefix}`))
872
874
  if (wrong.length) {
873
875
  const name = (r) => r.remoteRef.replace(/^refs\/heads\//, '')
874
- return { ok: false, why: `The session owner lets agents push only to branches of their own here: ${prefix}<topic> (not ${wrong.map(name).join(', ')}). Push to one (git push origin HEAD:${prefix}<topic>) and open a pull request to the session's branch (gh pr create, with GH_TOKEN from \`quilt git-token\`), or ask the owner to allow any branch (quilt_agent_commits any).` }
876
+ return { ok: false, why: `The session owner lets agents push only to branches of their own here: ${prefix}<topic> (not ${wrong.map(name).join(', ')}). Push to one (git push origin HEAD:${prefix}<topic>) and ask a person to open a pull request from it to the session's branch, or ask the owner to allow any branch (quilt_agent_commits any).` }
875
877
  }
876
878
  }
877
879
  // Pushing to GitHub needs credentials this folder gets from the session: when there are none, say why now, instead of git's "could not read Username".
@@ -1632,7 +1634,7 @@ export class Session extends EventEmitter {
1632
1634
  }
1633
1635
  const binary = [base, ours, theirs].some((k) => typeof k === 'string' && k.startsWith('bin:'))
1634
1636
  if (claimedByOther) return this.openConflict({ rel, base, ours, theirs, theirsBy, disk, kind: 'claimed', claimedBy: claim.by, binary, via })
1635
- if (binary || ours === null || theirs === undefined) return this.openConflict({ rel, base, ours, theirs, theirsBy, disk, kind: 'conflict', binary, via })
1637
+ if (binary || ours === null || theirs === undefined) return this.autoSettle({ rel, base, ours, theirs, theirsBy, disk, binary, via })
1636
1638
  const { text, conflicts } = merge3(base || '', ours, theirs)
1637
1639
  // Ours already has the session's change in it (git combined them here: a stash popped over a pull):
1638
1640
  // only one version is new, so it goes in. Nothing is combined.
@@ -1656,9 +1658,111 @@ export class Session extends EventEmitter {
1656
1658
  release()
1657
1659
  return 'merged'
1658
1660
  }
1659
- // Otherwise (offline, a pull) both sides changed it, differently: a person settles it, even when
1660
- // the lines don't overlap (two changes can clash in what they mean). Quilt never combines them itself.
1661
- return this.openConflict({ rel, base, ours, theirs, theirsBy, disk, kind: 'conflict', binary: false, via })
1661
+ // Otherwise (offline, a pull) both sides changed it: Quilt combines them itself.
1662
+ return this.autoSettle({ rel, base, ours, theirs, theirsBy, disk, binary: false, via })
1663
+ }
1664
+
1665
+ /** When the folder's HEAD commit last changed rel, in ms, or 0 (not a git folder, or git can't say). */
1666
+ committedAt (rel) {
1667
+ try {
1668
+ const out = execFileSync('git', ['--no-optional-locks', 'log', '-1', '--format=%ct', 'HEAD', '--', rel], { cwd: this.root, encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] })
1669
+ const s = Number(out.trim())
1670
+ return s > 0 ? s * 1000 + 999 : 0 // whole seconds: the end of its second, so an edit in the same second is older
1671
+ } catch { return 0 }
1672
+ }
1673
+
1674
+ /** When the session last changed rel (its chronology, then the room's activity), or 0. */
1675
+ sessionEditedAt (rel) {
1676
+ const h = this.history.array
1677
+ for (let i = h.length - 1; i >= 0; i--) {
1678
+ const e = h.get(i)
1679
+ if (e && e.path === rel && typeof e.ts === 'number') return e.ts
1680
+ }
1681
+ for (let i = this.activity.length - 1; i >= 0; i--) {
1682
+ const a = this.activity.get(i)
1683
+ if (a && a.path === rel && typeof a.ts === 'number') return a.ts
1684
+ }
1685
+ return 0
1686
+ }
1687
+
1688
+ /**
1689
+ * Both the folder (ours) and the session (theirs) changed rel since base: Quilt combines them
1690
+ * itself, and nobody is asked (docs/superpowers/specs/2026-10-18-automatic-merges.md). Lines only
1691
+ * one side changed are kept; lines both changed go to the newer edit; a binary file is the newer
1692
+ * version; an edit beats a deletion. What lost is kept (the record, .quilt/merges/<id>/, the
1693
+ * file's history) and whoever wrote it is told. Returns 'merged'.
1694
+ */
1695
+ autoSettle ({ rel, base, ours, theirs, theirsBy, disk, binary, via = null }) {
1696
+ const release = () => this.merging.delete(rel)
1697
+ const pulled = via === 'pull'
1698
+ const by = theirsBy ? cleanName(theirsBy) : null
1699
+ // When ours was written: a pull's file is as new as the commit that last changed it (not the
1700
+ // moment git wrote it), anything else as its last save.
1701
+ const oursAt = (pulled && this.committedAt(rel)) || (disk && Number.isFinite(disk.mtime) ? disk.mtime : Date.now())
1702
+ const oursNewer = oursAt >= this.sessionEditedAt(rel)
1703
+ const text = (k) => (typeof k === 'string' && !k.startsWith('bin:') ? k : null)
1704
+ const keep = ({ how, reason, lost = [] }) => {
1705
+ try {
1706
+ const rec = openMerge(this.bdoc, this.merges, {
1707
+ path: rel, by: this.name, byId: this.myKey(), others: by ? [by] : [], kind: 'conflict',
1708
+ ours: binary ? null : text(ours), oursDeleted: ours === null, via,
1709
+ base: binary ? null : text(base), theirsHash: theirs === undefined ? null : sha1(theirs), binary,
1710
+ reason: reason.replace(/[\u0000-\u001f\u007f]+/g, ' ').slice(0, 500)
1711
+ }, LOCAL)
1712
+ this.writeMergeFiles(rec.id, { base, ours, theirs, disk })
1713
+ updateMerge(this.bdoc, this.merges, rec.id, { state: 'done', how, resolvedBy: 'Quilt', reason: rec.reason }, LOCAL)
1714
+ } catch {}
1715
+ this.noteLostLines(rel, { by, lost, oursWon: how === 'mine' })
1716
+ }
1717
+ // An edit beats a deletion: nothing anyone wrote is thrown away.
1718
+ if (ours === null) {
1719
+ release()
1720
+ this.lastKnown.delete(rel)
1721
+ this.tryWrite(rel)
1722
+ keep({ how: 'theirs', reason: `Deleted here, but ${by || 'the session'} changed it: it stays` })
1723
+ this.log(`🧵 ${rel}: you deleted it, but ${by || 'the session'} changed it, so it stays`)
1724
+ return 'merged'
1725
+ }
1726
+ if (theirs === undefined) {
1727
+ release()
1728
+ this.ingest(rel, { pulled })
1729
+ keep({ how: 'mine', reason: `${by || 'The session'} deleted it, but it was changed here: it stays` })
1730
+ this.log(`🧵 ${rel}: ${by || 'the session'} deleted it, but you changed it, so it stays`)
1731
+ return 'merged'
1732
+ }
1733
+ if (binary) {
1734
+ release()
1735
+ if (oursNewer) this.ingest(rel, { pulled }); else { this.lastKnown.set(rel, ours); this.tryWrite(rel) }
1736
+ keep({ how: oursNewer ? 'mine' : 'theirs', reason: `Both changed it: the newer version (${oursNewer ? 'yours' : `${by || 'the session'}'s`}) stays`, lost: [{ side: oursNewer ? 'theirs' : 'ours', lines: [], kept: [], at: 0 }] })
1737
+ this.log(`🧵 ${rel}: both changed it; the newer version (${oursNewer ? 'yours' : `${by || 'the session'}'s`}) stays`)
1738
+ return 'merged'
1739
+ }
1740
+ // No common version (git couldn't read it, or both made the file): nothing lines up to combine,
1741
+ // so the newer version is the file, as for a binary.
1742
+ const r = typeof base === 'string' ? mergeAuto(base, ours, theirs, { oursNewer })
1743
+ : { text: oursNewer ? ours : theirs, lost: [{ side: oursNewer ? 'theirs' : 'ours', lines: (oursNewer ? theirs : ours).split('\n').filter(Boolean), kept: [], at: 0 }] }
1744
+ this.applyMerged(rel, r.text, `with ${by || 'the session'}'s changes${r.lost.length ? ' (the newer edit kept where both changed the same lines)' : ''}`, { pulled })
1745
+ release()
1746
+ if (r.lost.length) keep({ how: oursNewer ? 'mine' : 'theirs', reason: `Merged by Quilt: where both changed the same lines, the newer edit (${oursNewer ? 'yours' : `${by || 'the session'}'s`}) stays`, lost: r.lost })
1747
+ return 'merged'
1748
+ }
1749
+
1750
+ /**
1751
+ * Tells whoever's lines lost an automatic merge (autoSettle): this person in the log and the
1752
+ * catch-up, someone else in a direct message. A notice, never a task.
1753
+ */
1754
+ noteLostLines (rel, { by, lost, oursWon }) {
1755
+ if (!lost.length) return
1756
+ const shown = lost.flatMap((l) => l.lines).slice(0, 12)
1757
+ const lines = shown.length ? `\n${shown.map((l) => `- ${l}`).join('\n')}` : ''
1758
+ if (!oursWon) {
1759
+ this.log(`🧵 ${rel}: Quilt merged your changes with ${by || 'the session'}'s; where you both changed the same lines, their newer edit stays. Your lines are in the file's history and in .quilt/merges/.${lines}`)
1760
+ return
1761
+ }
1762
+ if (!by || by === this.name) return
1763
+ try {
1764
+ this.say(`Quilt merged ${rel}: where we both changed the same lines, my newer edit stays. Your lines it replaced are in the file's history (Changes):${lines}`, { to: by })
1765
+ } catch {}
1662
1766
  }
1663
1767
 
1664
1768
  /** Writes a merged text to the shared doc and the disk as one edit of ours (`pulled`: one a git pull brought). */
@@ -1715,19 +1819,14 @@ export class Session extends EventEmitter {
1715
1819
  reason: reason ? String(reason).replace(/[\u0000-\u001f\u007f]+/g, ' ').trim().slice(0, 500) || null : null
1716
1820
  }, LOCAL)
1717
1821
  this.writeMergeFiles(rec.id, { base, ours, theirs, disk })
1718
- // Live sync with GitHub: the relay holds the file and has the two parties merge it (relay-clash.js), never markers in the session.
1719
- if (this.branchLive() && this.conn && this.conn.adminRequest) {
1720
- this.conn.adminRequest({ op: 'offlineClash', path: rel, mergeId: rec.id, ...(binary ? { binary: true } : {}), ...(text(base) !== null ? { base: text(base) } : {}), ...(ours === null ? { oursDeleted: true } : text(ours) !== null ? { ours: text(ours) } : {}) })
1721
- .catch((err) => this.log(`could not hand ${rel}'s merge to the session: ${err.message}`))
1722
- }
1723
1822
  // The session's version goes back on disk (or the file goes, if the session
1724
1823
  // deleted it). lastKnown is set to ours first so writeOut doesn't also copy
1725
1824
  // it to .quilt/conflicts: the merge folder already has it.
1726
1825
  this.merging.delete(rel)
1727
1826
  if (ours === null) this.lastKnown.delete(rel); else this.lastKnown.set(rel, ours)
1728
1827
  this.tryWrite(rel)
1729
- const who = claimant ? `${claimant} has it claimed` : by ? `${by} changed it too` : 'it changed in the session too'
1730
- this.log(`⚠️ ${rel} needs merging: ${who}${reason ? ` (${reason})` : ''}. Your version is kept; see Merges in the app.`)
1828
+ // Only a file someone holds comes here now (autoSettle combines the rest): it waits for them.
1829
+ this.log(`⏳ ${rel}: ${claimant || by || 'someone'} has it claimed. Your changes are kept and go in, merged by Quilt, once they let go of it.`)
1731
1830
  this.emit('file-changed', { path: rel, by: by || 'partner' })
1732
1831
  return 'conflict'
1733
1832
  }
@@ -1852,11 +1951,11 @@ export class Session extends EventEmitter {
1852
1951
  if (looksBinary(buf)) {
1853
1952
  if (buf.length > MAX_BINARY_BYTES && this.largeFilesOff) return { tooLarge: true }
1854
1953
  const hash = sha1(buf)
1855
- return { binary: true, buf, hash, key: `bin:${hash}`, exec }
1954
+ return { binary: true, buf, hash, key: `bin:${hash}`, exec, mtime: st.mtimeMs }
1856
1955
  }
1857
1956
  if (buf.length > MAX_TEXT_BYTES) return { tooLarge: true }
1858
1957
  const text = buf.toString('utf8')
1859
- return { text, key: text, exec }
1958
+ return { text, key: text, exec, mtime: st.mtimeMs }
1860
1959
  }
1861
1960
 
1862
1961
  // ------------------------------------------------------- local -> shared --
@@ -2385,9 +2484,17 @@ export class Session extends EventEmitter {
2385
2484
  const theirs = inDoc ? this.sharedKey(rel) : head // null: neither this branch nor its commit has it
2386
2485
  if (ours === theirs) { if (ours === null) this.lastKnown.delete(rel); else this.lastKnown.set(rel, ours); return null }
2387
2486
  if (ours === base) { this.putBranchVersion(rel, { inDoc, head, headBuf }); return null } // not edited after all
2388
- // Edited here and changed on this branch too: never combined on its own (see mergeOne).
2389
- this.setAsideCarried(rel, { inDoc, head, headBuf, clash: theirs !== null })
2390
- return null
2487
+ if (theirs === null) { this.setAsideCarried(rel, { inDoc, head, headBuf, clash: false }); return null } // not on this branch
2488
+ // Edited here and changed on this branch too: combined by Quilt, as mergeOne does.
2489
+ const binary = [base, ours, theirs].some((k) => typeof k === 'string' && k.startsWith('bin:'))
2490
+ if (inDoc) return this.autoSettle({ rel, base, ours, theirs, theirsBy: this.lastEditorOf(rel), disk, binary })
2491
+ // This branch's commit has it, its document doesn't yet: the merge goes onto the disk, and is shared from there.
2492
+ if (ours === null) { this.putHeadVersion(rel, head, headBuf); return null }
2493
+ if (binary) return null // ours, edited after the checkout, is the newer version: it stays, and is shared from the disk
2494
+ const r = mergeAuto(base || '', ours, theirs, { oursNewer: true })
2495
+ this.writeFile(rel, resolveInside(this.root, rel), r.text)
2496
+ this.log(`🧵 ${rel}: your edit after switching to ${this.branch} is merged with ${this.branch}'s own change to it`)
2497
+ return 'merged'
2391
2498
  }
2392
2499
 
2393
2500
  /** This branch's version of rel onto the disk: its document's, else HEAD's file, else none. */
@@ -2787,7 +2894,7 @@ export class Session extends EventEmitter {
2787
2894
  const has = () => atHead && changed.every((rel) => this.sharedKey(rel) === (atHead.get(rel) ?? undefined))
2788
2895
  if (atHead && !atHead.failed && (has() || await this.relayHasPull(head.sha, has))) old = atHead
2789
2896
  }
2790
- if (old.failed) this.log(`⚠️ git could not read ${old.failed} file${old.failed === 1 ? '' : 's'} at the old commit; merged without a base (a merge record at worst)`)
2897
+ if (old.failed) this.log(`⚠️ git could not read ${old.failed} file${old.failed === 1 ? '' : 's'} at the old commit; merged without a base, so where they differ the newer version stays`)
2791
2898
  // A file git made the person resolve (stash pop, rebase) holds their merge of the commits and the
2792
2899
  // session's work: merged against the session's version this disk had, it is taken as resolved.
2793
2900
  const advance = changed.map((rel) => ({ rel, base: this.heldConflicts.has(rel) && this.lastKnown.has(rel) ? this.lastKnown.get(rel) : old.get(rel) ?? undefined, via: 'pull' }))
@@ -3049,6 +3156,11 @@ export class Session extends EventEmitter {
3049
3156
  if (!behind) { this.setUpstream({ ...up, behind, ahead }); if (ahead) await this.publishLocal({ up, at, behind, ahead }); return this.upstream }
3050
3157
  if (ahead) {
3051
3158
  if (await this.followReplayed({ up, at })) return this.upstream
3159
+ // The relay has these commits on GitHub already: the branch follows once GitHub's files are here (followPublished).
3160
+ if (this.adoptLater && this.adoptLater.at === at) {
3161
+ if (await this.followPublished(up)) return this.upstream
3162
+ if (this.adoptLater) { this.setUpstream({ ...up, behind, ahead, diverged: true }); return this.upstream }
3163
+ }
3052
3164
  this.setUpstream({ ...up, behind, ahead, diverged: true }); await this.publishLocal({ up, at, behind, ahead }); return this.upstream
3053
3165
  }
3054
3166
  await this.bringIn(at, up.sha, { moveRef: true, up: { ...up, behind, ahead }, now })
@@ -3093,17 +3205,67 @@ export class Session extends EventEmitter {
3093
3205
  let out
3094
3206
  try { out = (await this.conn.adminRequest({ op: 'bundle', bundle: bundle.toString('base64'), head: at, ref: `refs/heads/${branch}` }, { timeoutMs: PUSH_REQUEST_TIMEOUT_MS })).bundle } catch (err) { this.published = null; this.log(`could not hand your local commits to the relay: ${err.message}`); return }
3095
3207
  if (!out || !['pushed', 'nothing', 'clash'].includes(out.state)) { this.log(`your local commits on ${branch} stay here for now: ${(out && out.why) || 'the relay could not take them'}`); return }
3096
- if (out.state === 'clash') this.notice(`Your local commit on ${branch} clashes with GitHub: ${out.why}. Your files are in the session as they are; the clash is merged there, and this folder follows GitHub's branch (your commits are kept under refs/quilt/original/${branch}/).`)
3097
3208
  const f = await fetchUpstream(this.root, up.remote, up.url, { token: this.gitTokenNow() })
3098
3209
  if (!f.ok) return
3099
3210
  const now = await upstreamOf(this.root)
3100
- if (!now || !now.sha || now.sha === at) return
3101
- const r = await adoptPublished(this.root, branch, at, now.sha)
3102
- if (!r.moved) { this.log(`your local commits went to GitHub; ${branch} follows it once ${r.why}`); this.published = null; return }
3103
- this.gitSeen = { key: branch, branch, sha: now.sha }
3211
+ if (!now || !now.sha) return
3212
+ // The folder follows the commit the relay put the session at (its push, or GitHub's head it brought in), never a
3213
+ // newer one of GitHub's the fetch just found: the session has yet to bring that in, and a branch moved there with
3214
+ // git alone would lack its files, so a commit made then would delete them. GitHub's newer commits come in as any do.
3215
+ // (A relay before 0.3.96 says no `head` for 'nothing' and 'clash': GitHub's head then.)
3216
+ const to = out.sha || out.head || now.sha
3217
+ if (to === at) return
3218
+ this.adoptLater = { at, to, count: out.published ? out.published.length : 0 }
3219
+ await this.followPublished(now)
3220
+ }
3221
+
3222
+ /**
3223
+ * The relay put this folder's local commits on GitHub (publishLocal): the branch follows the commit the relay
3224
+ * put the session at (adoptLater.to) once the session's files are on disk (sessionOnDisk), else at a later look.
3225
+ * `now` is the upstream as the folder last fetched it. Resolves whether it moved.
3226
+ */
3227
+ async followPublished (now) {
3228
+ const later = this.adoptLater
3229
+ const branch = this.git && this.git.branch
3230
+ // Not on GitHub's branch any more (rewritten meanwhile): the commits are handed over again (publishLocal).
3231
+ if (!later || !branch || !now || !now.sha || (now.sha !== later.to && !(await isAncestor(this.root, later.to, now.sha)))) { this.adoptLater = null; this.published = null; return false }
3232
+ if (!(await this.sessionOnDisk(later.at, later.to))) {
3233
+ this.askRelayFor(later.to)
3234
+ this.checkUpstreamSoon({ delay: 500 })
3235
+ return false
3236
+ }
3237
+ this.adoptLater = null
3238
+ const r = await adoptPublished(this.root, branch, later.at, later.to)
3239
+ if (!r.moved) { this.log(`your local commits went to GitHub; ${branch} follows it once ${r.why}`); this.published = null; return false }
3240
+ this.gitSeen = { key: branch, branch, sha: later.to }
3104
3241
  this.gitIndex = indexStamp(this.root)
3105
- this.log(`⬆️ the relay put your local commit${out.published && out.published.length === 1 ? '' : 's'} on ${up.name} (${now.sha.slice(0, 7)}); ${branch} follows it (yours are kept under refs/quilt/original/${branch}/)`)
3106
- this.setUpstream({ ...now, behind: 0, ahead: 0 })
3242
+ this.log(`⬆️ the relay put your local commit${later.count === 1 ? '' : 's'} on ${now.name} (${later.to.slice(0, 7)}); ${branch} follows it (yours are kept under refs/quilt/original/${branch}/)`)
3243
+ const behind = now.sha === later.to ? 0 : ((await commitsBetween(this.root, later.to, now.sha)) ?? 1)
3244
+ this.setUpstream({ ...now, behind, ahead: 0 })
3245
+ if (behind) this.checkUpstreamSoon()
3246
+ return true
3247
+ }
3248
+
3249
+ /** What the folder holds at rel, as lastKnown keys it: null when nothing is there, undefined for a file never synced (a link, too large). */
3250
+ diskKey (rel) {
3251
+ const d = this.readDisk(rel)
3252
+ if (!d) return null
3253
+ return d.skip || d.tooLarge ? undefined : d.key
3254
+ }
3255
+
3256
+ /**
3257
+ * Whether this folder's branch may move from `from` to `to` with git alone, every file left as it is: the
3258
+ * session is at `to` (the relay brought it in, or a member's folder is there), and each file the commits
3259
+ * between them change is on disk as the session has it. A file of GitHub's still on its way here (one
3260
+ * written only once the folder's hold ends) would be missing under an index that has it, and a
3261
+ * `git commit -a` just after the move would take it for a deletion and undo it on GitHub.
3262
+ */
3263
+ async sessionOnDisk (from, to) {
3264
+ if (!this.memberAt(to) && !(await this.relayPast(to))) return false
3265
+ const changes = await changesBetween(this.root, from, to)
3266
+ if (!changes) return false
3267
+ for (const rel of changes.keys()) if (this.syncable(rel) && (this.diskKey(rel) ?? undefined) !== this.sharedKey(rel)) return false
3268
+ return true
3107
3269
  }
3108
3270
 
3109
3271
  /**
@@ -3173,11 +3335,7 @@ export class Session extends EventEmitter {
3173
3335
  for (const rel of paths) {
3174
3336
  if (this.syncable(rel) && this.writeRefusal(rel)) { state({ waiting: `you may not change ${rel} in this session` }); return }
3175
3337
  }
3176
- const diskKey = (rel) => {
3177
- const d = this.readDisk(rel)
3178
- if (!d) return null
3179
- return d.skip || d.tooLarge ? undefined : d.key
3180
- }
3338
+ const diskKey = (rel) => this.diskKey(rel)
3181
3339
  // The session and this folder must agree first: a change still on its way waits for the next look.
3182
3340
  // Following, that is every file the commits changed: the branch moves only once the session's versions are
3183
3341
  // on disk, or a commit made meanwhile (git commit -a) would take a file still on its way for a deletion.
@@ -3202,7 +3360,9 @@ export class Session extends EventEmitter {
3202
3360
  if (emptied.has(dir) || [...this.sharedPaths()].some((p) => (dir ? p.startsWith(dir + '/') : !p.includes('/')))) continue
3203
3361
  if (await hasFilesUnder(this.root, from, dir)) emptied.add(dir)
3204
3362
  }
3205
- const plan = planCatchUp({ changes: scope, base, theirs, disk: diskKey, moved, emptied, published: publishedOf(this.doc) })
3363
+ // Where the session and the commits changed the same lines, the newer edit stays (upstream.js combine).
3364
+ const times = await commitTimes(this.root, from, to)
3365
+ const plan = planCatchUp({ changes: scope, base, theirs, disk: diskKey, moved, emptied, published: publishedOf(this.doc), oursNewer: (rel) => this.sessionEditedAt(rel) > ((times && times.get(rel)) || 0) + 999 }) // a commit's whole second is its own
3206
3366
  if (this.stopped || !this.quietForUpstream()) return
3207
3367
  if (plan.conflicts.length) {
3208
3368
  const behind = up ? up.behind : await commitsBetween(this.root, from, to)
@@ -3253,6 +3413,14 @@ export class Session extends EventEmitter {
3253
3413
  if (following) this.log(`⬇️ ${branch} follows ${isRelay(following) ? 'the relay' : `${following}'s folder`} to ${to.slice(0, 7)} (${count ?? 'new'} commit${count === 1 ? '' : 's'} from ${upName}); the session already has ${isRelay(following) ? 'the files it brought in' : 'their files'}`)
3254
3414
  const moves = plan.moves.length ? `; followed ${plan.moves.map((m) => `${m.from} → ${m.to}`).join(', ')}` : ''
3255
3415
  if (!following) this.log(`⬇️ brought in ${count ?? 'new'} commit${count === 1 ? '' : 's'} from ${upName} (${written} file${written === 1 ? '' : 's'})${moves}`)
3416
+ // Whoever's lines the commits' newer edit replaced is told once (a notice, nothing to do).
3417
+ for (const m of following ? [] : plan.merged || []) {
3418
+ const lost = (m.lost || []).filter((l) => l.side === 'ours').flatMap((l) => l.lines)
3419
+ if (!lost.length) continue
3420
+ const text = `Quilt brought ${upName}'s commits into ${m.path}: where the session and the commits changed the same lines, the commits' newer edit stays. The replaced lines are in the file's history (Changes):\n${lost.slice(0, 12).map((l) => `- ${l}`).join('\n')}`
3421
+ const who = this.lastEditorOf(m.path)
3422
+ if (who && who !== this.name) { try { this.say(text, { to: who }) } catch {} } else this.log(`🧵 ${text}`)
3423
+ }
3256
3424
  if (written && !following) await this.notePull({ from: { sha: from }, to: { sha: to, branch }, files: written })
3257
3425
  if (plan.strays.length) {
3258
3426
  const list = plan.strays.slice(0, 5).join(', ') + (plan.strays.length > 5 ? ', …' : '')
@@ -4061,7 +4229,6 @@ export class Session extends EventEmitter {
4061
4229
 
4062
4230
  if (!disk) {
4063
4231
  if (!this.files.has(rel) && !this.blobs.has(rel)) { this.lastKnown.delete(rel); return false }
4064
- this.closeHandMerge(rel, null) // deleting a file being merged by hand settles it too
4065
4232
  this.transact(() => {
4066
4233
  const was = this.files.get(rel)
4067
4234
  const before = was ? was.toString() : undefined
@@ -4088,7 +4255,6 @@ export class Session extends EventEmitter {
4088
4255
  // Storage refused it, but it's small enough to share inside the document.
4089
4256
  }
4090
4257
 
4091
- if (!disk.binary) this.closeHandMerge(rel, disk.text)
4092
4258
  let detail = ''
4093
4259
  this.transact(() => {
4094
4260
  const existed = this.files.has(rel) || this.blobs.has(rel)
@@ -5988,42 +6154,124 @@ export class Session extends EventEmitter {
5988
6154
  })
5989
6155
  if (!res.ok) throw new Error(`upload failed: ${await res.text()}`)
5990
6156
  const fileId = await res.text()
5991
- return this.say(text, { to, file: { id: fileId, name: path.basename(abs), size: st.size } })
6157
+ const sent = this.say(text, { to, file: { id: fileId, name: path.basename(abs), size: st.size } })
6158
+ // Ours is in the chat files folder too, like everyone's who can see it.
6159
+ try {
6160
+ const msg = this.chat.toArray().find((m) => m.id === sent.id)
6161
+ const kept = this.chatFilePath(msg, { assign: true })
6162
+ fs.copyFileSync(abs, kept)
6163
+ this.chatFileArrived(msg, kept)
6164
+ return { ...sent, file: this.describeMessage(msg).file }
6165
+ } catch (err) {
6166
+ this.log(`couldn't keep ${path.basename(abs)} in ${CHAT_FILES}/: ${err.message}`)
6167
+ return sent
6168
+ }
5992
6169
  }
5993
6170
 
5994
- /** Downloads a message's attachment (to .quilt/inbox/ by default). */
6171
+ /** Downloads a message's attachment (to the chat files folder by default). */
5995
6172
  async fetchFile (msgOrId, dest) {
5996
6173
  const msg = typeof msgOrId === 'string' ? this.chat.toArray().find((m) => validMessage(m) && (m.id === msgOrId || (m.file && m.file.id === msgOrId))) : msgOrId
5997
6174
  if (!msg || !msg.file || !this.canSee(msg)) throw new Error('no such file')
5998
- const target = dest ? path.resolve(dest) : this.inboxPath(msg)
6175
+ const fresh = !dest && !this.chatFileNames()[msg.id]
6176
+ const target = dest ? path.resolve(dest) : this.chatFilePath(msg, { assign: true })
5999
6177
  const finalPath = fs.existsSync(target) && fs.statSync(target).isDirectory() ? path.join(target, safeName(msg.file.name)) : target
6000
6178
  if (!dest) {
6001
- // Belt and braces: the id and name are sanitised, so this can't escape the inbox; never let it anyway.
6002
- const inside = path.relative(path.join(this.stateDir, 'inbox'), finalPath)
6003
- if (!inside || inside.startsWith('..') || path.isAbsolute(inside)) throw new Error('refusing to save a file outside the inbox')
6179
+ // Belt and braces: the name is sanitised, so this can't leave the folder; never let it anyway.
6180
+ const inside = path.relative(path.join(this.root, CHAT_FILES), finalPath)
6181
+ if (!inside || inside.startsWith('..') || path.isAbsolute(inside)) throw new Error('refusing to save a file outside the chat files folder')
6182
+ }
6183
+ try {
6184
+ const res = await fetch(`${this.httpBase()}/files/${encodeURIComponent(this.room)}/${encodeURIComponent(msg.file.id)}`, {
6185
+ headers: await this.relayHeaders()
6186
+ })
6187
+ if (!res.ok) throw new Error(`download failed: ${await res.text()}`)
6188
+ fs.mkdirSync(path.dirname(finalPath), { recursive: true })
6189
+ fs.writeFileSync(finalPath, Buffer.from(await res.arrayBuffer()))
6190
+ if (!dest) this.chatFileArrived(msg, finalPath)
6191
+ } catch (err) {
6192
+ // Not saved: its name is free again, and the next start tries again.
6193
+ if (fresh) this.forgetChatFile(msg.id)
6194
+ throw err
6004
6195
  }
6005
- const res = await fetch(`${this.httpBase()}/files/${encodeURIComponent(this.room)}/${encodeURIComponent(msg.file.id)}`, {
6006
- headers: await this.relayHeaders()
6007
- })
6008
- if (!res.ok) throw new Error(`download failed: ${await res.text()}`)
6009
- fs.mkdirSync(path.dirname(finalPath), { recursive: true })
6010
- fs.writeFileSync(finalPath, Buffer.from(await res.arrayBuffer()))
6011
6196
  return finalPath
6012
6197
  }
6013
6198
 
6014
- /** Downloads unread files that were sent while we were offline. */
6015
- fetchMissedFiles () {
6016
- for (const m of this.messages({ unreadOnly: true, markRead: false, limit: 50 })) {
6017
- if (!m.file || m.file.localPath) continue
6018
- this.fetchFile(m.id).then(
6019
- (dest) => this.log(`📎 received ${m.file.name} from ${m.by} while you were away → ${path.relative(this.root, dest)}`),
6020
- () => {}
6021
- )
6199
+ /**
6200
+ * Every file in the chat I can see is in my chat files folder: those sent while I was away
6201
+ * or before I joined are downloaded, one at a time; one an older Quilt kept in .quilt/inbox/ is moved.
6202
+ * One saved before and since deleted from the folder stays deleted (opening it in chat fetches it again).
6203
+ */
6204
+ async fetchMissedFiles () {
6205
+ if (this.fetchingMissed) return
6206
+ this.fetchingMissed = true
6207
+ try {
6208
+ for (const m of this.chat.toArray()) {
6209
+ if (this.stopped) return
6210
+ try {
6211
+ if (!m || !m.file || !this.canSee(m) || this.chatFileNames()[m.id]) continue
6212
+ const old = path.join(this.stateDir, 'inbox', `${m.id.slice(0, 6)}-${safeName(m.file.name)}`)
6213
+ if (fs.existsSync(old)) { const to = this.chatFilePath(m, { assign: true }); fs.renameSync(old, to); this.chatFileArrived(m, to); continue }
6214
+ const dest = await this.fetchFile(m)
6215
+ if (m.by !== this.name) this.log(`📎 received ${m.file.name} from ${m.by} while you were away → ${path.relative(this.root, dest)}`)
6216
+ } catch {} // gone from the relay, or offline: the next start tries again
6217
+ }
6218
+ } finally { this.fetchingMissed = false }
6219
+ }
6220
+
6221
+ /** Names given to chat files in the chat files folder, by message id (kept in .quilt/). */
6222
+ chatFileNames () {
6223
+ if (!this.chatFileIndex) {
6224
+ try { this.chatFileIndex = JSON.parse(fs.readFileSync(path.join(this.stateDir, 'chat-files.json'), 'utf8')) } catch {}
6225
+ if (!this.chatFileIndex || typeof this.chatFileIndex !== 'object') this.chatFileIndex = {}
6226
+ }
6227
+ return this.chatFileIndex
6228
+ }
6229
+
6230
+ /**
6231
+ * Where a message's file is kept: `Chat files/<its name>`, or `<name> (2)` and on when another
6232
+ * file there has that name. With `assign`, a file not given a name yet gets one (and the folder is made).
6233
+ */
6234
+ chatFilePath (msg, { assign = false } = {}) {
6235
+ const dir = path.join(this.root, CHAT_FILES)
6236
+ const names = this.chatFileNames()
6237
+ // Names come from safeName below; one edited by hand to leave the folder is taken for none.
6238
+ let name = typeof names[msg.id] === 'string' && names[msg.id] === path.basename(names[msg.id]) && !names[msg.id].startsWith('.') ? names[msg.id] : null
6239
+ if (!name) {
6240
+ if (!assign) return null
6241
+ const taken = new Set(Object.values(names))
6242
+ const want = safeName(msg.file.name)
6243
+ const ext = path.extname(want)
6244
+ const stem = want.slice(0, want.length - ext.length) || want
6245
+ name = want
6246
+ for (let n = 2; taken.has(name) || fs.existsSync(path.join(dir, name)); n++) name = `${stem} (${n})${ext}`
6247
+ names[msg.id] = name
6248
+ writePrivateJson(path.join(this.stateDir, 'chat-files.json'), names)
6249
+ }
6250
+ if (assign) {
6251
+ fs.mkdirSync(dir, { recursive: true })
6252
+ // Git leaves the folder alone (Quilt never syncs it: pathrules.js), with nothing added to the project's .gitignore.
6253
+ const gi = path.join(dir, '.gitignore')
6254
+ if (!fs.existsSync(gi)) fs.writeFileSync(gi, '# Files sent in the Quilt session\'s chat: only for the people who can see them.\n*\n')
6022
6255
  }
6256
+ return path.join(dir, name)
6257
+ }
6258
+
6259
+ /** The app's file tree shows the chat files folder: a file saved there is a change to it. */
6260
+ chatFileArrived (msg, file) {
6261
+ this.emit('file-changed', { path: `${CHAT_FILES}/${path.basename(file)}`, by: msg.by, chat: true })
6262
+ }
6263
+
6264
+ forgetChatFile (id) {
6265
+ const names = this.chatFileNames()
6266
+ if (!(id in names)) return
6267
+ delete names[id]
6268
+ try { writePrivateJson(path.join(this.stateDir, 'chat-files.json'), names) } catch {}
6023
6269
  }
6024
6270
 
6025
- inboxPath (msg) {
6026
- return path.join(this.stateDir, 'inbox', `${msg.id.slice(0, 6)}-${safeName(msg.file.name)}`)
6271
+ /** The message's file in the chat files folder, when it is there. */
6272
+ chatFileSaved (msg) {
6273
+ const p = this.chatFilePath(msg)
6274
+ return p && fs.existsSync(p) ? p : null
6027
6275
  }
6028
6276
 
6029
6277
  /**
@@ -6065,8 +6313,8 @@ export class Session extends EventEmitter {
6065
6313
  describeMessage (msg) {
6066
6314
  const out = { ...msg, unread: msg.by !== this.name && !this.readIds().has(msg.id) }
6067
6315
  if (msg.file && validMessage(msg)) {
6068
- const local = this.inboxPath(msg)
6069
- out.file = { ...msg.file, localPath: fs.existsSync(local) ? path.relative(this.root, local) : null }
6316
+ const local = this.chatFileSaved(msg)
6317
+ out.file = { ...msg.file, localPath: local ? path.relative(this.root, local) : null }
6070
6318
  }
6071
6319
  return out
6072
6320
  }
@@ -6647,7 +6895,8 @@ export class Session extends EventEmitter {
6647
6895
  settleReleasedMerges () {
6648
6896
  for (const rec of this.mergeList()) {
6649
6897
  if (rec.kind !== 'claimed' || rec.state !== 'open' || rec.by !== this.name || rec.binary || rec.oursDeleted || rec.theirsHash === null) continue
6650
- if (this.mergeHeldBy(rec) || this.writeRefusal(rec.path) || this.merging.has(rec.path)) continue
6898
+ // A record anyone can write names any path: never one the session doesn't sync (.env, .git, ignored files).
6899
+ if (!this.syncable(rec.path) || this.mergeHeldBy(rec) || this.writeRefusal(rec.path) || this.merging.has(rec.path)) continue
6651
6900
  const theirs = this.sharedKey(rec.path)
6652
6901
  if (typeof theirs !== 'string' || theirs.startsWith('bin:')) continue
6653
6902
  const tried = `${rec.id}:${sha1(theirs)}`
@@ -6658,10 +6907,13 @@ export class Session extends EventEmitter {
6658
6907
  const { ours, base } = this.mergeTexts(rec)
6659
6908
  if (typeof ours !== 'string') continue
6660
6909
  const holder = rec.claimedBy || rec.others[0] || 'someone'
6661
- if (theirs !== (base ?? '')) { this.log(`⚠️ ${holder} let go of ${rec.path}, and it changed meanwhile: see Merges`); continue }
6662
6910
  try {
6663
- this.applyMerged(rec.path, ours, `once ${holder} let go of it`)
6664
- updateMerge(this.bdoc, this.merges, rec.id, { state: 'done', resolvedBy: this.name, reason: `Went in once ${holder} let go of it: nobody else changed it meanwhile` }, LOCAL)
6911
+ // Changed meanwhile: combined by Quilt (autoSettle's rule), the newer edit kept where both changed the same lines.
6912
+ const oursNewer = rec.ts >= this.sessionEditedAt(rec.path)
6913
+ const r = theirs === (base ?? '') ? { text: ours, lost: [] } : mergeAuto(base ?? '', ours, theirs, { oursNewer })
6914
+ this.applyMerged(rec.path, r.text, `once ${holder} let go of it`)
6915
+ updateMerge(this.bdoc, this.merges, rec.id, { state: 'done', how: r.lost.length && !oursNewer ? 'theirs' : 'mine', resolvedBy: 'Quilt', reason: r.lost.length ? `Went in once ${holder} let go of it, merged by Quilt: the newer edit kept where both changed the same lines` : `Went in once ${holder} let go of it` }, LOCAL)
6916
+ this.noteLostLines(rec.path, { by: holder, lost: r.lost, oursWon: oursNewer })
6665
6917
  } catch (err) {
6666
6918
  this.log(`could not combine ${rec.path}: ${err.message}`)
6667
6919
  continue
@@ -6715,204 +6967,18 @@ export class Session extends EventEmitter {
6715
6967
  return { ours, base, theirs }
6716
6968
  }
6717
6969
 
6718
- /**
6719
- * Settles a merge: `mine` writes the returning person's version, `theirs`
6720
- * keeps the session's, `hand` puts conflict markers in the file for someone
6721
- * to edit (the record closes when the file next syncs without them),
6722
- * `agent` and `review` just close it (the file is already as wanted).
6723
- */
6724
- resolveMerge (id, { how } = {}) {
6725
- const rec = this.mergeList().find((m) => m.id === id)
6726
- if (!rec) throw new Error('no such merge')
6727
- if (rec.state === 'done') throw new Error('that merge is already settled')
6728
- if (!['mine', 'theirs', 'hand', 'agent', 'review'].includes(how)) throw new Error('say how: mine, theirs, hand, agent or review')
6729
- // A record anyone can write names any path: never touch one the session doesn't sync (.env, .git, ignored files).
6730
- if (!this.syncable(rec.path) && how !== 'agent' && how !== 'review') throw new Error(`${rec.path} is not synced in this session, so Quilt will not write it`)
6731
- // The doc holds the markers now, so "theirs" would be marker text.
6732
- if (rec.state === 'editing' && (how === 'theirs' || how === 'hand')) throw new Error(STILL_MARKED)
6733
- // Settling is an edit of the session: viewers (and agents outside their folders) only see the record.
6734
- const refusal = this.writeRefusal(rec.path)
6735
- if (refusal) throw new Error(refusal)
6736
- if (this.branchLive() && how === 'hand') throw new Error('This branch syncs with GitHub: conflict markers never go in the shared file. Propose the merged file with quilt_clash, or keep one side.')
6737
- if (this.branchLive() && this.kind === 'agent' && (how === 'mine' || how === 'agent')) throw new Error('This branch syncs with GitHub: the clash is held, and agents merge it with quilt_clash (propose; the reviewer accepts).')
6738
- // Someone else holds the file (settling it, or their AI is): only keeping the session's version is left, so nobody settles one merge twice.
6739
- if (how !== 'theirs') this.mergeHeldCheck(rec)
6740
- const onlyThere = () => new Error(`${rec.by === this.name ? 'your' : `${rec.by}'s`} version of ${rec.path} is only in the merge folder on ${rec.by === this.name ? 'the computer you merged on' : 'their computer'}`)
6741
- const { ours, base, theirs } = this.mergeTexts(rec)
6742
- if (how === 'mine') {
6743
- let data = null // null: deleted while away, so keeping mine deletes it in the session too
6744
- if (!rec.oursDeleted) {
6745
- // Binary versions never go in the record: only the opener's machine has ours.
6746
- data = ours
6747
- if (rec.binary) {
6748
- try { data = fs.readFileSync(path.join(this.mergeDir(rec.id), 'ours')) } catch {}
6749
- }
6750
- if (data === null) throw onlyThere()
6751
- }
6752
- this.keepMine(rec.path, data)
6753
- } else if (how === 'theirs') {
6754
- this.tryWrite(rec.path)
6755
- } else if (how === 'hand') {
6756
- if (!rec.binary && !rec.oursDeleted && ours === null) throw onlyThere()
6757
- if (rec.binary || ours === null || theirs === null) throw new Error('markers only work when both sides have a text version: keep mine or keep theirs instead')
6758
- this.applyMerged(rec.path, withMarkers(base || '', ours, theirs, { mine: rec.by, theirs: rec.others[0] || 'session' }), 'with conflict markers to edit by hand')
6759
- return updateMerge(this.bdoc, this.merges, id, { state: 'editing', how: 'hand', resolvedBy: this.name }, LOCAL)
6760
- }
6761
- const out = updateMerge(this.bdoc, this.merges, id, { state: 'done', how, resolvedBy: this.name }, LOCAL)
6762
- this.log(`✅ ${rec.path}: merge settled (${how === 'mine' ? `${rec.by}'s version` : how === 'theirs' ? "the session's version" : how === 'agent' ? 'merged by an AI' : 'reviewed'})`)
6763
- this.scheduleStatusWrite()
6764
- return out
6765
- }
6766
-
6767
- /**
6768
- * Takes a merge's file for us before settling it or sending it to an AI, so two people
6769
- * can't settle one merge at once: the relay hands a claim to one of them and refuses the
6770
- * other, who then sees it claimed and can ask for it. Already ours: nothing to take.
6771
- * Returns whether a claim was made here, to let go of once the merge is settled.
6772
- */
6773
- async takeMerge (rec) {
6774
- if (!this.conn || this.mergeHeldBy(rec)) return false // offline, or someone else's: the checks after say so
6775
- const claim = this.claimFor(rec.path)
6776
- if (claim && this.ownClaim(claim)) return false
6777
- try {
6778
- await this.claim(rec.path, `${MERGE_CLAIM_NOTE} ${rec.path}`)
6779
- } catch (err) {
6780
- const by = /claimed by (.+)$/.exec(err.message)?.[1] || /overlaps (.+?)'s claim/.exec(err.message)?.[1]
6781
- throw new Error(by ? `${by} is settling ${rec.path} right now. Ask ${by} for it, or keep ${by}'s version once they're done.` : err.message)
6782
- }
6783
- this.mergeClaims.add(rec.path)
6784
- return true
6785
- }
6786
-
6787
- /**
6788
- * Lets go of the claim a merge took (takeMerge) once it is settled; known by its note too,
6789
- * so one from before a restart goes as well. Someone waiting for it is handed it by its holder instead.
6790
- */
6791
- async releaseMergeClaim (rel) {
6792
- const took = this.mergeClaims.delete(rel)
6793
- const c = this.claims.get(rel)
6794
- if (!c || !this.ownClaim(c) || (c.queue && c.queue.length)) return
6795
- if (!took && !String(c.note || '').startsWith(MERGE_CLAIM_NOTE)) return
6796
- await this.release(rel).catch(() => {})
6797
- }
6798
-
6799
- /** resolveMerge with the file taken first; the claim stays while markers are in it, and goes once it is settled. */
6800
- async settleMerge (id, { how } = {}) {
6801
- const rec = this.mergeList().find((m) => m.id === id)
6802
- if (!rec) throw new Error('no such merge')
6803
- const writes = how === 'mine' || how === 'theirs' || how === 'hand'
6804
- const took = writes && rec.state !== 'done' ? await this.takeMerge(rec) : false
6805
- let out
6806
- try {
6807
- out = this.resolveMerge(id, { how })
6808
- } catch (err) {
6809
- if (took) await this.releaseMergeClaim(rec.path)
6810
- throw err
6811
- }
6812
- if (out?.state === 'done') await this.releaseMergeClaim(rec.path)
6813
- return out
6814
- }
6815
-
6816
- /** prepareMergeSend with the file taken first: it stays ours while our AI merges it, until the merge is settled. */
6817
- async sendMerge (id) {
6818
- const rec = this.mergeList().find((m) => m.id === id)
6819
- if (!rec) throw new Error('no such merge')
6820
- const took = rec.state === 'open' ? await this.takeMerge(rec) : false
6821
- try {
6822
- return this.prepareMergeSend(id)
6823
- } catch (err) {
6824
- if (took) await this.releaseMergeClaim(rec.path)
6825
- throw err
6826
- }
6827
- }
6828
-
6829
- /**
6830
- * Keep mine: the disk first, then the doc, so a failed write never leaves
6831
- * the session with ours while the record stays open. `data` null deletes.
6832
- */
6833
- keepMine (rel, data) {
6834
- if (this.merging.has(rel) || this.downloading.has(rel)) throw new Error(`${rel} is still syncing; try again in a moment`)
6835
- const abs = resolveInside(this.root, rel)
6836
- if (data === null) {
6837
- fs.rmSync(abs, { force: true })
6838
- removeEmptyParents(this.root, path.dirname(abs))
6839
- } else {
6840
- fs.mkdirSync(path.dirname(abs), { recursive: true })
6841
- this.writeFile(rel, abs, data)
6842
- }
6843
- this.writeFailed.delete(rel) // the disk now holds a version we chose, not a stale one
6844
- if (this.ingest(rel)) return
6845
- // Nothing pushed: fine if the session already has it, or a large file is on its way up.
6846
- const want = data === null ? undefined : Buffer.isBuffer(data) ? `bin:${sha1(data)}` : data
6847
- if (this.sharedKey(rel) === want || (Buffer.isBuffer(data) && this.uploading.get(rel) === sha1(data))) return
6848
- throw new Error(`could not share ${rel}; the merge stays open`)
6849
- }
6850
-
6851
6970
  /** Who else holds a merge's file right now, or null: settling it (or sending it to an AI) writes the file. */
6852
6971
  mergeHeldBy (rec) {
6853
6972
  const claim = this.claimFor(rec.path)
6854
6973
  return claim && !this.ownClaim(claim) ? claim : null
6855
6974
  }
6856
6975
 
6857
- mergeHeldCheck (rec) {
6858
- const claim = this.mergeHeldBy(rec)
6859
- if (claim) throw new Error(`${rec.path} is claimed by ${claim.by}${claim.note ? ` (${claim.note})` : ''}. Ask ${claim.by} for it to merge the two, or keep ${claim.by}'s version.`)
6860
- }
6861
-
6862
6976
  /** A merge as status shows it: without its texts, with who holds the file and whether we asked for it. */
6863
6977
  mergeStatus ({ ours, base, ...m }) {
6864
6978
  const claim = m.state === 'done' ? null : this.mergeHeldBy(m)
6865
6979
  return claim ? { ...m, heldBy: claim.by, asked: (claim.queue || []).some((r) => r.by === this.name) } : m
6866
6980
  }
6867
6981
 
6868
- /** A file being edited by hand lost its markers: that merge is settled. */
6869
- closeHandMerge (rel, text) {
6870
- const rec = this.mergeList().find((m) => m.path === rel && m.state === 'editing')
6871
- if (!rec || hasMarkers(text)) return
6872
- updateMerge(this.bdoc, this.merges, rec.id, { state: 'done', how: 'hand', resolvedBy: this.name }, LOCAL)
6873
- this.log(`✅ ${rel}: merged by hand`)
6874
- this.releaseMergeClaim(rel).catch(() => {})
6875
- }
6876
-
6877
- /** What a coding tool is asked to do for Send to…: names the three files, the path and the merge id. */
6878
- mergePromptFor (rec) {
6879
- const dir = path.relative(this.root, this.mergeDir(rec.id)).split(path.sep).join('/')
6880
- const other = rec.others[0] || 'someone in the session'
6881
- return `Merge conflict in \`${rec.path}\` (Quilt merge ${rec.id}).
6882
-
6883
- ${rec.by} changed this file while away from the session; meanwhile ${other} changed it in the session. Quilt could not combine the two on its own. Please merge them:
6884
-
6885
- - \`${dir}/base\`: the version both started from
6886
- - \`${dir}/ours\`: ${rec.by}'s version (offline)${rec.oursDeleted ? ' — deleted' : ''}
6887
- - \`${dir}/theirs\`: the session's version (${other})${rec.theirsHash === null ? ' — deleted' : ''}
6888
- - \`${rec.path}\`: currently the session's version
6889
-
6890
- Write the merged result to \`${rec.path}\`, keeping every change from both sides and changing no behaviour. If the two really cannot both be true, say so and ask ${rec.by} and ${other} in the chat (quilt_message) rather than picking one.
6891
- When the file is right, call the \`quilt_resolve_merge\` tool with id \`${rec.id}\` and how \`agent\` (or tell the person to click Resolved in Quilt).
6892
- `
6893
- }
6894
-
6895
- /** Writes the three versions and PROMPT.md for a tool to work from; returns the prompt. */
6896
- prepareMergeSend (id) {
6897
- const rec = this.mergeList().find((m) => m.id === id)
6898
- if (!rec) throw new Error('no such merge')
6899
- if (rec.state === 'editing') throw new Error(STILL_MARKED)
6900
- // The prompt asks a tool to write rec.path: never one the session doesn't sync.
6901
- if (!this.syncable(rec.path)) throw new Error(`${rec.path} is not synced in this session, so Quilt will not send it`)
6902
- this.mergeHeldCheck(rec)
6903
- const dir = this.mergeDir(id)
6904
- fs.mkdirSync(dir, { recursive: true })
6905
- const { ours, base, theirs } = this.mergeTexts(rec)
6906
- // base and ours never change; theirs is rewritten, as the session may have moved on.
6907
- const put = (name, text) => { if (text !== null && !fs.existsSync(path.join(dir, name))) fs.writeFileSync(path.join(dir, name), text) }
6908
- put('base', base)
6909
- put('ours', ours)
6910
- if (theirs !== null) fs.writeFileSync(path.join(dir, 'theirs'), theirs)
6911
- const prompt = this.mergePromptFor(rec)
6912
- fs.writeFileSync(path.join(dir, 'PROMPT.md'), prompt)
6913
- return { prompt, dir }
6914
- }
6915
-
6916
6982
  // ------------------------------------------------------------ AI feed --
6917
6983
 
6918
6984
  /**
@@ -7131,6 +7197,7 @@ When the file is right, call the \`quilt_resolve_merge\` tool with id \`${rec.id
7131
7197
  const blob = this.blobs.get(p)
7132
7198
  out.push({ path: p, binary: !!blob && !this.files.has(p), edited: edited.get(p) || null, claim: claimFor(p), github: fromGithub(p) })
7133
7199
  }
7200
+ out.push(...this.chatFileEntries())
7134
7201
  out.sort((a, b) => a.path.localeCompare(b.path))
7135
7202
  return {
7136
7203
  files: out,
@@ -7139,10 +7206,28 @@ When the file is right, call the \`quilt_resolve_merge\` tool with id \`${rec.id
7139
7206
  }
7140
7207
  }
7141
7208
 
7142
- /** Contents of one shared file, straight from the shared doc (never from disk). */
7209
+ /** The chat files folder, for the file tree: each file I can see that is saved there, with who sent it. */
7210
+ chatFileEntries () {
7211
+ const out = []
7212
+ for (const m of this.chat.toArray()) {
7213
+ try {
7214
+ const p = m && m.file && this.canSee(m) ? this.chatFileSaved(m) : null
7215
+ if (p) out.push({ path: `${CHAT_FILES}/${path.basename(p)}`, chat: { id: m.id, by: m.by, ts: m.ts, ...(m.to ? { to: m.to } : {}) } })
7216
+ } catch {}
7217
+ }
7218
+ return out
7219
+ }
7220
+
7221
+ /** Contents of one shared file, straight from the shared doc (never from disk); a chat file from the chat files folder. */
7143
7222
  readShared (rel) {
7144
7223
  rel = String(rel || '').replace(/^\.\//, '')
7145
7224
  if (!isSafeRelPath(rel)) return null
7225
+ if (rel.startsWith(`${CHAT_FILES}/`)) {
7226
+ const e = this.chatFileEntries().find((x) => x.path === rel)
7227
+ if (!e) return null
7228
+ const buf = fs.readFileSync(path.join(this.root, rel))
7229
+ return buf.length <= MAX_TEXT_BYTES && !looksBinary(buf) ? { path: rel, text: buf.toString('utf8'), chat: e.chat } : { path: rel, binary: true, size: buf.length, chat: e.chat }
7230
+ }
7146
7231
  const t = this.files.get(rel)
7147
7232
  if (t) return { path: rel, text: t.toString() }
7148
7233
  const b = this.blobs.get(rel)
@@ -7275,7 +7360,7 @@ When the file is right, call the \`quilt_resolve_merge\` tool with id \`${rec.id
7275
7360
  }
7276
7361
  this.stopped = true
7277
7362
  clearInterval(this.retryTimer)
7278
- if (this.watcher) await this.watcher.close()
7363
+ if (this.watcher) await closeWatcher(this.watcher)
7279
7364
  for (const t of this.rechecks.values()) clearTimeout(t)
7280
7365
  this.rechecks.clear()
7281
7366
  clearInterval(this.reconcileTimer)