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
package/src/session.js CHANGED
@@ -2,6 +2,11 @@
2
2
  // back. Any tool that edits files on disk (Claude Code, Cursor, Codex, vim...)
3
3
  // participates automatically; concurrent edits are merged character by
4
4
  // character by the CRDT.
5
+ import { describeQuiltPlan } from './session-quilt.js'
6
+ import { SquareFollower, relayReader, gitAsideOf, setGitAside, putGitBack, unsentPaths, GIT_ASIDE, squareFor } from './session-follow.js'
7
+ import { appSeamUrl } from './seam-url.js'
8
+ import { pushRefusal } from './session-seam.js'
9
+ import { quiltClient } from '@heyquilt/seam/lib/quilts/client.js'
5
10
  import fs from 'node:fs'
6
11
  import path from 'node:path'
7
12
  import crypto from 'node:crypto'
@@ -10,7 +15,7 @@ import { execFile, execFileSync } from 'node:child_process'
10
15
  import { EventEmitter } from 'node:events'
11
16
  import * as Y from 'yjs'
12
17
  import { watch } from 'chokidar'
13
- import { Connection, PUSH_REQUEST_TIMEOUT_MS } from './connection.js'
18
+ import { Connection, PUSH_REQUEST_TIMEOUT_MS, QUILT_MOVE_TIMEOUT_MS } from './connection.js'
14
19
  import { loadIdentity } from './identity.js'
15
20
  import { writePrivateJson } from './private-file.js'
16
21
  import { MAX_SHARED_FILE_BYTES, FEATURES } from './protocol.js'
@@ -25,7 +30,12 @@ import { migrateDir } from './legacy.js'
25
30
  import { withComments, addComment as putComment } from './task-comments.js'
26
31
  import { readTasks, addTask as putTask, updateTask as patchTask, deleteTask as dropTask, planAutoTask, nextTask, pickupMode, columnName } from './tasks.js'
27
32
  import { getSettings } from './settings.js'
28
- import { HistoryLog, queryHistory, parseSince, currentTask, blameChange, lineDiff } from './history.js'
33
+ import { HistoryLog, queryHistory, parseSince, currentTask, blameChange, lineDiff, legacyTail } from './history.js'
34
+ import { linkedHistory, mergeLinked } from './quilt-history.js'
35
+ import { readAccount } from './account.js'
36
+ import { seamAccess, sessionCredential, isAgentKey } from './seam-access.js'
37
+ import { startAsQuilt } from './quilt-default.js'
38
+ import { releaseInSeam } from './seam-claims.js'
29
39
  import { historyMarks, awayChanges, mergeCatchUp, emptyCatchUp } from './catchup.js'
30
40
  import { Inbox, mentionsMe, describeEvent } from './inbox.js'
31
41
  import { projectRules, renderRules, gatherPlan, planKey } from './project-rules.js'
@@ -46,7 +56,7 @@ import { merge3, mergeAuto, hasMarkers } from './merge3.js'
46
56
  import { sameEditClient, asClient } from './same-edit.js'
47
57
  import { openMerge, updateMerge, readMerges, pruneMerges, cleanName } from './merges.js'
48
58
  import { ensureQuiltIgnored } from './gitignore.js'
49
- 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, committedAs, hasFilesUnder, repoBranches, remoteHost, backWithGit, installGitHelpers, setPersonGit, hasCommit, pushLocal, bundleOf, adoptPublished, fetchSignedIn, movedRemoteUrl, commitTimes, pushedBase, changedSince } from './gitstate.js'
59
+ 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, committedAs, hasFilesUnder, repoBranches, remoteHost, backWithGit, installGitHelpers, setPersonGit, hasCommit, pushLocal, bundleOf, adoptPublished, fetchSignedIn, movedRemoteUrl, commitTimes, pushedBase, changedSince, ownSinceFork, linksAt } from './gitstate.js'
50
60
  import { planCatchUp, catchUpAdvice, publishedOf, notePublished } from './upstream.js'
51
61
  import { agentPrefix, noreply, slug, gitForEveryoneWords } from './github-commit.js'
52
62
  import { parseRepo, upstreamRef } from './relay-upstream.js'
@@ -57,13 +67,12 @@ import { cleanTyping, TYPING_MS, AGENT_TYPING_MS } from './ui/chat.js'
57
67
  import { DEFAULT_KEY } from './branchdocs.js'
58
68
  import { boxEntry } from './boxes.js'
59
69
  import { cleanVoice, newCallId, voiceView, inVoice, transcribeSetting, setTranscribe, addLine, readLines, MAX_IN_CALL, CALL_FULL } from './voice.js'
60
- import { toolLabel } from './agents/common.js'
70
+ import { toolInUse } from './agents/common.js'
71
+ import { squareShort } from '@heyquilt/seam/lib/quilts/errors.js'
61
72
 
62
73
  export { applyTextDiff }
63
74
 
64
75
  const LOCAL = Symbol('local')
65
- // The tools a person can be shown as using (a guessed one gives way to the AI that connects).
66
- const KNOWN_TOOLS = new Set(['Claude Code', 'Cursor', 'Codex', 'xAI', 'Windsurf', 'Zed'])
67
76
  const EMPTY_TREE = '4b825dc642cb6eb9a060e54bf8d69288fbee4904' // git's empty tree, the base of a branch pushed from nothing
68
77
 
69
78
  /**
@@ -80,6 +89,8 @@ function defaultCli () {
80
89
  const CLASH = Symbol('clash') // clash tasks this Quilt writes (quiet for its own inbox until settleClash)
81
90
  const CLASH_REASSIGN_MS = 5 * 60 * 1000 // a clash task's assignee gone this long: the next member takes it
82
91
  const CLASH_WAIT_MS = 30 * 1000 // no clash task from the first in line this long: the next one writes it
92
+ const FILE_LOG_MS = 30 * 1000 // a file's History in the app reads the quilt's log at most this often (fileHistory)
93
+ const HISTORY_SEAM_OFF_MS = 60 * 1000 // quilt_history reads through the relay this long after Seam would not let this member in
83
94
  const CLASH_IDLE_MS = 15 * 60 * 1000 // a clash assignee with no progress this long is handed on
84
95
  const CLASH_SETTLE_MS = 2000 // a clash task this member wrote is told to its AI after this long, if it survived a race
85
96
  const CLASH_KEEP_MS = 3 * 24 * 60 * 60 * 1000 // a finished clash record is kept this long
@@ -149,9 +160,36 @@ const SETTLE_BOXES_MS = 1500 // after an edit to one of our boxed files: does gi
149
160
  const RELAY_ASK_EVERY_MS = 5 * 1000 // a folder waiting for the relay asks it again this often (server.js SYNC_EVERY_GIT_MS)
150
161
  const INDEX_WATCH_MS = 8000 // after a bring-in, how long the index is checked against what another git may write back
151
162
 
163
+ // The owner's app renews the room's read token this long before it ends, looks this often, and tries again after this (renewReadToken).
164
+ const READ_RENEW_BEFORE_MS = 30 * 86400e3
165
+ const READ_RENEW_CHECK_MS = 6 * 3600e3
166
+ const READ_RENEW_RETRY_MS = 3600e3
167
+
168
+ /** What a move says of large files in the session's storage that git does not have (server.js quiltSwitchNow): the folders put them in. */
169
+ const largeLine = (list) => `Large files in the session's storage that git does not have are not in the squares yet (the relay can't read them); each folder that has them puts them in its square when it follows it, with a Seam sign-in on that computer: ${list.slice(0, 8).map((x) => `${x.path} (${x.branch}${x.size ? `, ${Math.max(1, Math.round(x.size / 1048576))} MB` : ''})`).join(', ')}${list.length > 8 ? `, and ${list.length - 8} more` : ''}.`
170
+
171
+ /** What every member is told while the session moves (server.js withMigrationLease `moving`): what to expect, in plain words. */
172
+ export const movingLine = (m) => m && m.kind === 'back'
173
+ ? 'The session\'s code is moving back from Seam to its git branches. Keep Quilt open: it can take a few minutes, and your edits are kept; your folder takes git back when it is done.'
174
+ : 'The session\'s code is moving into Seam, where it will be committed and merged; the session stays in Quilt. Keep Quilt open: it can take a few minutes on a large session, and your edits are kept; your folder then follows its square by itself.'
175
+
176
+
177
+ /** The move's word on people Seam would not make writers of a square: their AIs can't commit there until they are. */
178
+ function notSharedLines (list) {
179
+ if (!(list || []).length) return []
180
+ const by = new Map()
181
+ for (const x of list) { const k = `${x.member || 'everyone'} (${x.why})`; by.set(k, [...(by.get(k) || []), squareShort(x.square)]) }
182
+ const each = [...by].slice(0, 6).map(([who, sqs]) => `${who} in ${sqs.join(', ')}`)
183
+ return [`Not every person could be made a writer of every square, so their AIs can't commit there yet: ${each.join('; ')}${by.size > 6 ? '; …' : ''}. Add them to the quilt (seam quilts add <member> --level write) and share the square (seam_share).`]
184
+ }
185
+ /** What a move says when it made the room's read token again (server.js freshReadToken). */
186
+ const readTokenLine = (why) => `The session's read token for its quilt was ${why === 'missing' ? 'missing' : why === 'expiring' ? 'about to end' : 'no longer taken by Seam'}, so a new one was made: every folder reads its square with it.`
187
+
152
188
  export class Session extends EventEmitter {
153
- constructor ({ dir, server, room, secret, key = '', viewSecret = '', name, tool = 'unknown', toolGuessed = false, color = null, prefer = 'remote', kind = 'human', shareAgent = true, summarize = null, identity = null, passes = null, startName = '', startAgentCommits = '', workspace = '', autoClaimQuietMs = AUTO_CLAIM_QUIET_MS, handoffGraceMs = HANDOFF_GRACE_MS, aiTasks = null, pushLocalCommits = null, webhookTransport = null, pullWaitMs = PULL_WAIT_MS, bringInUpstream = true, clashReassignMs = CLASH_REASSIGN_MS, clashWaitMs = CLASH_WAIT_MS, clashSettleMs = CLASH_SETTLE_MS, clashIdleMs = CLASH_IDLE_MS, catcherWaitMs = CATCHER_WAIT_MS, gitBacking = true, gitRemote = null, followsRelay = true, turns = false, features = null, cli = defaultCli() }) {
189
+ constructor ({ dir, server, room, secret, key = '', viewSecret = '', name, tool = 'unknown', color = null, prefer = 'remote', kind = 'human', shareAgent = true, summarize = null, identity = null, passes = null, startName = '', startAgentCommits = '', newSession = false, quiltDefault = null, workspace = '', autoClaimQuietMs = AUTO_CLAIM_QUIET_MS, handoffGraceMs = HANDOFF_GRACE_MS, aiTasks = null, pushLocalCommits = null, webhookTransport = null, pullWaitMs = PULL_WAIT_MS, bringInUpstream = true, clashReassignMs = CLASH_REASSIGN_MS, clashWaitMs = CLASH_WAIT_MS, clashSettleMs = CLASH_SETTLE_MS, clashIdleMs = CLASH_IDLE_MS, catcherWaitMs = CATCHER_WAIT_MS, gitBacking = true, gitRemote = null, followsRelay = true, turns = false, features = null, cli = defaultCli(), apiAuth = null }) {
154
190
  super()
191
+ // The Quilt sign-in this session runs as, for the accounts API and Seam (seam-access.js): its person's, or its agent's key.
192
+ this.apiAuth = apiAuth
155
193
  // An agent that works in turns (agent-join.js keptInProject): the relay shows it away between them.
156
194
  this.turns = turns
157
195
  this.pullWaitMs = pullWaitMs
@@ -179,8 +217,6 @@ export class Session extends EventEmitter {
179
217
  // Signs in to a relay that requires it (see pass-source.js), with passes for this room.
180
218
  this.passes = passes && passes.forRoom ? passes.forRoom(room) : passes
181
219
  this.tool = tool
182
- // Nobody picked `tool`, Quilt guessed it (tool-guess.js): the first AI that connects corrects it.
183
- this.toolGuessed = toolGuessed
184
220
  this.color = color
185
221
  this.prefer = prefer
186
222
  this.stateDir = migrateDir(this.root)
@@ -247,6 +283,9 @@ export class Session extends EventEmitter {
247
283
  this.relayProblem = null // why the relay can't be reached, when we know (setRelayProblem)
248
284
  this.agentPrompts = new Map() // conv -> latest prompt line, so an edit can be titled after the question that started it
249
285
  this.merging = new Set() // paths held out of normal sync until their offline merge has run
286
+ // At layout 3 the folder follows its branch's square in place (session-follow.js): { square, since }, and
287
+ // while it does, its branch-document sync and git-mode upstream work are held (enterSquare).
288
+ this.inSquare = null
250
289
  this.catchUp = null // "while you were away" (catchup.js), until the person dismisses it
251
290
  this.awayBackups = null // while joining: copies of ours kept in .quilt/conflicts, for the catch-up
252
291
  this.work = null // { state: 'working'|'done', note, ts }: what an agent says it's doing
@@ -324,6 +363,10 @@ export class Session extends EventEmitter {
324
363
  this.startAgentCommits = startAgentCommits // the person's default for what agents may commit, set on a new room the same way
325
364
  this.workspace = workspace // the workspace this session is in (the app knows); told to the relay as owner, so webhook events can say so
326
365
  this.startNameSent = false
366
+ // A session this app just started: with QUILT_SOURCE_DEFAULT on the API it becomes a quilt from the start (quilt-default.js).
367
+ this.newSession = !!newSession
368
+ this.quiltDefault = quiltDefault // tests: replaces startAsQuilt's options
369
+ this.startQuiltTried = false
327
370
  this.logs = [] // the last 200 log lines (for status and tests)
328
371
  // Git awareness (gitstate.js): a git operation in this folder is recognised, not broadcast as edits.
329
372
  this.git = null // { key, branch, sha } this folder was on when the session started (null: not a repo)
@@ -462,8 +505,10 @@ export class Session extends EventEmitter {
462
505
  this.gone = doc.getMap('gone')
463
506
  this.fileBases = doc.getMap('fileBases')
464
507
  this.merges = doc.getMap('merges')
465
- this.tallies = doc.getMap('changes')
466
- this.history = new HistoryLog(doc, doc.getArray('history'), { origin: LOCAL })
508
+ // While the folder follows a square, who changed what is the square's (followSquareDoc).
509
+ const record = this.squareDoc || doc
510
+ this.tallies = record.getMap('changes')
511
+ this.history = new HistoryLog(record, record.getArray('history'), { origin: LOCAL })
467
512
  }
468
513
 
469
514
  /**
@@ -630,6 +675,8 @@ export class Session extends EventEmitter {
630
675
  // Back in a folder we synced before: the line is an offline edit, merged once synced. A first
631
676
  // join adds it after taking the room's files (reconcileFirstJoin), which may replace .gitignore.
632
677
  if (hadState) this.ignoreQuiltState()
678
+ // Restarted while the folder follows its square: its .git is set aside (session-follow.js) and stays so; its branch is the note's.
679
+ this.gitAside = gitDir(this.root) ? null : gitAsideOf(this.root)
633
680
  this.git = await headKey(this.root)
634
681
  this.gitSeen = this.git
635
682
  // A repo whose git can't be run (not on the PATH of an app started from the Dock, say) or
@@ -641,7 +688,7 @@ export class Session extends EventEmitter {
641
688
  const resumed = hadState && this.resumeHold()
642
689
  // The branch document this folder syncs: the branch it syncs in git (still the one it was paused off, until
643
690
  // the folder follows git), ∅ (the room's default) without git.
644
- this.branch = this.git ? this.git.key : (gitDir(this.root) && headRef(this.root)) || DEFAULT_KEY
691
+ this.branch = this.git ? this.git.key : (gitDir(this.root) && headRef(this.root)) || (this.gitAside && this.gitAside.branch) || DEFAULT_KEY
645
692
  // The form saved is the saved branch's; another branch's is the relay's to say when it is joined.
646
693
  if (this.savedBranch !== this.branch) { this.branchForm = null; this.diffBase = null }
647
694
  const diffJoin = await this.diffJoinFields()
@@ -681,6 +728,29 @@ export class Session extends EventEmitter {
681
728
  this.conn.on('access', (a) => this.setAccess(a))
682
729
  this.conn.on('members', (m) => this.setMembers(m))
683
730
  this.conn.on('branches', (list) => this.setBranches(list))
731
+ // Seam (Seam's plans/quilt-source-control/16): the relay says when the session is a quilt, and which.
732
+ this.conn.on('quilt', (q) => {
733
+ this.quilt = q
734
+ this.emit('quilt', q)
735
+ // At layout 3 this folder follows its branch's square by itself (session-follow.js).
736
+ if (!this.squareFollower) this.squareFollower = new SquareFollower(this)
737
+ this.squareFollower.onQuilt(q).catch(() => {})
738
+ this.renewReadToken().catch(() => {})
739
+ })
740
+ // A write of this folder's reached a read-only branch document at layout 3: it was not kept (server.js refusedReadOnly).
741
+ this.conn.on('read-only', (r) => this.noteReadOnly(r))
742
+ this.conn.on('quilt-shadow', (q) => { this.quiltShadow = q; this.emit('quilt-shadow', q) })
743
+ this.conn.on('importing', (x) => { this.quiltImporting = x; this.scheduleStatusWrite(); this.emit('importing', x) })
744
+ // The forced date (16 flag 9, force-date.js): live sync stopped here, or the owner's open makes the session a quilt.
745
+ this.conn.on('force', (f) => this.onForce(f))
746
+ // A move of the session (into Seam, or back) runs on the relay: said in the log, the app and quilt_status while it lasts.
747
+ this.conn.on('moving', (m) => {
748
+ const was = this.moving
749
+ this.moving = m
750
+ if (m && !was) this.log(`🧵 ${movingLine(m)}`)
751
+ else if (!m && was) this.log(`🧵 the move of the session's code ${was.kind === 'back' ? 'back to its git branches' : 'into Seam'} is over.`)
752
+ this.scheduleStatusWrite()
753
+ })
684
754
  // GitHub moved this folder's branch: fetch and bring it in now, not at the next minute's look.
685
755
  this.conn.on('pushed', (p) => {
686
756
  if (!this.git || !this.git.branch || p.branch !== this.branchKey() || this.stopped) return
@@ -712,7 +782,9 @@ export class Session extends EventEmitter {
712
782
  const gitNow = this.diffForm && !resumed && this.git && this.git.sha ? await changedSince(this.root, this.git.sha).catch(() => null) : null
713
783
  const offline = resumed ? { entries: [], take: [], downloads: [] } : this.captureOffline(gitNow)
714
784
  this.goLive()
715
- if (offline.entries.length) this.log(`${offline.entries.length} file(s) changed while you were away; merging once the relay has synced…`)
785
+ // A folder that follows its square (its .git set aside) is compared with the branch document it last synced, which the
786
+ // square's work has moved past: its block's first look says what went in, so nothing is said here.
787
+ if (offline.entries.length && !gitAsideOf(this.root)) this.log(`${offline.entries.length} file(s) changed while you were away; merging once the relay has synced…`)
716
788
  const synced = this.conn.waitForSync()
717
789
  // An agent's folder synced before folders were git-backed: once the relay has synced, with its
718
790
  // branch's repository known, it is made a checkout, and git is watched from then on.
@@ -777,6 +849,8 @@ export class Session extends EventEmitter {
777
849
  await this.maybeBackWithGit()
778
850
  }
779
851
  await this.startWatcher()
852
+ // git was set aside while following a square, and the session is not one at layout 3 now (or Seam is off here): it comes back.
853
+ if (this.gitAside) this.conn.waitForSync().then(() => { if (!this.inSquare && !this.stopped) return this.gitBack() }).catch(() => {})
780
854
  return this
781
855
  }
782
856
 
@@ -790,7 +864,7 @@ export class Session extends EventEmitter {
790
864
  */
791
865
  async maybeBackWithGit ({ late = false } = {}) {
792
866
  // A person's folder too, while git is on for everyone here and a connection covers the repository (git for everyone): with GitHub as origin.
793
- if (!this.gitBacking || (this.kind !== 'agent' && !this.personGitOn()) || this.git || gitDir(this.root) || this.stopped || this.backing) return false
867
+ if (!this.gitBacking || (this.kind !== 'agent' && !this.personGitOn()) || this.git || gitDir(this.root) || this.gitAside || this.inSquare || this.stopped || this.backing) return false
794
868
  // The relay's record of the branch (its repository and the commit the session is at) comes with its
795
869
  // branch list; when it isn't here yet, setBranches tries again as it arrives (no wait at start).
796
870
  const relay = this.relayEntryFor(this.branch)
@@ -984,6 +1058,9 @@ export class Session extends EventEmitter {
984
1058
  */
985
1059
  async pushCheck ({ remote = 'origin', refs = [] } = {}) {
986
1060
  const list = (Array.isArray(refs) ? refs : []).filter((r) => r && typeof r.remoteRef === 'string')
1061
+ // A session that is a quilt lands work through Seam, and GitHub takes only Seam's export: an
1062
+ // agent's push goes nowhere, and a person's to the session's repository is told the Seam way.
1063
+ if (this.quilt && this.quilt.id && (this.kind === 'agent' || await this.isSessionRemote(remote))) return { ok: false, why: pushRefusal(this.quilt.slug || this.quilt.id) }
987
1064
  if (this.kind !== 'agent') return this.personPushCheck({ remote })
988
1065
  const policy = (this.access && this.access.agentCommits) || 'branches'
989
1066
  if (policy === 'off') return { ok: false, why: `${gitForEveryoneWords({ policy: 'off', by: this.offBy() })} Ask a person for the commit with quilt_request_commit.` }
@@ -1035,6 +1112,16 @@ export class Session extends EventEmitter {
1035
1112
  return { ok: false, why: `${gitForEveryoneWords({ policy: 'off', by })} ${by ? 'Its admins turn it back on in its settings.' : 'The session owner turns it back on in the session\'s settings (under Version control, This session\'s source control).'}` }
1036
1113
  }
1037
1114
 
1115
+ /** Whether `remote` (a name or URL) is the session's GitHub repository: its own folder's origin, or one its branches follow. */
1116
+ async isSessionRemote (remote = 'origin') {
1117
+ const name = String(remote || 'origin')
1118
+ const url = /^[a-z][a-z0-9+.-]*:\/\/|^[^/\s]+@[^/\s]+:/i.test(name) ? name : await this.gitOut(['config', '--get', `remote.${name}.url`])
1119
+ if (url && url === this.relayGitUrl()) return true
1120
+ const repo = url ? parseRepo(url) : null
1121
+ if (!repo || !repo.github) return false
1122
+ return name === 'origin' || this.sessionRepos().has(`github.com/${repo.owner}/${repo.name}`.toLowerCase())
1123
+ }
1124
+
1038
1125
  /** The rule over the session that turned git off ({ by, name }), or null when the owner did (or it is on). */
1039
1126
  offBy () {
1040
1127
  const by = this.access && this.access.agentCommitsBy
@@ -1213,6 +1300,9 @@ export class Session extends EventEmitter {
1213
1300
  if (was && was.agentCommits !== a.agentCommits) this.gitToken = null
1214
1301
  this.syncPersonGitSoon() // git for everyone: a person's helper follows git on or off
1215
1302
  if (a.state === 'approved' && a.owner) this.sendStartName()
1303
+ if (this.force && this.force.migrate && a.state === 'approved' && a.owner) this.onForce(this.force)
1304
+ // A new session becomes a quilt as its owner (in a room with no owner, the relay lets any editor: quiltPlanRefusal).
1305
+ if (a.state === 'approved' && a.role !== 'viewer' && (a.owner || !a.controlled)) this.startQuiltOnce()
1216
1306
  if (a.state === 'approved' && this.webhook && !this.webhook.local) this.migrateWebhook().catch(() => {}) // the relay sends it from now on (or again, if it lost it)
1217
1307
  if (a.state === 'pending' && (!was || was.state !== 'pending')) this.log(`⏳ waiting for the session owner to let you in (you were invited to ${a.invitedAs === 'viewer' ? 'view' : 'edit'})`)
1218
1308
  if (a.state === 'approved' && was && (was.role !== a.role || String(was.scopes) !== String(a.scopes) || String(was.scopesExcept || []) !== String(a.scopesExcept || []))) {
@@ -1313,6 +1403,19 @@ export class Session extends EventEmitter {
1313
1403
  this.rename(this.startName).catch((err) => this.log(`couldn't name the session: ${err.message}`))
1314
1404
  }
1315
1405
 
1406
+ /** A new session, as its owner, starts as a quilt when the API says new sessions do (flags order 8); once, in the background. */
1407
+ startQuiltOnce () {
1408
+ if (!this.newSession || this.startQuiltTried) return
1409
+ this.startQuiltTried = true
1410
+ // Made as a person: an agent's own key only reads on Seam, so an agent's new session is made as the person signed in on this computer.
1411
+ const credential = async () => {
1412
+ const c = await sessionCredential({ apiAuth: this.apiAuth, account: readAccount })()
1413
+ if (c && isAgentKey(c) && !process.env.SEAM_TOKEN) { const acc = readAccount(); if (acc && acc.token) return acc.token }
1414
+ return c
1415
+ }
1416
+ this.startingQuilt = startAsQuilt(this, { credential, ...(this.quiltDefault || {}) })
1417
+ }
1418
+
1316
1419
  /** As the owner, tells the relay which workspace the session is in (once per run; again when it moves). */
1317
1420
  sendWorkspace () {
1318
1421
  if (!this.workspace || this.workspaceSent === this.workspace || !this.isOwner) return
@@ -1690,9 +1793,12 @@ export class Session extends EventEmitter {
1690
1793
 
1691
1794
  /** Runs once the relay has synced: merges every captured path against the session's version. */
1692
1795
  async mergeOffline ({ entries, take, downloads }) {
1796
+ if (this.syncStopped) { for (const e of entries) this.merging.delete(e.rel); return null }
1693
1797
  if (this.stopped) { for (const e of entries) this.merging.delete(e.rel); return null } // the next start captures them again
1694
1798
  // The saved copy was of an older document of this branch: the folder joined fresh instead (rejoinFresh).
1695
1799
  if (this.staleRejoin || this.rejoinedFresh) { for (const e of entries) this.merging.delete(e.rel); return null }
1800
+ // The folder follows its square: the block takes the edits made while away in (they differ from lastKnown, editedSinceSync).
1801
+ if (this.inSquare) { for (const e of entries) this.merging.delete(e.rel); return null }
1696
1802
  const counts = { pushed: 0, merged: 0, ai: 0, conflict: 0 }
1697
1803
  const paths = { merged: [], conflict: [] } // for the catch-up
1698
1804
  const queue = entries.slice()
@@ -2009,6 +2115,7 @@ export class Session extends EventEmitter {
2009
2115
 
2010
2116
  /** `gitNow`: for a diff branch, what the folder has that differs from HEAD (gitstate.js changedSince), or null. */
2011
2117
  reconcileFirstJoin (gitNow = null) {
2118
+ if (this.inSquare || this.syncStopped) return { pulled: 0 } // the square's block brings the folder in (session-follow.js)
2012
2119
  const onDisk = new Set(walk(this.root, this.ig))
2013
2120
  let pulled = 0; let pushed = 0; let backedUp = 0
2014
2121
  const backupDir = path.join(this.stateDir, 'conflicts', new Date().toISOString().replace(/[:.]/g, '-'))
@@ -2128,6 +2235,10 @@ export class Session extends EventEmitter {
2128
2235
  // ------------------------------------------------------- local -> shared --
2129
2236
 
2130
2237
  queue (rel) {
2238
+ // The folder follows its square: the block sees every change; git moving HEAD may take it to another branch's square.
2239
+ // The app hears of each change on disk all the same (its Files list and an open file read the folder then: squareFiles).
2240
+ if (this.syncStopped) return // live sync stopped at the forced date (onForce): the folder is plain git
2241
+ if (this.inSquare) { if (rel === HEAD_CHANGED) this.squareHeadMoved(); else this.emit('file-changed', { path: rel }); return }
2131
2242
  this.pending.add(rel)
2132
2243
  if (!this.flushTimer) this.flushTimer = setTimeout(() => this.flushPending(), FLUSH_MS)
2133
2244
  }
@@ -2933,7 +3044,7 @@ export class Session extends EventEmitter {
2933
3044
 
2934
3045
  /** (Re)arms the settle timer: the hold ends SETTLE_MS after the last file or git event. */
2935
3046
  settleSoon () {
2936
- if (!this.hold || this.hold.kind === 'switching' || this.stopped || this.holdAwaitsSync) return
3047
+ if (!this.hold || this.hold.kind === 'switching' || this.stopped || this.holdAwaitsSync || this.inSquare) return
2937
3048
  clearTimeout(this.settleTimer)
2938
3049
  const gen = ++this.settleGen
2939
3050
  this.settleTimer = setTimeout(() => {
@@ -3303,7 +3414,7 @@ export class Session extends EventEmitter {
3303
3414
 
3304
3415
  /** Looks again shortly (a branch moved, a fetch landed); several asks in a row make one look. */
3305
3416
  checkUpstreamSoon ({ fetch = false, delay = SETTLE_MS } = {}) {
3306
- if (!this.git || !this.git.branch || this.stopped) return
3417
+ if (!this.git || !this.git.branch || this.stopped || this.inSquare || this.syncStopped) return
3307
3418
  this.upstreamFetch = this.upstreamFetch || fetch
3308
3419
  if (this.upstreamTimer) return // one look is already on its way: it takes this ask too
3309
3420
  this.upstreamTimer = setTimeout(() => {
@@ -3332,7 +3443,7 @@ export class Session extends EventEmitter {
3332
3443
  * fetching first when asked, and catches up when the branch is behind. Returns the state.
3333
3444
  */
3334
3445
  async checkUpstream ({ fetch = false, now = false } = {}) {
3335
- if (!this.git || !this.git.branch || this.stopped) return this.upstream
3446
+ if (!this.git || !this.git.branch || this.stopped || this.inSquare || this.syncStopped) return this.upstream
3336
3447
  if (!this.quietForUpstream()) {
3337
3448
  this.upstreamLater(fetch) // busy for a moment: asked again once it's quiet
3338
3449
  return this.upstream
@@ -3569,7 +3680,17 @@ export class Session extends EventEmitter {
3569
3680
  for (const rel of paths) {
3570
3681
  if (this.syncable(rel) && this.writeRefusal(rel)) { state({ waiting: `you may not change ${rel} in this session` }); return }
3571
3682
  }
3572
- const diskKey = (rel) => this.diskKey(rel)
3683
+ // Links are git's (the session never syncs them): one the folder has as git has it at either end is that version, and one the commits bring is written as a link.
3684
+ const [linksFrom, linksTo] = [await linksAt(this.root, from, paths), await linksAt(this.root, to, paths)]
3685
+ if (!linksFrom || !linksTo) return
3686
+ const linkKey = (rel) => {
3687
+ let target
3688
+ try { target = fs.readlinkSync(resolveInside(this.root, rel)) } catch { return undefined }
3689
+ if (linksTo.has(rel) && theirs.get(rel) === target) return target
3690
+ if (linksFrom.has(rel) && base.get(rel) === target) return target
3691
+ return undefined
3692
+ }
3693
+ const diskKey = (rel) => { const k = this.diskKey(rel); return k === undefined ? linkKey(rel) : k }
3573
3694
  // The session and this folder must agree first: a change still on its way waits for the next look.
3574
3695
  // Following, that is every file the commits changed: the branch moves only once the session's versions are
3575
3696
  // on disk, or a commit made meanwhile (git commit -a) would take a file still on its way for a deletion.
@@ -3625,7 +3746,7 @@ export class Session extends EventEmitter {
3625
3746
  const data = key.startsWith('bin:') ? await blobAt(this.root, to, rel) : key
3626
3747
  if (data === null) throw new Error('git could not read it')
3627
3748
  fs.mkdirSync(path.dirname(abs), { recursive: true })
3628
- this.writeFile(rel, abs, data)
3749
+ if (linksTo.has(rel)) { fs.rmSync(abs, { force: true }); fs.symlinkSync(String(data), abs) } else this.writeFile(rel, abs, data)
3629
3750
  }
3630
3751
  written++
3631
3752
  if (this.syncable(rel)) this.ingest(rel, { pulled: true })
@@ -3686,6 +3807,514 @@ export class Session extends EventEmitter {
3686
3807
  * Asked for by an AI (quilt_sync_branch): fetch and bring commits in now, without waiting for
3687
3808
  * the next look or another member's folder. What happened: { branch, upstream, moved, busy }.
3688
3809
  */
3810
+ /**
3811
+ * What making this session a quilt would do (Seam's plans/quilt-source-control/16), read only: the
3812
+ * relay's dry run (behind QUILT_SOURCE_URL there, the owner's), as { plan, text }.
3813
+ */
3814
+ async quiltPlan () {
3815
+ if (!this.conn || !this.conn.connected) throw new Error('not connected to the relay; the dry run needs a connection')
3816
+ const r = await this.conn.branchRequest({ op: 'quilt-plan' })
3817
+ return { plan: r.plan, text: describeQuiltPlan(r.plan) }
3818
+ }
3819
+
3820
+ /**
3821
+ * Makes the session a quilt (Seam's plans/quilt-source-control/16, "Without history"): the relay does
3822
+ * it, as the owner, with `token` (their Seam sign-in: SEAM_TOKEN, else their Quilt account's).
3823
+ * What it made: { quilt, text }.
3824
+ */
3825
+ async quiltMigrate (token, { op = 'quilt-migrate' } = {}) {
3826
+ if (!this.conn || !this.conn.connected) throw new Error('not connected to the relay; making the session a quilt needs a connection')
3827
+ if (!token) throw new Error('sign in first (quilt login, or set SEAM_TOKEN): the session becomes a quilt as its owner')
3828
+ const r = await this.conn.branchRequest({ op, token }, { timeoutMs: QUILT_MOVE_TIMEOUT_MS })
3829
+ const q = r.quilt
3830
+ // A session with a git remote moves in steps (the relay's quiltMakeWithRemote): Seam importing it, then ready for the cut-over.
3831
+ if (q && (q.stage === 'importing' || q.stage === 'ready')) return { quilt: q, text: q.text }
3832
+ const lines = [`Session ${this.room || ''} is now the quilt ${q.slug}.`.replace('Session is', 'This session is')]
3833
+ if (q.history) lines.push(`It is the quilt its git remote was imported into: main is at patch ${q.patch && q.patch.no}, git's history before it.`)
3834
+ else if (q.patch) lines.push(`Patch ${q.patch.no} holds the main branch as it was.`)
3835
+ for (const s of q.squares) lines.push(`Branch ${s.from} is square ${squareShort(s.id)}.`)
3836
+ for (const b of q.based_at_tip || []) {
3837
+ lines.push(b.why === 'unfetched'
3838
+ ? `The relay never fetched ${b.from} from git, so its square starts at main's tip with the branch's files over it: its history before the move is main's, not the branch's.`
3839
+ : b.why === 'merged'
3840
+ ? `${b.from}'s commit ${String(b.wanted || '').replace(/^git:/, '').slice(0, 12)} is already merged into main, so its square starts at main's tip.`
3841
+ : `Seam has no commit for ${String(b.wanted || '').replace(/^git:/, '')}, so ${b.from}'s square starts at main's tip.`)
3842
+ }
3843
+ if (q.adoption) lines.push(`Work on the main branch that git does not have is in the square "Uncommitted work" (${squareShort(q.adoption)}).`)
3844
+ if (q.read_token) lines.push(readTokenLine(q.read_token))
3845
+ if (q.large) lines.push(largeLine(q.large))
3846
+ if (q.rebased) lines.push(`Someone's unfinished work boxed on an older main went onto main's newer copy as their change only, not their whole file: ${q.rebased.slice(0, 8).map((x) => `${x.path} (${x.branch})`).join(', ')}.`)
3847
+ if (q.claims && q.claims.left && q.claims.left.length) lines.push(`Claims not moved: ${q.claims.left.map((c) => `${c.pattern} (${c.why})`).join(', ')}.`)
3848
+ if (q.layout === 3) lines.push('Its files are in its squares now; apps without Seam are asked to update. quilt session migrate --back goes back to git.')
3849
+ lines.push(...notSharedLines(q.not_shared))
3850
+ const moved = Object.keys(q.tasks || {}).length
3851
+ if (moved) lines.push(`${moved} task${moved === 1 ? '' : 's'} with a pull request follow${moved === 1 ? 's' : ''} its square.`)
3852
+ if (q.left_out.length) lines.push(`Large files left in the session's storage, not in the quilt: ${q.left_out.join(', ')}.`)
3853
+ return { quilt: q, text: lines.join('\n') }
3854
+ }
3855
+
3856
+ /**
3857
+ * The relay's word on the forced date (16 flag 9). `stopped`: the session had a git remote and did
3858
+ * not cut over, so its live file sync stopped for good: this folder is plain git from now on (no
3859
+ * file is written or shared by Quilt, no git work of Quilt's), said once in the log, the app and
3860
+ * every quilt answer. `migrate`: a session with no git remote, past the date, opened by its owner:
3861
+ * made a quilt now, as they would with the button (once per run of the app).
3862
+ */
3863
+ onForce (f) {
3864
+ this.force = f || null
3865
+ if (f && f.stopped && !this.syncStopped) {
3866
+ this.syncStopped = { at: f.stopped.at }
3867
+ clearTimeout(this.flushTimer)
3868
+ this.flushTimer = null
3869
+ this.pending.clear()
3870
+ clearTimeout(this.upstreamTimer)
3871
+ this.upstreamTimer = null
3872
+ const why = String(f.stopped.why || 'Live sync stopped: this session\'s folder is plain git now.')
3873
+ this.log(`🧵 ${why}`)
3874
+ this.notice(why)
3875
+ this.scheduleStatusWrite()
3876
+ this.emit('force', f)
3877
+ return
3878
+ }
3879
+ if (f && f.migrate && !f.remote && !this.forceMigrateTried && this.access && this.access.owner) {
3880
+ const token = process.env.SEAM_TOKEN || (readAccount() || {}).token
3881
+ if (!token) return
3882
+ this.forceMigrateTried = true
3883
+ this.log('🧵 The date for moving sessions into Seam has passed: making this session a quilt now (its code is committed in Seam from here on; nothing is lost).')
3884
+ this.quiltMake(token).then((r) => { this.log(`🧵 ${r.text.split('\n')[0]}`); this.notice(r.text) }, (err) => this.log(`⚠️ couldn't make the session a quilt at the forced date: ${err.message}. Settings → Session → Make this a quilt tries again.`))
3885
+ }
3886
+ this.emit('force', f)
3887
+ }
3888
+
3889
+ /**
3890
+ * "Make this a quilt" (16 flag 9), the owner's button and `quilt session migrate`: the relay makes the
3891
+ * session a quilt and moves its files into its squares in one go (Room.quiltMake). { quilt, text }.
3892
+ */
3893
+ quiltMake (token) { return this.quiltMigrate(token, { op: 'quilt-make' }) }
3894
+
3895
+ /**
3896
+ * The session's files into its squares' live documents on the relay (layout 3, 16), as the
3897
+ * owner, once it is a quilt: { layout, live, adoption, text }.
3898
+ */
3899
+ async quiltSwitch (token) {
3900
+ if (!this.conn || !this.conn.connected) throw new Error('not connected to the relay; moving the session needs a connection')
3901
+ if (!token) throw new Error('sign in first (quilt login, or set SEAM_TOKEN): the session moves into its squares as its owner')
3902
+ const r = await this.conn.branchRequest({ op: 'quilt-switch', token }, { timeoutMs: QUILT_MOVE_TIMEOUT_MS })
3903
+ const lines = ['The session\'s files are in its squares now; its branches are kept, read-only.']
3904
+ for (const [b, k] of Object.entries(r.live || {})) lines.push(`${b} is in square ${squareShort(k.replace(/^quilt\//, ''))}.`)
3905
+ if (r.adoption) lines.push(`Work on the main branch since it became a quilt is in the square "Uncommitted work" (${squareShort(r.adoption)}).`)
3906
+ if (r.read_token) lines.push(readTokenLine(r.read_token))
3907
+ if (r.large) lines.push(largeLine(r.large))
3908
+ if (r.rebased) lines.push(`Someone's unfinished work boxed on an older main went onto main's newer copy as their change only, not their whole file: ${r.rebased.slice(0, 8).map((x) => `${x.path} (${x.branch})`).join(', ')}.`)
3909
+ lines.push('Apps without Seam are asked to update. quilt session migrate --back goes back to the branches.')
3910
+ lines.push(...notSharedLines(r.not_shared))
3911
+ return { ...r, text: lines.join('\n') }
3912
+ }
3913
+
3914
+ /**
3915
+ * At layout 3 (Seam's plans/quilt-source-control/16): `dir` (its own folder) follows this folder's
3916
+ * branch's square, live (session-follow.js), signed in to Seam with `token` (else as this member). Only with
3917
+ * Seam on for this app (seam-url.js) and the relay saying the session is a layout-3 quilt.
3918
+ */
3919
+ async quiltFollow (token, dir) {
3920
+ if (!this.squareFollower) this.squareFollower = new SquareFollower(this)
3921
+ // As `token`, else as this member (seamMember), the same as `quilt commit` and the Seam tools.
3922
+ const client = !appSeamUrl() ? null : token ? quiltClient({ api: appSeamUrl(), token }) : await this.seamClientNow()
3923
+ const r = await this.squareFollower.follow({ dir: dir ? path.resolve(this.root, String(dir)) : '', client })
3924
+ return { ...r, text: `${r.dir} follows the square ${squareShort(r.square)}: edits there are live for everyone on it.` }
3925
+ }
3926
+
3927
+ /**
3928
+ * The folder follows its branch's square in place (session-follow.js, at layout 3): from now on
3929
+ * its block takes every change, so the session's own sync of the folder holds: nothing goes to
3930
+ * the read-only branch document, and no git-mode upstream work runs (no fetch, fast-forward or
3931
+ * merge into uncommitted work, no boxes). Called before the block opens, so nothing slips through.
3932
+ */
3933
+ enterSquare (at) {
3934
+ if (this.inSquare && this.inSquare.square === at.square) return
3935
+ this.inSquare = { square: at.square, since: Date.now() }
3936
+ clearTimeout(this.flushTimer)
3937
+ this.flushTimer = null
3938
+ this.pending.clear()
3939
+ this.setGitAside()
3940
+ this.scheduleStatusWrite()
3941
+ }
3942
+
3943
+ /** In a quilt nobody uses git: the folder's .git goes into .quilt while it follows its square (session-follow.js), and git-mode work stops. */
3944
+ setGitAside () {
3945
+ let r
3946
+ try { r = setGitAside(this.root, { branch: this.branchKey() }) } catch (err) { this.log(`⚠️ couldn't set git aside in this folder (${err.message}); git still works here, but the session's code history is in Seam`); return }
3947
+ if (r.both) this.log('⚠️ a new .git appeared in this folder while it follows its square; the one set aside is kept in .quilt/git-before-seam')
3948
+ if (r.moved || r.already) this.gitAside = gitAsideOf(this.root)
3949
+ if (r.moved) this.log('🧵 git is set aside in this folder while it follows its square (in .quilt/git-before-seam): in a quilt, Seam keeps the history. It comes back if the session goes back to its branches.')
3950
+ if (r.worktrees) {
3951
+ const n = r.worktrees.length
3952
+ this.log(`🧵 ${n} other worktree${n === 1 ? '' : 's'} of this repository (${r.worktrees.slice(0, 5).join(', ')}${n > 5 ? `, and ${n - 5} more` : ''}) keep using git while this folder follows its square: each one's .git file points at this folder's .git in .quilt/git-before-seam meanwhile, and goes back as it was with it.`)
3953
+ }
3954
+ if (this.gitWatcher) { const w = this.gitWatcher; this.gitWatcher = null; Promise.resolve(w.close()).catch(() => {}) }
3955
+ clearInterval(this.upstreamPoll)
3956
+ this.upstreamPoll = null
3957
+ this.git = null
3958
+ }
3959
+
3960
+ /** The .git set aside comes back (before git-mode sync resumes), and git is watched again. */
3961
+ async gitBack () {
3962
+ if (!gitAsideOf(this.root)) { this.gitAside = null; return false }
3963
+ let back = false
3964
+ try { back = putGitBack(this.root) } catch (err) { this.log(`⚠️ couldn't put git back in this folder (${err.message}); it is in .quilt/git-before-seam`); return false }
3965
+ if (!back) { this.log('⚠️ this folder has a new .git, so the one set aside stays in .quilt/git-before-seam'); return false }
3966
+ this.gitAside = null
3967
+ this.git = await headKey(this.root)
3968
+ this.gitSeen = this.git
3969
+ if (this.git && !this.stopped) this.startGitWatch()
3970
+ this.log('git is back in this folder.')
3971
+ this.scheduleStatusWrite()
3972
+ return true
3973
+ }
3974
+
3975
+ /**
3976
+ * Back from the square (the session went back to layout 2): the old sync resumes. What the folder
3977
+ * holds now that differs from the branch document (the square's work, the same on every member's
3978
+ * folder) is shared as one edit from all of them (same-edit.js), then git-mode work carries on.
3979
+ * `writers` (path -> name, who last wrote it in the square: live-square.js lastWriters): only that
3980
+ * member's folder boxes a returning file. Whichever folder ingested it first used to, so a member
3981
+ * who reached the session first held another's work, and the writer's next edit reached nobody.
3982
+ */
3983
+ async leaveSquare ({ writers = null } = {}) {
3984
+ if (!this.inSquare) return
3985
+ this.unfollowSquareDoc()
3986
+ // git first: git-mode sync resumes on a folder git knows again.
3987
+ await this.gitBack()
3988
+ this.inSquare = null
3989
+ // The block's record of the folder goes too: after branches, a later switch starts from the session's sync, not from it.
3990
+ try { fs.rmSync(path.join(this.stateDir, 'block.json'), { force: true }) } catch {}
3991
+ const moved = this.squareBranch && this.squareBranch !== this.branchKey()
3992
+ this.squareBranch = null
3993
+ if (moved) this.queue(HEAD_CHANGED)
3994
+ else {
3995
+ for (const rel of new Set([...walk(this.root, this.ig), ...this.lastKnown.keys()])) {
3996
+ if (!this.syncable(rel) || this.untouchedHere(rel)) continue
3997
+ try { this.ingest(rel, { same: true, claim: !!(writers && writers.get(rel) === this.name) }) } catch (err) { this.log(`could not sync ${rel}: ${err.message}`) }
3998
+ }
3999
+ }
4000
+ this.scanDisk({ baseline: true })
4001
+ this.checkUpstreamSoon({ fetch: true })
4002
+ this.settleSoon()
4003
+ this.scheduleStatusWrite()
4004
+ }
4005
+
4006
+ /**
4007
+ * Whether the folder's copy of `rel` changed since the session last synced it (for the square's
4008
+ * first look at the folder, live-block.js adopt). A folder never synced has no edits of its own.
4009
+ */
4010
+ editedSinceSync (rel) {
4011
+ if (!this.lastKnown.size) return false
4012
+ const k = this.diskKey(rel)
4013
+ if (k === undefined) return true // a link, or too large for the session: the folder's own
4014
+ return this.lastKnown.has(rel) ? k !== this.lastKnown.get(rel) : k !== null
4015
+ }
4016
+
4017
+ /**
4018
+ * Whether `rel` was someone else's box in this folder before the move (ghost): the folder kept its own
4019
+ * copy (git's, moved on by pulls) while the session held the box's, so the square's first look puts the
4020
+ * box's change onto the folder's copy (live-block.js combineAway), from what everyone had when it began.
4021
+ */
4022
+ boxedHere (rel) { return this.boxBase.has(rel) }
4023
+
4024
+ /**
4025
+ * Whether the session held `rel` back from this folder before the move: someone else's box here (the
4026
+ * folder kept git's copy, or none for a new file), or a change still waiting for whoever held the file
4027
+ * (an open merge record: deleted or changed offline while another held it). The folder's lack of it
4028
+ * at the square's first look is then not its deletion.
4029
+ */
4030
+ heldBackHere (rel) {
4031
+ if (this.boxBase.has(rel)) return true
4032
+ try { return this.mergeList().some((m) => m && m.path === rel && m.state !== 'done') } catch { return false }
4033
+ }
4034
+
4035
+ /** Whether the session synced `rel` with this folder (the square's first look takes a file it has as git or the session left it for no edit). */
4036
+ syncedHere (rel) {
4037
+ if (!this.lastKnown.has(rel) || this.boxBase.has(rel)) return false
4038
+ // A large file in the session's storage is not in the square (the relay can't read its bytes, so the
4039
+ // move left it out and named it): the folder's copy is what puts it there, never the square's absence.
4040
+ const b = this.blobs && this.blobs.get(rel)
4041
+ return !(b && b.stored)
4042
+ }
4043
+
4044
+ /** The paths this folder's branch changed since it left the default branch, { committed, uncommitted } (the square's first look keeps those as the folder's), or null when git can't say. */
4045
+ branchOwnPaths () {
4046
+ return ownSinceFork(this.root, gitAsideOf(this.root) ? { gitDir: path.join(this.root, GIT_ASIDE) } : {}).catch(() => null)
4047
+ }
4048
+
4049
+ /**
4050
+ * The paths whose edits this folder made into its branch document but the relay does not hold
4051
+ * (session-follow.js unsentPaths): at layout 3 the read-only document refused them, so the
4052
+ * folder's copy is its own work, never the switch's older copy. Empty with nothing refused;
4053
+ * null when the relay can't say (an older relay).
4054
+ */
4055
+ async unsentPaths () {
4056
+ if (!this.bdoc || !this.conn || !this.conn.connected || this.conn.branchKey === null) return null
4057
+ const r = await this.conn.branchRequest({ op: 'state', branch: this.conn.branchKey })
4058
+ const bytes = (b64) => new Uint8Array(Buffer.from(String(b64 || ''), 'base64'))
4059
+ return unsentPaths(this.bdoc, bytes(r.sv), bytes(r.deleted))
4060
+ }
4061
+
4062
+ /**
4063
+ * The folder's block on its square is open: who changed what (the chronology, Changes) is kept in
4064
+ * the square's live document from now on. It came with the branch's at the switch and goes on from
4065
+ * square to square (session-layout3.js copyIntoLive, carryLive), so a file's History, Changes and
4066
+ * the Files tree carry on where the branch left them.
4067
+ */
4068
+ followSquareDoc (doc) {
4069
+ if (!doc || this.squareDoc === doc) return
4070
+ this.squareDoc = doc
4071
+ this.history = new HistoryLog(doc, doc.getArray('history'), { origin: LOCAL })
4072
+ this.tallies = doc.getMap('changes')
4073
+ this.scheduleStatusWrite()
4074
+ }
4075
+
4076
+ /** Back on the branch's document (the block closed). */
4077
+ unfollowSquareDoc () {
4078
+ if (!this.squareDoc) return
4079
+ this.squareDoc = null
4080
+ if (this.bdoc) { this.history = new HistoryLog(this.bdoc, this.bdoc.getArray('history'), { origin: LOCAL }); this.tallies = this.bdoc.getMap('changes') }
4081
+ this.scheduleStatusWrite()
4082
+ }
4083
+
4084
+ /**
4085
+ * Edits this folder made in its square, as its block took them in (live-block.js onEdits): on
4086
+ * record as this member's, the same as on a branch (recordActivity: the chronology and Changes in
4087
+ * the square's live document `doc`, the room's activity, this member's presence).
4088
+ */
4089
+ squareEdits (list, doc, held = null) {
4090
+ this.followSquareDoc(doc)
4091
+ for (const e of list) {
4092
+ const texts = typeof e.before === 'string' || typeof e.after === 'string' ? { before: e.before, after: e.after } : undefined
4093
+ try { this.recordActivity(e.path, e.kind, e.detail || '', texts, { activity: false }) } catch (err) { this.emit('debug', `could not record ${e.path}: ${err.message}`) }
4094
+ this.noteMyEdit(e.path)
4095
+ // Claims follow edits here too (as ingest makes them on a branch): the room's claims on this
4096
+ // branch, which the Loom stitches, quilt_before_edit answers and the file queue hang on.
4097
+ const claim = this.claimFor(e.path)
4098
+ if (e.kind !== 'deleted' && (!claim || this.autoClaims.has(e.path)) && this.aiMayBeEditing()) this.autoClaim(e.path)
4099
+ }
4100
+ // The room's activity names a file the relay finds in the square (server.js onBranch): sent once the relay holds the edit, over the block's connection.
4101
+ const push = () => { if (this.stopped) return; for (const e of list) { try { this.pushActivity(e.path, e.kind, e.detail || '') } catch {} } }
4102
+ if (held) held().then(push, push); else push()
4103
+ }
4104
+
4105
+ /**
4106
+ * The text the session last synced for `rel` (the square's first look combines an edit made while
4107
+ * away with the square's), or null: a file a diff branch leaves to git is git's, read from
4108
+ * the .git set aside.
4109
+ */
4110
+ async syncedText (rel) {
4111
+ // Someone else's box: what everyone had when it began (ghost), not the folder's own copy since.
4112
+ if (this.boxBase.has(rel)) { const b = this.boxBase.get(rel); return b && typeof b.text === 'string' ? b.text : null }
4113
+ const k = this.lastKnown.get(rel)
4114
+ if (k !== undefined) return typeof k === 'string' && !/^bin:[0-9a-f]{40}$/.test(k) ? k : null
4115
+ if (!this.followsGit(rel) || !gitAsideOf(this.root)) return null
4116
+ const buf = await blobAt(this.root, 'HEAD', rel, { gitDir: path.join(this.root, GIT_ASIDE) })
4117
+ return buf && !looksBinary(buf) ? buf.toString('utf8') : null
4118
+ }
4119
+
4120
+ /** Whether the session never syncs anything in folder `rel` (.git, .quilt, node_modules, ignored ones): the block's walk skips it. */
4121
+ ignoredDir (rel) { return !isSafeRelPath(rel) || isIgnored(this.ig, rel) || this.ig.ignores(`${rel}/`) }
4122
+
4123
+ /** The branch the folder is on for its square: git's, once git moved it while following (squareHeadMoved). */
4124
+ squareBranchKey () { return this.squareBranch || this.branchKey() }
4125
+
4126
+ /** git moved HEAD while the folder follows a square: on another branch, the folder follows that branch's square. */
4127
+ squareHeadMoved () {
4128
+ clearTimeout(this.squareHeadTimer)
4129
+ this.squareHeadTimer = setTimeout(async () => {
4130
+ const head = await headKey(this.root).catch(() => null)
4131
+ if (!head || !this.inSquare || this.stopped) return
4132
+ const key = head.key === DEFAULT_KEY ? this.branchKey() : head.key
4133
+ if (key === this.squareBranchKey()) return
4134
+ this.squareBranch = key
4135
+ if (this.squareFollower) await this.squareFollower.branchMoved(this.quilt).catch((err) => this.log(`🧵 ${err.message}`))
4136
+ }, SETTLE_MS)
4137
+ this.squareHeadTimer.unref?.()
4138
+ }
4139
+
4140
+ /** Reads of the quilt for the folder's block: through the relay, with the room's read token (session-follow.js relayReader). */
4141
+ squareReader () { return relayReader(this.conn) }
4142
+
4143
+ /**
4144
+ * This member to Seam (seam-access.js): SEAM_TOKEN, else the Quilt sign-in the session runs as
4145
+ * (its person's, or its agent's key traded for an agent token). The Seam tools (control.js), the
4146
+ * folder's block and `quilt commit` all use this one, so they act as the same member.
4147
+ */
4148
+ seamMember () {
4149
+ if (!this.seam) this.seam = seamAccess({ api: () => this.seamApiUrl ?? appSeamUrl(), credential: () => sessionCredential({ apiAuth: this.apiAuth, account: readAccount })() })
4150
+ return this.seam
4151
+ }
4152
+
4153
+ /** A Seam client as this member for the square the folder follows (seamMember), for uploads and commits; throws with why when there is none. */
4154
+ async seamClientNow () {
4155
+ if (!this.quilt || !this.quilt.id) throw new Error('This session is not a quilt.')
4156
+ const f = this.squareFollower
4157
+ const square = (f && f.here && f.here.square) || (f && f.following && f.following.square) || null
4158
+ return (await this.seamMember().client(this.quilt.id, square ? [square] : [])).client
4159
+ }
4160
+
4161
+ /** The same, or null without one (the block reads through the relay meanwhile). */
4162
+ async seamWriteClient () {
4163
+ try { return await this.seamClientNow() } catch { return null }
4164
+ }
4165
+
4166
+ /** `quilt commit`: the square's live work, committed from this folder (session-follow.js). */
4167
+ async squareCommit (message) {
4168
+ if (!this.squareFollower) throw new Error('This folder does not follow a square: the session is not a quilt at layout 3.')
4169
+ // As this member, the same as seam_commit's (a cloud AI's agent token renews itself).
4170
+ if (this.squareFollower.here && this.squareFollower.here.block) this.squareFollower.here.block.client = await this.seamClientNow()
4171
+ const r = await this.squareFollower.commit({ message })
4172
+ if (r && r.nothing) return { nothing: true, text: 'Nothing to commit: the square has no work since its last commit.' }
4173
+ const head = r && r.square && r.square.head
4174
+ return { ...r, text: `Committed to square ${squareShort(this.squareFollower.here.square)}${head ? ` as ${String(head).slice(0, 12)}` : ''}.` }
4175
+ }
4176
+
4177
+ /**
4178
+ * { branch } when this folder's branch has no square in the quilt at layout 3 (a branch made after the
4179
+ * switch, or main with nothing to adopt): its edits reach nobody until one is opened for it. Else null.
4180
+ */
4181
+ squareless () {
4182
+ if (!this.quilt || this.quilt.layout !== 3 || !this.root) return null
4183
+ const branch = this.squareBranchKey()
4184
+ return squareFor(this.quilt, branch) ? null : { branch }
4185
+ }
4186
+
4187
+ /**
4188
+ * The app's Open a square (status `squareless`): the same ask the first refused write makes
4189
+ * (openBranchSquare, the relay's quilt-open, as this member), with what went wrong said, not only
4190
+ * logged. { square, text }.
4191
+ */
4192
+ async openSquareHere () {
4193
+ if (!this.quilt || this.quilt.layout !== 3 || !this.quilt.id) throw new Error('This session\'s code is not in Seam.')
4194
+ if (!this.conn || !this.conn.connected) throw new Error('This folder is not connected to the relay; try again in a moment.')
4195
+ const branch = this.squareBranchKey()
4196
+ const at = squareFor(this.quilt, branch)
4197
+ if (at) return { square: at.square, text: `${branch} has a square already: ${squareShort(at.square)}.` }
4198
+ if (this.openingSquare) throw new Error(`A square for ${branch} is being opened already; your folder follows it in a moment.`)
4199
+ let r
4200
+ try { r = await this.openBranchSquare(branch, { quiet: false }) } catch (err) {
4201
+ // A branch the relay never had (checked out in git after the move): nothing to start its square from.
4202
+ if (/is not one of this session's branches/.test(err.message)) throw new Error(`${branch} was made after the session's code moved into Seam, so the session has nothing of it to start a square from. Check out a branch the session has (main, say) and your folder follows that branch's square; or ask the session's owner.`)
4203
+ throw err
4204
+ }
4205
+ return { square: r.square, text: r.opened ? `Opened square ${squareShort(r.square)} for ${branch}: your folder follows it in a moment, with your files as its work, and your edits reach everyone on it.` : `${branch} has a square already: ${squareShort(r.square)}; your folder follows it in a moment.` }
4206
+ }
4207
+
4208
+ /**
4209
+ * The relay refused a write of this folder's to a read-only branch document (layout 3): said in
4210
+ * the log, kept for status (quilt_status, the app), and told to this member's AI at its next call.
4211
+ */
4212
+ noteReadOnly (r) {
4213
+ if (!r || !r.why) return
4214
+ this.readOnly = { branch: r.branch || '', ...(r.square ? { square: String(r.square) } : {}), why: String(r.why).slice(0, 500), at: Date.now() }
4215
+ this.log(`⚠️ ${this.readOnly.why}`)
4216
+ this.notice(this.readOnly.why)
4217
+ this.scheduleStatusWrite()
4218
+ if (r.noSquare) this.openBranchSquare(r.branch || this.branchKey()).catch(() => {})
4219
+ }
4220
+
4221
+ /**
4222
+ * The session owner's app renews the room's read token (every folder reads its square with it)
4223
+ * when the relay says it is missing, refused, or ends within a month: the relay holds no
4224
+ * credential that can (server.js readTokenState), the owner's sign-in can. Checked whenever the
4225
+ * relay says something of the quilt and every few hours; tried at most once an hour.
4226
+ */
4227
+ async renewReadToken () {
4228
+ const q = this.quilt
4229
+ if (!q || q.layout !== 3 || !this.isOwner || !this.conn || !this.conn.connected || !appSeamUrl() || this.stopped) return null
4230
+ if (!this.readRenewTimer) { this.readRenewTimer = setInterval(() => this.renewReadToken().catch(() => {}), READ_RENEW_CHECK_MS); this.readRenewTimer.unref?.() }
4231
+ const r = q.read || {}
4232
+ if (!(r.renew || (r.ends && r.ends - Date.now() < READ_RENEW_BEFORE_MS))) return null
4233
+ if (this.readRenewAt && Date.now() - this.readRenewAt < READ_RENEW_RETRY_MS) return null
4234
+ this.readRenewAt = Date.now()
4235
+ try {
4236
+ const { token } = await this.seamMember().client(q.id, [])
4237
+ const a = await this.conn.branchRequest({ op: 'quilt-shadow', token, quilt: q.slug || q.id }, { timeoutMs: QUILT_MOVE_TIMEOUT_MS })
4238
+ if (a.read && a.read.renewed) this.log(`🧵 made a new read token for ${q.slug || q.id}${a.read.until ? ` (until ${a.read.until.slice(0, 10)})` : ''}: every folder reads its square with it.`)
4239
+ return a
4240
+ } catch (err) {
4241
+ this.log(`⚠️ couldn't renew the session's read token for ${q.slug || q.id} (${err.message}); folders can't read their squares without it. quilt session migrate --shadow ${q.slug || q.id} tries again.`)
4242
+ return null
4243
+ }
4244
+ }
4245
+
4246
+ /**
4247
+ * The folder's branch has no square at layout 3 (the relay kept its edits in the branch's
4248
+ * document): one is opened for it as this member (server.js quiltOpen), with the document and
4249
+ * those edits, and the folder follows it when the relay says so. One ask at a time. `quiet: false`
4250
+ * (the app's button, openSquareHere): a failure throws instead of only being logged.
4251
+ */
4252
+ async openBranchSquare (branch, { quiet = true, token: given = null } = {}) {
4253
+ if (this.openingSquare || !this.quilt || !this.quilt.id || !this.conn || !this.conn.connected || !appSeamUrl()) {
4254
+ if (quiet) return null
4255
+ throw new Error(!appSeamUrl() ? 'Seam is turned off for this app (QUILT_SOURCE_URL=off).' : 'This folder is not connected to the relay; try again in a moment.')
4256
+ }
4257
+ this.openingSquare = true
4258
+ try {
4259
+ // `token`: the owner's sign-in a new session was made a quilt with (quilt-default.js).
4260
+ const token = given || (await this.seamMember().client(this.quilt.id, [])).token
4261
+ const r = await this.conn.branchRequest({ op: 'quilt-open', token, branch }, { timeoutMs: QUILT_MOVE_TIMEOUT_MS })
4262
+ if (r.opened) this.log(`🧵 opened square ${squareShort(r.square)} for ${branch}, which had none: this folder follows it, with your edits.`)
4263
+ // What is on disk is the new square's work: the folder is never written over as it starts to follow it (session-follow.js openHere).
4264
+ this.folderWins = r.square
4265
+ this.readOnly = null
4266
+ this.scheduleStatusWrite()
4267
+ return r
4268
+ } catch (err) {
4269
+ if (!quiet) throw err
4270
+ this.log(`⚠️ couldn't open a square for ${branch} (${err.message}); your edits are kept in the session, and the next one asks again`)
4271
+ return null
4272
+ } finally { this.openingSquare = false }
4273
+ }
4274
+
4275
+ /**
4276
+ * 16 step 1: links the session to the quilt an import of its git remote fills (`quilt`; null
4277
+ * unlinks), as the owner with `token`. The session stays as it is; quilt_history and the app
4278
+ * read the quilt's log from then on.
4279
+ */
4280
+ async quiltShadowLink (token, quilt) {
4281
+ if (!this.conn || !this.conn.connected) throw new Error('not connected to the relay; linking the session needs a connection')
4282
+ if (!token) throw new Error('sign in first (quilt login, or set SEAM_TOKEN): the session is linked as its owner')
4283
+ const r = await this.conn.branchRequest({ op: 'quilt-shadow', token, quilt: quilt || null }, { timeoutMs: QUILT_MOVE_TIMEOUT_MS })
4284
+ if (r.read) {
4285
+ const name = r.shadow.slug || r.shadow.id
4286
+ const until = r.read.until ? ` (until ${r.read.until.slice(0, 10)})` : ''
4287
+ return { ...r, text: r.read.renewed ? `The session has a new read token for ${name}${until}: every folder reads its square with it.` : `The session's read token for ${name} works${until}; nothing to renew.` }
4288
+ }
4289
+ const text = r.shadow ? `This session now reads the quilt ${r.shadow.slug || r.shadow.id}'s log (quilt_history); git stays its source of truth until the cut-over.` : r.was ? `This session no longer reads the quilt ${r.was.slug || r.was.id}.` : 'This session was not linked to a quilt.'
4290
+ return { ...r, text }
4291
+ }
4292
+
4293
+ /**
4294
+ * Back to the branches (layout 2) from the kept branch documents, as the owner with `token`.
4295
+ * The relay refuses while the quilt has open squares, unless each is named in `carried` (its
4296
+ * work carried over by hand: seam diff <square> | git apply).
4297
+ */
4298
+ async quiltUnswitch (token, carried = []) {
4299
+ if (!this.conn || !this.conn.connected) throw new Error('not connected to the relay')
4300
+ if (!token) throw new Error('sign in first (quilt login, or set SEAM_TOKEN): going back reads the quilt\'s open squares as its owner')
4301
+ const r = await this.conn.branchRequest({ op: 'quilt-unswitch', token, carried }, { timeoutMs: QUILT_MOVE_TIMEOUT_MS })
4302
+ const lines = [r.shadow
4303
+ ? `The session works from its branches and git again; it still reads the quilt ${r.shadow.slug || r.shadow.id}'s log.`
4304
+ : 'The session works from its branches again; the squares keep their live documents.']
4305
+ const quilt = (r.quilt && (r.quilt.slug || r.quilt.id)) || ''
4306
+ const list = (a) => `${a.slice(0, 8).join(', ')}${a.length > 8 ? `, and ${a.length - 8} more` : ''}`
4307
+ for (const k of r.kept || []) {
4308
+ const sq = squareShort(k.square)
4309
+ if (k.error) { lines.push(`No folder follows square ${sq} (${k.branch}) now, and the relay could not copy its work back (${k.error}): it is in the square (seam diff ${sq} --quilt ${quilt}) and on the relay.`); continue }
4310
+ if (k.copied.length) lines.push(`No folder follows square ${sq} (${k.branch}) now, so the relay copied its work into ${k.branch}: ${list(k.copied)}.`)
4311
+ if (k.uncommitted) lines.push(`Its live work could not be committed in Seam (${k.uncommitted}); what was copied is in ${k.branch}.`)
4312
+ else if (k.commit) lines.push(`All of square ${sq}'s work stays readable in Seam at ${String(k.commit).slice(0, 12)} (seam diff ${sq} --quilt ${quilt}).`)
4313
+ for (const x of k.left) lines.push(`Not copied into ${k.branch}: ${x.path}, ${x.why}${k.commit && !k.uncommitted ? `; get it with seam cat ${String(k.commit).slice(0, 12)} ${x.path} --quilt ${quilt}` : ''}.`)
4314
+ }
4315
+ return { ...r, text: lines.join('\n') }
4316
+ }
4317
+
3689
4318
  async syncBranchNow () {
3690
4319
  // A folder that can't bring commits in itself (no git here, or its fetch fails) asks the
3691
4320
  // relay to look at GitHub for its branch now (relay-upstream.js), as a hosted agent can.
@@ -4397,8 +5026,9 @@ export class Session extends EventEmitter {
4397
5026
  * Pushes the on-disk state of a path into the shared doc. Returns true if anything changed.
4398
5027
  * `pulled`: git brought this version (a pull), so it is not an edit of ours: never claimed, and counted as the pull's.
4399
5028
  */
4400
- ingest (rel, { pulled = false } = {}) {
4401
- if (!this.syncable(rel)) return false
5029
+ /** `claim`: false keeps this edit from claiming (boxing) the file for us (leaveSquare: another member's work). */
5030
+ ingest (rel, { pulled = false, same = false, claim: mayClaim = true } = {}) {
5031
+ if (!this.syncable(rel) || this.inSquare || this.syncStopped) return false
4402
5032
  if (this.merging.has(rel)) return false // its offline merge hasn't run yet; see mergeOffline
4403
5033
  if (this.held()) { this.heldPaths.add(rel); return false } // git is at work in this folder; see onSettled
4404
5034
  if (this.pullWait.has(rel)) { if (!this.onDisk(rel)) return false; this.endPullWait(rel) } // made way for a pull: not a deletion
@@ -4407,7 +5037,7 @@ export class Session extends EventEmitter {
4407
5037
  if (this.downloading.has(rel)) return false // our copy is being replaced by a download
4408
5038
  if (this.writeFailed.has(rel)) return false // the shared version never reached the disk: what's there is no edit of ours
4409
5039
  // New ignore rules: a box of ours on a file they now leave out lets go (settleBoxesOnce).
4410
- if (IGNORE_FILES.includes(path.posix.basename(rel))) { this.ig = loadIgnore(this.root); this.settleBoxesSoon() }
5040
+ if (IGNORE_FILES.includes(path.posix.basename(rel)) || rel === '.gitmodules') { this.ig = loadIgnore(this.root); this.settleBoxesSoon() }
4411
5041
  const disk = this.readDisk(rel)
4412
5042
  const boxed = this.boxedByOther(rel)
4413
5043
  if (boxed) {
@@ -4472,7 +5102,7 @@ export class Session extends EventEmitter {
4472
5102
  const quiltOwn = this.quiltOwnWrite === rel && disk && disk.key === this.quiltOwnKey
4473
5103
  if (quiltOwn) { this.quiltOwnWrite = null; this.quiltOwnKey = null }
4474
5104
  // With boxes, a deletion is boxed too: it reaches nobody else's folder until it's committed.
4475
- if ((disk || this.boxesOn()) && !pulled && !quiltOwn && this.ready && !this.seeding && changesIt && (!claim || this.autoClaims.has(rel) || this.unboxedOwn(rel, claim)) && this.aiMayBeEditing()) this.autoClaim(rel)
5105
+ if (mayClaim && (disk || this.boxesOn()) && !pulled && !quiltOwn && this.ready && !this.seeding && changesIt && (!claim || this.autoClaims.has(rel) || this.unboxedOwn(rel, claim)) && this.aiMayBeEditing()) this.autoClaim(rel)
4476
5106
  if (!pulled && this.ownBox(rel)) this.settleBoxesSoon() // put back to what was committed, say: the box opens
4477
5107
 
4478
5108
  if (!disk) {
@@ -4552,7 +5182,8 @@ export class Session extends EventEmitter {
4552
5182
  texts = { before, after: disk.text }
4553
5183
  // Folders on one branch pull the same commits, often at the same moment: made under an id
4554
5184
  // taken from the change, the pull's edit is the same edit from each of them, and lands once.
4555
- detail = pulled ? asClient(this.bdoc, sameEditClient('pull', rel, this.files.has(rel) ? before : null, disk.text), put) : put()
5185
+ // So is the square's work every folder brings back when a session leaves layout 3 (leaveSquare).
5186
+ detail = pulled || same ? asClient(this.bdoc, sameEditClient(pulled ? 'pull' : 'square', rel, this.files.has(rel) ? before : null, disk.text), put) : put()
4556
5187
  }
4557
5188
  if (disk.exec) { if (this.modes.get(rel) !== 'x') this.modes.set(rel, 'x') } else if (this.modes.has(rel)) this.modes.delete(rel)
4558
5189
  this.notePresentHere(rel, this.entryBase())
@@ -4646,13 +5277,18 @@ export class Session extends EventEmitter {
4646
5277
  * `texts` is { before, after } for text files, so the chronology keeps the diff. `pulled`: a git pull
4647
5278
  * brought it: counted as the pull's, and left out of the activity log (notePull writes one line for it all).
4648
5279
  */
4649
- recordActivity (rel, kind, detail, texts, { pulled = false } = {}) {
5280
+ recordActivity (rel, kind, detail, texts, { pulled = false, activity = true } = {}) {
4650
5281
  const now = Date.now()
4651
5282
  this.tally(rel, kind, detail, now, texts, pulled)
4652
5283
  // A folder's starting copy of a branch (seeding) is nobody's change and no task's: it is on record, but never blamed or counted as unpublished work.
4653
5284
  this.history.record({ by: this.name, path: rel, kind, detail, before: texts?.before, after: texts?.after, task: pulled || this.seeding ? null : this.currentTask(), pulled, seed: !!this.seeding, ts: now })
4654
5285
  // A pull is logged once by notePull; a starting copy (seeding) is nobody's edit, so the file tree names no one for it.
4655
- if (pulled || this.seeding) return
5286
+ if (pulled || this.seeding || !activity) return
5287
+ this.pushActivity(rel, kind, detail, now)
5288
+ }
5289
+
5290
+ /** The room's activity log: one entry for a change of ours (bursts to one file fold into one). */
5291
+ pushActivity (rel, kind, detail, now = Date.now()) {
4656
5292
  const last = this.lastActivityPush.get(rel)
4657
5293
  // Collapse bursts of edits to the same file into one entry.
4658
5294
  if (kind === 'edited' && last && now - last < 20000) return
@@ -4741,6 +5377,7 @@ export class Session extends EventEmitter {
4741
5377
  * the others: it's noted in writeFailed and tried again later.
4742
5378
  */
4743
5379
  applyRemote (paths) {
5380
+ if (this.inSquare || this.syncStopped) return
4744
5381
  const failed = []
4745
5382
  for (const p of paths) {
4746
5383
  try { this.fromRemote(p); this.writeFailed.delete(p) } catch { failed.push(p) }
@@ -4787,7 +5424,7 @@ export class Session extends EventEmitter {
4787
5424
  }
4788
5425
 
4789
5426
  writeOut (rel) {
4790
- if (!this.syncable(rel)) return
5427
+ if (!this.syncable(rel) || this.inSquare || this.syncStopped) return
4791
5428
  if (this.ghost(rel)) return
4792
5429
  if (this.merging.has(rel)) return // mergeOffline writes this path once it has merged it
4793
5430
  if (this.held()) { this.heldPaths.add(rel); return } // written back when the hold ends (writeBack)
@@ -4833,7 +5470,7 @@ export class Session extends EventEmitter {
4833
5470
  this.lastKnown.set(rel, shared)
4834
5471
  }
4835
5472
  // New ignore rules: a box of ours on a file they now leave out lets go (settleBoxesOnce).
4836
- if (IGNORE_FILES.includes(path.posix.basename(rel))) { this.ig = loadIgnore(this.root); this.settleBoxesSoon() }
5473
+ if (IGNORE_FILES.includes(path.posix.basename(rel)) || rel === '.gitmodules') { this.ig = loadIgnore(this.root); this.settleBoxesSoon() }
4837
5474
 
4838
5475
  if (this.ready && !this.arriving) { // a move writing its branch onto the folder is no partner's change
4839
5476
  this.emit('file-changed', { path: rel, by: this.lastEditorOf(rel) || 'partner' })
@@ -4985,7 +5622,7 @@ export class Session extends EventEmitter {
4985
5622
 
4986
5623
  /** Tries failed uploads, downloads and writes again. */
4987
5624
  retryFailed () {
4988
- if (!this.ready || this.stopped) return
5625
+ if (!this.ready || this.stopped || this.inSquare || this.syncStopped) return
4989
5626
  if (this.hold && this.hold.kind === 'switching' && !this.following) this.recheckSoon()
4990
5627
  const failed = [...this.retry]
4991
5628
  this.retry.clear()
@@ -5294,6 +5931,7 @@ export class Session extends EventEmitter {
5294
5931
 
5295
5932
  /** Stats the folder and queues every path that appeared, changed or vanished since the last scan. */
5296
5933
  scanDisk ({ baseline = false } = {}) {
5934
+ if (this.inSquare || this.syncStopped) return
5297
5935
  const seen = new Set()
5298
5936
  for (const rel of walk(this.root, this.ig)) {
5299
5937
  let st
@@ -5339,7 +5977,7 @@ export class Session extends EventEmitter {
5339
5977
 
5340
5978
  /**
5341
5979
  * Joins, updates or leaves the session's voice call (src/voice.js): `v` is null to leave, or
5342
- * { muted, sig, transcribing } from the app window, which does the audio. Only people talk.
5980
+ * { muted, sig, transcribing, sharing } from the app window, which does the audio. Only people talk.
5343
5981
  */
5344
5982
  setVoice (v) {
5345
5983
  if (!this.conn || !this.conn.awareness) throw new Error('Not connected to the session yet.')
@@ -5524,6 +6162,8 @@ export class Session extends EventEmitter {
5524
6162
  requestCommit (message, { files = null, task = null, via = null, by = null, id = null, description = '', quiet = false } = {}) {
5525
6163
  message = String(message || '').trim().slice(0, MAX_MESSAGE)
5526
6164
  if (this.access && this.access.state === 'approved' && this.access.role === 'viewer') throw new Error('viewers can’t ask for commits')
6165
+ // In Seam nobody waits on someone with git: whoever is on a square commits it.
6166
+ if (this.quilt && this.quilt.layout === 3) throw new Error('This session\'s code is committed in Seam, so there is no one to ask: commit your square yourself (the square button at the top of the app, quilt commit "<message>", or seam_commit for an AI).')
5527
6167
  // A commit request is a message to the host: the relay undoes it from someone who may not post.
5528
6168
  if (!this.mayTalk()) throw new Error(TALK_REFUSED)
5529
6169
  const t = task ? this.taskList().find((x) => x.id === String(task)) : null
@@ -5634,9 +6274,91 @@ export class Session extends EventEmitter {
5634
6274
  historyQuery ({ path, by, since, task, limit } = {}) {
5635
6275
  const from = parseSince(since)
5636
6276
  if (from === undefined) throw new Error('since: use a duration like 2h or 3d, "today", "yesterday", or a date')
5637
- return queryHistory(this.history.entries(), { path, by, since: from ?? undefined, task, limit: Math.min(Number(limit) || 50, 500) })
6277
+ // Once the session is a quilt (the relay says so, src/server.js branchesFields), older changes are its legacy tail.
6278
+ const entries = legacyTail(this.history.entries(), { at: this.quilt && this.quilt.at, history: !!(this.quilt && this.quilt.history) })
6279
+ return queryHistory(entries, { path, by, since: from ?? undefined, task, limit: Math.min(Number(limit) || 50, 500) })
6280
+ }
6281
+
6282
+ /**
6283
+ * quilt_history: historyQuery, and in a session linked to a quilt (Seam on, the relay says the
6284
+ * session is a quilt or shadows one, 16) the quilt's log merged in (quilt-history.js): read as
6285
+ * this member (seamMember: SEAM_TOKEN, else the session's Quilt sign-in, the same member as the
6286
+ * Seam tools), else through the relay with the room's read token.
6287
+ * { entries, note? }; the diffs of the log's commits only when `with_diff`.
6288
+ */
6289
+ async historyLinked ({ path: p, by, since, task, limit, with_diff: withDiff } = {}) {
6290
+ // The relay says whether the room is linked (it has Seam on; this app may not): its read token answers then.
6291
+ const link = this.quiltLink()
6292
+ if (!link) return { entries: this.historyQuery({ path: p, by, since, task, limit }) }
6293
+ const from = parseSince(since)
6294
+ if (from === undefined) throw new Error('since: use a duration like 2h or 3d, "today", "yesterday", or a date')
6295
+ const own = legacyTail(this.history.entries(), { at: this.quilt && this.quilt.at, history: !!(this.quilt && this.quilt.history) })
6296
+ const query = { path: p || '', by, since: from ?? undefined, task, limit: Math.min(Number(limit) || 50, 500), withDiff: !!withDiff }
6297
+ const quilt = { id: link.id, slug: link.slug || '' }
6298
+ const api = this.seamApiUrl ?? appSeamUrl()
6299
+ // As this member; one Seam would not let in (an agent with no member row, say) is not asked again for a
6300
+ // minute. No sign-in yet is not Seam's answer: a sign-in made meanwhile (quilt login, SEAM_TOKEN) is used at once.
6301
+ let seam = null
6302
+ if (api && !(this.historySeamOff && Date.now() - this.historySeamOff < HISTORY_SEAM_OFF_MS)) {
6303
+ try { seam = (await this.seamMember().client(quilt.id, [], { read: true, retry: false })).client } catch (err) { if (err.status) this.historySeamOff = Date.now() }
6304
+ }
6305
+ if (seam) {
6306
+ // No retries: Seam down falls back to the relay's read at once.
6307
+ const r = await linkedHistory({ seam, quilt, entries: own, query })
6308
+ if (!r.note) return { entries: r.list }
6309
+ }
6310
+ let got = null
6311
+ try { got = this.conn && this.conn.connected ? await this.conn.branchRequest({ op: 'quilt-log', query: { ...query, with_diff: !!withDiff } }) : null } catch (err) { got = { note: `Seam's log for ${quilt.slug || quilt.id} could not be read (${err.message}); this is the session's own record.` } }
6312
+ const note = !got ? `Seam's log for ${quilt.slug || quilt.id} was not read: this folder is not connected to the relay.` : got.note || ''
6313
+ return { entries: mergeLinked(own, (got && got.entries) || [], { ...query, logRead: !note }), ...(note ? { note } : {}) }
6314
+ }
6315
+
6316
+ /** The quilt the relay says this session is, or shadows (16 step 1): { id, slug, shadow, at? }, or null. */
6317
+ quiltLink () {
6318
+ const l = this.quilt || this.quiltShadow
6319
+ // `layout: 3`: the session's files are in its squares (the app's source control is Seam's then).
6320
+ return l && l.id ? { id: l.id, slug: l.slug || '', shadow: !this.quilt, ...(this.quilt && this.quilt.at ? { at: this.quilt.at } : {}), ...(this.quilt && this.quilt.layout === 3 ? { layout: 3 } : {}) } : null
6321
+ }
6322
+
6323
+ /**
6324
+ * A file's History in the app, in a session linked to a quilt: its live changes not in the
6325
+ * quilt's log yet, then the patches that touched it (each newest first), the same chronology
6326
+ * quilt_history gives agents. null when the session is not linked.
6327
+ */
6328
+ async fileHistory (file) {
6329
+ const quilt = this.quiltLink()
6330
+ if (!quilt) return null
6331
+ // The app asks again after every save to the file: the log is read at most every 30 s a file.
6332
+ const logs = this.fileLogs || (this.fileLogs = new Map())
6333
+ let got = logs.get(file)
6334
+ if (!got || got.quilt !== quilt.id || Date.now() - got.at > FILE_LOG_MS) {
6335
+ const { entries, note } = await this.historyLinked({ path: file, limit: 100 })
6336
+ got = { quilt: quilt.id, at: Date.now(), patches: entries.filter((e) => e.seam).reverse(), note: note || '' }
6337
+ logs.delete(file)
6338
+ logs.set(file, got)
6339
+ if (logs.size > 50) logs.delete(logs.keys().next().value)
6340
+ }
6341
+ const newest = got.patches.length ? got.patches[0].ts : 0
6342
+ // What git brought in is the log's, once the log was read; the session's starting copy (seed) is nobody's live work.
6343
+ const live = this.history.entries().filter((e) => e.path === file && e.ts > newest && !e.seed && !(e.pulled && !got.note)).reverse()
6344
+ return { quilt, live, patches: got.patches, ...(got.note ? { note: got.note } : {}) }
6345
+ }
6346
+
6347
+ /** A read of the linked quilt through the relay, with the room's read token: { quilt: null } when the session is not linked. */
6348
+ async quiltRead (req) {
6349
+ if (!this.quiltLink()) return { quilt: null }
6350
+ if (!this.conn || !this.conn.connected) throw new Error('This folder is not connected to the relay.')
6351
+ const { id, op, ...r } = await this.conn.branchRequest(req)
6352
+ return r
5638
6353
  }
5639
6354
 
6355
+ /** Who wrote each line of a file on main (Seam's blame); { working: true } while Seam works it out. */
6356
+ quiltBlame (file) { return this.quiltRead({ op: 'quilt-blame', path: file }) }
6357
+ /** Code search over main. */
6358
+ quiltSearch (q) { return this.quiltRead({ op: 'quilt-search', q }) }
6359
+ /** One patch of the log: its files and the diff of `file`. */
6360
+ quiltShow (commit, file) { return this.quiltRead({ op: 'quilt-show', commit, path: file }) }
6361
+
5640
6362
  /** `input` is a title, or { title, assignee, forAi, to_ai, tool, files }. "me" means this person. */
5641
6363
  addTask (input) {
5642
6364
  const fields = typeof input === 'string' ? { title: input } : { ...(input || {}) }
@@ -6038,7 +6760,20 @@ export class Session extends EventEmitter {
6038
6760
  * agents may push here. The agent then works and pushes there like anyone; the pull request opens
6039
6761
  * by itself. Returns the branch, or null when nothing was done (people's folders, no git, already on its own branch).
6040
6762
  */
6041
- async branchForTask (task) {
6763
+ branchForTask (task) { return this.branchMove(() => this.branchForTaskNow(task)) }
6764
+
6765
+ /**
6766
+ * One branch move at a time in this agent's folder: a task's new branch (branchForTask) and leaving
6767
+ * a merged one (leaveMergedBranch) both check out; at once, they fought over git's index, or the
6768
+ * second undid the first (an agent taking a task as its last one's branch merged got no branch).
6769
+ */
6770
+ branchMove (fn) {
6771
+ const p = (this.branchMoves || Promise.resolve()).then(fn)
6772
+ this.branchMoves = p.catch(() => {})
6773
+ return p
6774
+ }
6775
+
6776
+ async branchForTaskNow (task) {
6042
6777
  if (this.kind !== 'agent' || !this.git || !this.git.branch || !task || !task.title || !this.canCommitHere()) return null
6043
6778
  const prefix = agentPrefix(this.name)
6044
6779
  const settle = async () => { for (let i = 0; i < 20 && (this.hold || gitBusy(this.root, gitDir(this.root))); i++) await new Promise((r) => setTimeout(r, 250)) }
@@ -6089,7 +6824,9 @@ export class Session extends EventEmitter {
6089
6824
  * its own the merge didn't take (changes not committed, or commits the session's record of the branch
6090
6825
  * hasn't), the folder checks out the branch it merged into and brings it up to date. Returns the branch, or null.
6091
6826
  */
6092
- async leaveMergedBranch (b) {
6827
+ leaveMergedBranch (b) { return this.branchMove(() => this.leaveMergedBranchNow(b)) }
6828
+
6829
+ async leaveMergedBranchNow (b) {
6093
6830
  const here = b.key
6094
6831
  const into = this.branchList.some((x) => x.key === b.merged.into) ? b.merged.into : (this.branchList.find((x) => x.default) || {}).key || 'main'
6095
6832
  if (!into || into === here) return null
@@ -6252,14 +6989,32 @@ export class Session extends EventEmitter {
6252
6989
  if (!dirty) return null
6253
6990
  const rels = [...dirty.keys()].filter((rel) => this.syncable(rel))
6254
6991
  const history = this.history.entries()
6255
- // Each changed line is put down to whoever added or removed it last; a file git or this folder
6256
- // can't give as text falls back to who edited it since it was last committed.
6257
- const before = await filesAt(this.root, 'HEAD', rels.filter((rel) => dirty.get(rel) !== 'A')) || new Map()
6258
- const editors = new Map()
6259
6992
  const byPath = new Map()
6260
6993
  for (const h of history) { if (h && h.path) { if (!byPath.has(h.path)) byPath.set(h.path, []); byPath.get(h.path).push(h) } }
6261
- const rest = []
6994
+ // Who changed a file is worked out again only when the file, its history or HEAD changed since the
6995
+ // last look. Done for every file every two minutes (reviewCommits), reading each one and its copy
6996
+ // at HEAD and diffing them, it held hundreds of MB at a time in a folder far from its HEAD.
6997
+ const head = ((await this.gitOut(['rev-parse', '--verify', '-q', 'HEAD'])) || '').trim()
6998
+ const keyOf = (rel) => {
6999
+ let st = null
7000
+ try { st = fs.statSync(path.join(this.root, rel)) } catch {}
7001
+ const h = byPath.get(rel) || []
7002
+ return `${head} ${dirty.get(rel)} ${st ? `${st.size} ${st.mtimeMs}` : '-'} ${h.length} ${h.length ? h[h.length - 1].ts : 0}`
7003
+ }
7004
+ const cache = (this.editorsCache ||= new Map())
7005
+ const editors = new Map()
7006
+ const todo = new Map() // rel -> its key
6262
7007
  for (const rel of rels) {
7008
+ const key = keyOf(rel)
7009
+ const c = cache.get(rel)
7010
+ if (c && c.key === key) editors.set(rel, c.names)
7011
+ else todo.set(rel, key)
7012
+ }
7013
+ // Each changed line is put down to whoever added or removed it last; a file git or this folder
7014
+ // can't give as text falls back to who edited it since it was last committed.
7015
+ const before = todo.size ? await filesAt(this.root, 'HEAD', [...todo.keys()].filter((rel) => dirty.get(rel) !== 'A')) || new Map() : new Map()
7016
+ const rest = []
7017
+ for (const rel of todo.keys()) {
6263
7018
  const was = dirty.get(rel) === 'A' ? '' : before.get(rel)
6264
7019
  let now = ''
6265
7020
  if (dirty.get(rel) !== 'D') { try { now = fs.readFileSync(path.join(this.root, rel), 'utf8') } catch { now = null } }
@@ -6267,6 +7022,8 @@ export class Session extends EventEmitter {
6267
7022
  editors.set(rel, blameChange({ before: was, after: now, entries: byPath.get(rel) || [] }))
6268
7023
  }
6269
7024
  if (rest.length) for (const [rel, names] of editorsSince(rest, history, await lastCommitted(this.root))) editors.set(rel, names)
7025
+ for (const [rel, key] of todo) cache.set(rel, { key, names: editors.get(rel) })
7026
+ for (const rel of cache.keys()) if (!dirty.has(rel)) cache.delete(rel)
6270
7027
  const st = this.commitStatus({ includeMe: false })
6271
7028
  const busy = st.busy.map((b) => b.name)
6272
7029
  const claims = [...this.claims.values()]
@@ -6404,8 +7161,9 @@ export class Session extends EventEmitter {
6404
7161
  if (!client) return
6405
7162
  this.agents.add(String(client).slice(0, 80))
6406
7163
  this.conn.awareness.setLocalStateField('agents', [...this.agents])
6407
- const used = toolLabel(client)
6408
- if (this.toolGuessed && KNOWN_TOOLS.has(used) && used !== this.tool) {
7164
+ // The AI that connects is the tool this person uses, whatever Settings says (toolInUse).
7165
+ const used = toolInUse(this.tool, this.agents)
7166
+ if (used !== this.tool) {
6409
7167
  this.tool = used
6410
7168
  this.conn.awareness.setLocalStateField('tool', used)
6411
7169
  }
@@ -7094,7 +7852,7 @@ export class Session extends EventEmitter {
7094
7852
 
7095
7853
  /** settleBoxes in a moment: after a commit, a file put back, or an edit to one of our boxed files. */
7096
7854
  settleBoxesSoon (ms = SETTLE_BOXES_MS) {
7097
- if (this.stopped || !this.git || this.settleBoxesTimer) return
7855
+ if (this.stopped || !this.git || this.settleBoxesTimer || this.inSquare || this.syncStopped) return
7098
7856
  this.settleBoxesTimer = setTimeout(() => {
7099
7857
  this.settleBoxesTimer = null
7100
7858
  this.settleBoxes().catch((err) => this.log(`could not look at your boxes: ${err.message}`))
@@ -7458,6 +8216,7 @@ export class Session extends EventEmitter {
7458
8216
  * claim that covers a path that isn't a claim itself }.
7459
8217
  */
7460
8218
  async releaseReport (pattern = '*', via = null) {
8219
+ if (this.quilt && this.quilt.id && this.quilt.layout === 3) return this.releaseInSeam(String(pattern || '*'), via)
7461
8220
  const r = await this.conn.claimRequest({ op: 'release', pattern: String(pattern), ...this.as(via) })
7462
8221
  if (r.released) this.noteActivity(`Released ${pattern === '*' ? 'all my claims' : pattern}`, { via })
7463
8222
  const out = { released: r.released || 0 }
@@ -7465,6 +8224,27 @@ export class Session extends EventEmitter {
7465
8224
  return out
7466
8225
  }
7467
8226
 
8227
+ /**
8228
+ * releaseReport at layout 3, where claims are Seam's (seam-claims.js): this member lets go of theirs
8229
+ * in Seam as themselves (seamMember), the claims the move made for them included, though the mover
8230
+ * holds those in Seam (the relay says which: quilt-claims). One someone waits for is kept (`held`):
8231
+ * Seam hands it on with a note instead.
8232
+ */
8233
+ async releaseInSeam (pattern = '*', via = null) {
8234
+ if (!this.conn) throw new Error('not connected to the relay')
8235
+ let asked
8236
+ try { asked = await this.conn.claimRequest({ op: 'quilt-claims', ...this.as(via) }) } catch (err) {
8237
+ // A relay from before 0.3.132 can't say whose the move's claims were: let go of nothing rather than someone else's.
8238
+ throw new Error(`The relay could not say whose this session's claims are (${err.message}), so none was let go of. Try again once the relay is updated, or let go of one in Seam (seam unclaim <claim> --quilt ${this.quilt.slug || this.quilt.id}).`)
8239
+ }
8240
+ const { gone, ...r } = await releaseInSeam({ access: this.seamMember(), api: this.seamApiUrl ?? appSeamUrl(), quilt: this.quilt.id, asked, pattern })
8241
+ if (gone.length) {
8242
+ await this.conn.claimRequest({ op: 'quilt-released', ids: gone, ...this.as(via) }).catch(() => {})
8243
+ this.noteActivity(`Released ${pattern === '*' ? 'all my claims' : pattern}`, { via })
8244
+ }
8245
+ return r
8246
+ }
8247
+
7468
8248
  /**
7469
8249
  * The session's boxes as the app shows them (boxes.js): each with what it changes from the version
7470
8250
  * this folder has. Someone else's: from this folder's version before the box (what is on disk);
@@ -7883,9 +8663,12 @@ export class Session extends EventEmitter {
7883
8663
  const fromGithub = (p) => { const v = gh && gh.get(p); return v && typeof v.sha === 'string' ? { sha: v.sha, by: v.by || '', at: v.at || 0, came: v.came || 0, subject: v.subject || '' } : null }
7884
8664
  const out = []
7885
8665
  // A diff branch's files the session never changed are the folder's own checkout's (branch-view.js): listed too.
7886
- const listed = this.diffForm ? new Set([...this.sharedPaths(), ...[...this.gitOnly.keys()].filter((p) => this.followsGit(p) && this.onDisk(p))]) : this.sharedPaths()
8666
+ // Following a square in place (layout 3), the folder is the square's live copy: listed from disk.
8667
+ const square = this.squareFiles()
8668
+ const listed = square ? new Set(square.keys()) : this.diffForm ? new Set([...this.sharedPaths(), ...[...this.gitOnly.keys()].filter((p) => this.followsGit(p) && this.onDisk(p))]) : this.sharedPaths()
7887
8669
  for (const p of listed) {
7888
8670
  if (!isSafeRelPath(p)) continue
8671
+ if (square) { out.push({ path: p, binary: square.get(p), edited: edited.get(p) || null, claim: claimFor(p), github: null }); continue }
7889
8672
  const blob = this.blobs.get(p)
7890
8673
  out.push({ path: p, binary: blob ? !this.files.has(p) : this.diffForm && this.files.has(p) === false && /^bin:/.test(String(this.gitOnly.get(p) || '')), edited: edited.get(p) || null, claim: claimFor(p), github: fromGithub(p) })
7891
8674
  }
@@ -7910,7 +8693,46 @@ export class Session extends EventEmitter {
7910
8693
  return out
7911
8694
  }
7912
8695
 
7913
- /** Contents of one shared file, straight from the shared doc (never from disk); a chat file from the chat files folder. */
8696
+ /**
8697
+ * While the folder follows its square in place (layout 3, session-follow.js), the folder IS the
8698
+ * square's live copy: every edit of everyone on the square comes to disk, and the branch document
8699
+ * the session synced with is frozen, read-only, for a move back. So the app's Files and file views
8700
+ * read the folder then: path -> whether it is binary (sniffed once per size and time), or null when
8701
+ * the folder does not follow a square.
8702
+ */
8703
+ squareFiles () {
8704
+ if (!this.inSquare) return null
8705
+ const seen = this.squareSniff || (this.squareSniff = new Map()) // path -> { key, binary }
8706
+ const out = new Map()
8707
+ for (const rel of walk(this.root, this.ig)) {
8708
+ if (!this.syncable(rel)) continue
8709
+ let st
8710
+ try { st = fs.statSync(path.join(this.root, rel)) } catch { continue }
8711
+ const key = `${st.size}:${st.mtimeMs}`
8712
+ let c = seen.get(rel)
8713
+ if (!c || c.key !== key) {
8714
+ let binary = st.size > MAX_TEXT_BYTES
8715
+ if (!binary) { try { binary = looksBinary(fs.readFileSync(path.join(this.root, rel))) } catch { continue } }
8716
+ c = { key, binary }
8717
+ seen.set(rel, c)
8718
+ }
8719
+ out.set(rel, c.binary)
8720
+ }
8721
+ for (const rel of seen.keys()) if (!out.has(rel)) seen.delete(rel)
8722
+ return out
8723
+ }
8724
+
8725
+ /** One file of the folder while it follows its square (squareFiles): its bytes, or null when the session doesn't sync it or it is not there. */
8726
+ squareFileBytes (rel) {
8727
+ if (!this.syncable(rel)) return null
8728
+ try {
8729
+ const p = path.join(this.root, rel)
8730
+ if (!fs.statSync(p).isFile()) return null
8731
+ return fs.readFileSync(p)
8732
+ } catch { return null }
8733
+ }
8734
+
8735
+ /** Contents of one shared file, straight from the shared doc (never from disk, but for a folder following its square: squareFiles); a chat file from the chat files folder. */
7914
8736
  readShared (rel) {
7915
8737
  rel = String(rel || '').replace(/^\.\//, '')
7916
8738
  if (!isSafeRelPath(rel)) return null
@@ -7920,6 +8742,11 @@ export class Session extends EventEmitter {
7920
8742
  const buf = fs.readFileSync(path.join(this.root, rel))
7921
8743
  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 }
7922
8744
  }
8745
+ if (this.inSquare) {
8746
+ const buf = this.squareFileBytes(rel)
8747
+ if (!buf) return null
8748
+ return buf.length <= MAX_TEXT_BYTES && !looksBinary(buf) ? { path: rel, text: buf.toString('utf8') } : { path: rel, binary: true, size: buf.length }
8749
+ }
7923
8750
  const t = this.files.get(rel)
7924
8751
  if (t) return { path: rel, text: t.toString() }
7925
8752
  const b = this.blobs.get(rel)
@@ -7939,6 +8766,10 @@ export class Session extends EventEmitter {
7939
8766
  if (!this.chatFileEntries().some((x) => x.path === rel)) return null
7940
8767
  return { path: rel, buf: fs.readFileSync(path.join(this.root, rel)) }
7941
8768
  }
8769
+ if (this.inSquare) {
8770
+ const buf = this.squareFileBytes(rel)
8771
+ return buf ? { path: rel, buf } : null
8772
+ }
7942
8773
  const t = this.files.get(rel)
7943
8774
  if (t) return { path: rel, buf: Buffer.from(t.toString(), 'utf8') }
7944
8775
  const b = this.blobs.get(rel)
@@ -7963,7 +8794,8 @@ export class Session extends EventEmitter {
7963
8794
  const git = cleanGit(s.git)
7964
8795
  peers.push({
7965
8796
  name: s.name,
7966
- tool: s.tool,
8797
+ // An older app keeps its person's stale tool after their AI connects: show the AI that did.
8798
+ tool: toolInUse(s.tool, s.agents || []),
7967
8799
  color: s.color,
7968
8800
  kind: s.kind || 'human',
7969
8801
  // A person's cloud AI (its relay checked whose): one of that person's AI sessions.
@@ -7994,15 +8826,33 @@ export class Session extends EventEmitter {
7994
8826
  const t = typing.get(p.name)
7995
8827
  if (t) { p.typing = true; if (t.to) p.typingTo = t.to }
7996
8828
  const v = voices.get(p.name)
7997
- if (v && !p.persona) p.voice = { muted: v.muted, ...(v.transcribing ? { transcribing: true } : {}) }
8829
+ if (v && !p.persona) p.voice = { muted: v.muted, ...(v.transcribing ? { transcribing: true } : {}), ...(v.sharing ? { sharing: true } : {}) }
7998
8830
  }
7999
8831
  const myVoice = voices.get(this.name)
8832
+ const square = this.squareFollower ? this.squareFollower.status() : null
8000
8833
  return {
8001
8834
  room: this.room,
8002
8835
  server: this.server,
8003
8836
  branch: this.branch,
8004
8837
  connected: !!(this.conn && this.conn.connected),
8005
8838
  ...(this.relayProblem ? { problem: this.relayProblem } : {}),
8839
+ // The quilt the session is linked to (the app's History, Blame and search read it through the relay).
8840
+ ...(this.quiltLink() ? { quilt: this.quiltLink() } : {}),
8841
+ // At layout 3: the square this folder follows, with its one line (session-follow.js), and a write the relay could not keep.
8842
+ ...(square ? { square } : {}),
8843
+ // In Seam: each branch's square, as Seam names it (the app's sidebar shows who is on which).
8844
+ ...(this.quilt && this.quilt.layout === 3 ? { squares: Object.fromEntries(Object.entries(this.quilt.live || {}).filter(([, k]) => typeof k === 'string' && k.startsWith('quilt/')).map(([b, k]) => [b, squareShort(k.slice('quilt/'.length))])) } : {}),
8845
+ // The owner is moving the session into Seam or back right now (the relay's `moving`): { kind, since, line }.
8846
+ ...(this.moving ? { moving: { ...this.moving, line: movingLine(this.moving) } } : {}),
8847
+ // The forced date (16 flag 9): live sync stopped here (`stopped`), or when it is ({ at, remote }).
8848
+ ...(this.force ? { force: this.force } : {}),
8849
+ // "Make this a quilt" on a session with a git remote: Seam importing it ({ slug, repo }), or linked (quiltShadow).
8850
+ ...(this.quiltImporting ? { importing: this.quiltImporting } : {}),
8851
+ ...(this.quiltShadow ? { shadow: this.quiltShadow } : {}),
8852
+ // The folder's branch has no square in the quilt (openSquareHere; the app's button opens one).
8853
+ ...(!square && this.squareless() ? { squareless: this.squareless() } : {}),
8854
+ // Until the folder follows a square, its edits reach nobody: said until it does.
8855
+ ...(this.readOnly && this.quilt && this.quilt.layout === 3 && !(square && !square.opening && !square.problem) ? { readOnly: this.readOnly } : {}),
8006
8856
  access: this.access,
8007
8857
  sessionName: this.sessionName,
8008
8858
  members: this.members,
@@ -8022,7 +8872,7 @@ export class Session extends EventEmitter {
8022
8872
  color: this.conn?.awareness.getLocalState()?.color,
8023
8873
  agent: { ...(this.agentState || { status: 'idle' }), sharing: this.agentSharing, summarized: !!this.summarizer },
8024
8874
  work: this.work,
8025
- ...(myVoice && myVoice.me ? { voice: { muted: myVoice.muted, ...(myVoice.transcribing ? { transcribing: true } : {}) } } : {})
8875
+ ...(myVoice && myVoice.me ? { voice: { muted: myVoice.muted, ...(myVoice.transcribing ? { transcribing: true } : {}), ...(myVoice.sharing ? { sharing: true } : {}) } } : {})
8026
8876
  },
8027
8877
  // The session's call: whether the owner has transcripts on.
8028
8878
  voice: { transcribe: transcribeSetting(this.doc, this.members).on },
@@ -8076,7 +8926,9 @@ export class Session extends EventEmitter {
8076
8926
  for (const t of this.rechecks.values()) clearTimeout(t)
8077
8927
  this.rechecks.clear()
8078
8928
  clearInterval(this.reconcileTimer)
8929
+ clearInterval(this.readRenewTimer)
8079
8930
  clearInterval(this.upstreamPoll)
8931
+ if (this.squareFollower) await this.squareFollower.stop()
8080
8932
  clearTimeout(this.clashTimer)
8081
8933
  if (this.clashPending) clearTimeout(this.clashPending.timer)
8082
8934
  clearTimeout(this.upstreamTimer)
@@ -8090,6 +8942,7 @@ export class Session extends EventEmitter {
8090
8942
  clearTimeout(this.typingExpiryTimer)
8091
8943
  if (this.conn) this.conn.close()
8092
8944
  this.saveState()
8945
+ this.emit('stopped')
8093
8946
  await new Promise((r) => setTimeout(r, 100))
8094
8947
  }
8095
8948
  }