heyquilt 0.3.122 → 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 (136) hide show
  1. package/RELEASES.md +273 -17
  2. package/bin/quilt.js +55 -9
  3. package/desktop/main.js +16 -2
  4. package/package.json +3 -2
  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 +16 -9
  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/session-migrate.js +7 -4
  15. package/src/api/routes/sessions.js +73 -1
  16. package/src/api/server.js +13 -7
  17. package/src/api/supabase-store.js +43 -4
  18. package/src/autowork.js +182 -16
  19. package/src/connection.js +36 -9
  20. package/src/control.js +129 -15
  21. package/src/force-date.js +27 -0
  22. package/src/fsutil.js +13 -3
  23. package/src/gitstate.js +38 -2
  24. package/src/history.js +13 -3
  25. package/src/mcp.js +87 -12
  26. package/src/merge3.js +11 -3
  27. package/src/now-summary.js +113 -0
  28. package/src/pathrules.js +12 -0
  29. package/src/protocol.js +10 -2
  30. package/src/quilt-default.js +84 -0
  31. package/src/quilt-history.js +106 -0
  32. package/src/quilt-remote.js +78 -0
  33. package/src/relay-commit.js +5 -0
  34. package/src/relay-lease.js +6 -2
  35. package/src/relay-mcp.js +151 -17
  36. package/src/relay-outbound.js +1 -1
  37. package/src/release-words.js +2 -2
  38. package/src/runner.js +20 -7
  39. package/src/seam-access.js +97 -0
  40. package/src/seam-claims.js +53 -0
  41. package/src/seam-url.js +15 -0
  42. package/src/server.js +1517 -86
  43. package/src/session-follow.js +461 -27
  44. package/src/session-layout3.js +410 -4
  45. package/src/session-migrate.js +15 -11
  46. package/src/session-quilt.js +1 -1
  47. package/src/session-seam.js +233 -20
  48. package/src/session.js +848 -67
  49. package/src/source-live/bring-in.js +283 -0
  50. package/src/source-live/live-block.js +504 -33
  51. package/src/source-live/live-square.js +16 -5
  52. package/src/status.js +5 -0
  53. package/src/ui/agent-guide.js +1 -1
  54. package/src/ui/app.css +85 -20
  55. package/src/ui/app.js +4 -1
  56. package/src/ui/chat.js +9 -3
  57. package/src/ui/cloud-ai-model.js +13 -0
  58. package/src/ui/cloud-ai.js +10 -2
  59. package/src/ui/common.js +6 -2
  60. package/src/ui/fileview.js +20 -7
  61. package/src/ui/force-words.js +19 -0
  62. package/src/ui/git-rule.js +9 -0
  63. package/src/ui/home.js +0 -147
  64. package/src/ui/loom-model.js +15 -1
  65. package/src/ui/quiltview.js +213 -0
  66. package/src/ui/seam-model.js +251 -0
  67. package/src/ui/session.js +413 -31
  68. package/src/ui/voice-call.js +83 -14
  69. package/src/ui/voice-model.js +39 -0
  70. package/src/ui/voice.js +167 -17
  71. package/src/ui-server.js +80 -28
  72. package/src/update-check.js +25 -1
  73. package/src/voice.js +13 -6
  74. package/src/git.js +0 -147
  75. package/src/quilts/AGENTS.md +0 -1
  76. package/src/quilts/CLAUDE.md +0 -1
  77. package/src/quilts/accounts.js +0 -136
  78. package/src/quilts/affected.js +0 -132
  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/checks.js +0 -308
  87. package/src/quilts/claims.js +0 -144
  88. package/src/quilts/cli.js +0 -536
  89. package/src/quilts/client.js +0 -137
  90. package/src/quilts/conflict-format.js +0 -190
  91. package/src/quilts/describe.js +0 -296
  92. package/src/quilts/dogfood.js +0 -82
  93. package/src/quilts/errors.js +0 -8
  94. package/src/quilts/events.js +0 -171
  95. package/src/quilts/export-git.js +0 -130
  96. package/src/quilts/export-remote.js +0 -156
  97. package/src/quilts/export.js +0 -81
  98. package/src/quilts/fastmerge.js +0 -41
  99. package/src/quilts/hash.js +0 -44
  100. package/src/quilts/history.js +0 -266
  101. package/src/quilts/ids.js +0 -12
  102. package/src/quilts/import-follow.js +0 -236
  103. package/src/quilts/import-git.js +0 -294
  104. package/src/quilts/import.js +0 -196
  105. package/src/quilts/index.js +0 -94
  106. package/src/quilts/keys.js +0 -186
  107. package/src/quilts/limits.js +0 -67
  108. package/src/quilts/lines.js +0 -49
  109. package/src/quilts/live.js +0 -50
  110. package/src/quilts/merge-smart.js +0 -114
  111. package/src/quilts/merge.js +0 -501
  112. package/src/quilts/oauth.js +0 -202
  113. package/src/quilts/objects.js +0 -144
  114. package/src/quilts/pack.js +0 -118
  115. package/src/quilts/records.js +0 -211
  116. package/src/quilts/resolutions.js +0 -69
  117. package/src/quilts/reviews.js +0 -208
  118. package/src/quilts/routes.js +0 -186
  119. package/src/quilts/runner.js +0 -95
  120. package/src/quilts/server.js +0 -144
  121. package/src/quilts/service.js +0 -963
  122. package/src/quilts/setup.js +0 -41
  123. package/src/quilts/signing.js +0 -149
  124. package/src/quilts/squares.js +0 -239
  125. package/src/quilts/store-memory.js +0 -844
  126. package/src/quilts/store-rpc.js +0 -90
  127. package/src/quilts/store-supabase.js +0 -13
  128. package/src/quilts/tags.js +0 -64
  129. package/src/quilts/textdiff.js +0 -96
  130. package/src/quilts/tokens.js +0 -66
  131. package/src/quilts/tools.js +0 -403
  132. package/src/quilts/tree.js +0 -351
  133. package/src/quilts/webhook.js +0 -116
  134. package/src/quilts/window.js +0 -121
  135. package/src/quilts/xdiff.js +0 -435
  136. package/src/ui/branches.js +0 -111
package/src/connection.js CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  } from './protocol.js'
16
16
  import { signChallenge, RESERVED_ROOM } from './identity.js'
17
17
  import { PASS_REFRESH_MS } from './pass-source.js'
18
+ import { isUpdateRefusal, withUpdateSteps } from './update-check.js'
18
19
 
19
20
  const ROOM_FULL_MESSAGE = 'This session is over the relay\'s size limit, so new changes can\'t be saved there. Start a new session, or host your own relay with a higher limit.'
20
21
 
@@ -22,9 +23,14 @@ const REQUEST_TIMEOUT_MS = 10000
22
23
  // A request the relay answers after pushing to GitHub: pushes to one repository go at least ten seconds apart
23
24
  // (relay-git.js PUSH_GAP_MS), so the answer can take longer than an ordinary one.
24
25
  export const PUSH_REQUEST_TIMEOUT_MS = 60 * 1000
26
+ // Moving a session into its squares and back (the relay's quilt-migrate, -switch, -unswitch, -shadow,
27
+ // -next) is many calls to Seam; a large session's takes minutes, and the relay answers when it is done.
28
+ export const QUILT_MOVE_TIMEOUT_MS = 10 * 60 * 1000
25
29
  const IDENTITY_CHANGED = "This computer's Quilt identity changed. Sign out and sign in again."
26
30
  // Never quote the relay URL in errors: it names the room, and older relays may log it.
27
31
  const BAD_ADDRESS = "Couldn't connect to the relay: the address isn't valid."
32
+ // The relay refused this app or CLI: an app too old for the session is told only how to update.
33
+ const refusedBy = (reason) => new Error(isUpdateRefusal(reason) ? withUpdateSteps(reason) : `Relay refused connection: ${reason}`)
28
34
  // A pass that lapses this soon after connecting wasn't really valid: after a few in a
29
35
  // row, the clock is the likely culprit, and reconnecting won't help.
30
36
  const QUICK_EXPIRY_MS = 60 * 1000
@@ -58,7 +64,7 @@ export class Connection extends EventEmitter {
58
64
  * @param {number} [opts.livenessMs] give up on a connection that stays silent this long
59
65
  * @param {string} [opts.tool] the app or AI tool this is, for the session's audit trail
60
66
  */
61
- constructor ({ server, room, secret, key, viewSecret, kind = 'human', name, identity, doc, branch = null, onStaleBranch = null, beforeRemote, features = FEATURES, passes = null, passRefreshMs = PASS_REFRESH_MS, livenessMs = LIVENESS_MS, tool = '' }) {
67
+ constructor ({ server, room, secret, key, viewSecret, kind = 'human', name, identity, doc, branch = null, onStaleBranch = null, beforeRemote, features = FEATURES, passes = null, passRefreshMs = PASS_REFRESH_MS, livenessMs = LIVENESS_MS, tool = '', roomDoc = true }) {
62
68
  super()
63
69
  if (room === RESERVED_ROOM) throw new Error(`"${RESERVED_ROOM}" is not a session name`)
64
70
  // Secrets travel in headers, never in the URL: proxies log URLs, and Fly's did (issue 011).
@@ -103,6 +109,11 @@ export class Connection extends EventEmitter {
103
109
  this.autoJoin = null // the id of the automatic join, whose refusal is said (branch-refused)
104
110
  this.roomSynced = false
105
111
  this.branchSynced = false
112
+ // roomDoc: false: only the branch is synced, never the room's document (chat, tasks, activity),
113
+ // nor anyone's presence. A folder's live block (live-block.js) is that: its app's own connection
114
+ // has the room already, so the block's would only download it all again at every square.
115
+ this.roomDocOn = roomDoc !== false
116
+ if (!this.roomDocOn) this.awareness.setLocalState(null)
106
117
  // Which document of the branch this app's copy belongs to (the relay starts a new one when a
107
118
  // branch is started again after being removed). While the relay hasn't said, a saved copy is
108
119
  // held back (branchHeld); a copy of an older one is never merged in: onStaleBranch(reply)
@@ -218,7 +229,7 @@ export class Connection extends EventEmitter {
218
229
  this.emit('fatal', new Error('This relay needs a relay key to start new sessions. Join a session someone started there with their invite link instead.'))
219
230
  this.close()
220
231
  } else if (res.statusCode === 401 || res.statusCode === 400 || res.statusCode === 403) {
221
- this.emit('fatal', new Error(`Relay refused connection: ${reason}`))
232
+ this.emit('fatal', refusedBy(reason))
222
233
  this.close()
223
234
  } else if (res.statusCode === 413) {
224
235
  this.emit('fatal', new Error(ROOM_FULL_MESSAGE))
@@ -261,7 +272,7 @@ export class Connection extends EventEmitter {
261
272
  this.emit('fatal', Object.assign(new Error(String(reason) || 'The session owner did not let you in'), { denied: true }))
262
273
  this.close()
263
274
  } else if (code === CLOSE_AUTH_FAILED || code === CLOSE_NAME_TAKEN) {
264
- this.emit('fatal', new Error(`Relay refused connection: ${String(reason) || 'identity check failed'}`))
275
+ this.emit('fatal', refusedBy(String(reason) || 'identity check failed'))
265
276
  this.close()
266
277
  } else if (code === CLOSE_ROOM_FULL) {
267
278
  this.emit('fatal', new Error(ROOM_FULL_MESSAGE))
@@ -342,7 +353,8 @@ export class Connection extends EventEmitter {
342
353
  }
343
354
 
344
355
  startSync () {
345
- this.send(syncStep1Message(this.doc))
356
+ if (this.roomDocOn) this.send(syncStep1Message(this.doc))
357
+ else this.roomSynced = true
346
358
  if (this.branchKey !== null) {
347
359
  // The relay handles messages in order: the join lands before the branch's sync step 1.
348
360
  const id = crypto.randomBytes(8).toString('hex')
@@ -353,6 +365,7 @@ export class Connection extends EventEmitter {
353
365
  if (this.branchEpoch) this.branchHeld = true
354
366
  else this.send(syncStep1Message(this.branchDoc, this.branchKey))
355
367
  }
368
+ if (!this.roomDocOn) return this.noteSynced()
356
369
  // Set again rather than resent: that moves our clock on, so the relay takes the state
357
370
  // even when it still holds the clock from before a drop (it would ignore a repeat).
358
371
  const mine = this.awareness.getLocalState()
@@ -367,7 +380,7 @@ export class Connection extends EventEmitter {
367
380
  const type = decoding.readVarUint(dec)
368
381
  if (type === MSG_SYNC) {
369
382
  const docId = decoding.readVarString(dec)
370
- const doc = docId === ROOM_DOC ? this.doc : docId === this.branchKey && !this.branchHeld ? this.branchDoc : null
383
+ const doc = docId === ROOM_DOC ? (this.roomDocOn ? this.doc : null) : docId === this.branchKey && !this.branchHeld ? this.branchDoc : null
371
384
  if (!doc) return // a branch this connection has left (or whose document the relay hasn't confirmed yet)
372
385
  // Give the owner a chance to capture unsaved local edits so remote
373
386
  // changes merge with them instead of overwriting them.
@@ -382,10 +395,24 @@ export class Connection extends EventEmitter {
382
395
  this.noteSynced()
383
396
  }
384
397
  } else if (type === MSG_BRANCHES) {
385
- const { branches, reply, pushed, quilt } = JSON.parse(decoding.readVarString(dec))
398
+ const { branches, reply, pushed, quilt, shadow, readOnly, moving, force, importing } = JSON.parse(decoding.readVarString(dec))
399
+ // "Make this a quilt" on a session with a git remote: Seam imports it ({ slug, repo }), or null.
400
+ const im = importing && typeof importing.repo === 'string' ? { slug: String(importing.slug || ''), repo: importing.repo } : null
401
+ if (JSON.stringify(im) !== JSON.stringify(this.importing || null)) { this.importing = im; this.emit('importing', im) }
402
+ // The forced date (16 flag 9): { at, remote, migrate? } before or at it, { stopped: { at, why } } once live sync stopped; else none.
403
+ const fz = force && typeof force === 'object' ? force : null
404
+ if (JSON.stringify(fz) !== JSON.stringify(this.force || null)) { this.force = fz; this.emit('force', fz) }
405
+ // At layout 3 a write of ours reached a read-only branch document: the relay did not keep it.
406
+ if (readOnly && typeof readOnly.why === 'string') this.emit('read-only', readOnly)
386
407
  // The session is a quilt (Seam): its link, or null while it is not.
387
408
  const link = quilt && typeof quilt.id === 'string' ? quilt : null
388
409
  if (JSON.stringify(link) !== JSON.stringify(this.quilt || null)) { this.quilt = link; this.emit('quilt', link) }
410
+ // The quilt an import of the session's git remote fills while git is still the authority (16 step 1).
411
+ const sh = shadow && typeof shadow.id === 'string' ? { id: shadow.id, slug: String(shadow.slug || '') } : null
412
+ if (JSON.stringify(sh) !== JSON.stringify(this.quiltShadow || null)) { this.quiltShadow = sh; this.emit('quilt-shadow', sh) }
413
+ // The owner is moving the session into Seam ('in') or back to its branches ('back') right now: { kind, since }, or null.
414
+ const mv = moving && (moving.kind === 'in' || moving.kind === 'back') ? { kind: moving.kind, since: Number(moving.since) || 0 } : null
415
+ if (JSON.stringify(mv) !== JSON.stringify(this.moving || null)) { this.moving = mv; this.emit('moving', mv) }
389
416
  // GitHub moved a branch the session follows (the relay heard it from GitHub): look now.
390
417
  if (pushed && typeof pushed.branch === 'string') this.emit('pushed', pushed)
391
418
  if (reply && this.joining && reply.id === this.joining.id) {
@@ -414,9 +441,9 @@ export class Connection extends EventEmitter {
414
441
  if (Array.isArray(branches)) this.emit('branches', branches)
415
442
  this.settle(reply, 'the relay refused that branch change')
416
443
  } else if (type === MSG_AWARENESS) {
417
- awarenessProtocol.applyAwarenessUpdate(this.awareness, decoding.readVarUint8Array(dec), REMOTE)
444
+ if (this.roomDocOn) awarenessProtocol.applyAwarenessUpdate(this.awareness, decoding.readVarUint8Array(dec), REMOTE)
418
445
  } else if (type === MSG_QUERY_AWARENESS) {
419
- this.send(awarenessMessage(this.awareness, [...this.awareness.getStates().keys()]))
446
+ if (this.roomDocOn) this.send(awarenessMessage(this.awareness, [...this.awareness.getStates().keys()]))
420
447
  } else if (type === MSG_ACCESS) {
421
448
  const was = this.access
422
449
  this.access = JSON.parse(decoding.readVarString(dec))
@@ -486,7 +513,7 @@ export class Connection extends EventEmitter {
486
513
  }
487
514
 
488
515
  /** Any other branch request (remove). */
489
- branchRequest (req) { return this.request(MSG_BRANCH, req, 'branch changes') }
516
+ branchRequest (req, { timeoutMs = REQUEST_TIMEOUT_MS } = {}) { return this.request(MSG_BRANCH, req, 'branch changes', null, timeoutMs) }
490
517
 
491
518
  waitForBranchSync () {
492
519
  if (this.branchSynced) return Promise.resolve()
package/src/control.js CHANGED
@@ -11,6 +11,14 @@ import { createRun, waitRun, endRun, secretNames, apiCall } from './secrets-clie
11
11
  import { runMailTool, MAIL_TOOLS } from './mail-tools.js'
12
12
  import { runWakeTool } from './wake-tool.js'
13
13
  import { wakeCheck } from './wake.js'
14
+ import { seamAccess, sessionCredential } from './seam-access.js'
15
+ import { runSessionSeamTool } from './session-seam.js'
16
+ import { appSeamUrl } from './seam-url.js'
17
+ import { squareFor } from './session-follow.js'
18
+ import { shareWithPeople } from './session-layout3.js'
19
+ import { QUILT_MOVE_TIMEOUT_MS } from './connection.js'
20
+ import { FEATURES } from './protocol.js'
21
+ import { currentVersion } from './releases.js'
14
22
 
15
23
  export async function startControl (session, extras = {}) {
16
24
  const token = crypto.randomBytes(16).toString('hex')
@@ -22,10 +30,45 @@ export async function startControl (session, extras = {}) {
22
30
  // Step 2 of joining (wake.js): an agent that hasn't chosen how Quilt wakes it is reminded with each answer.
23
31
  const wake = wakeCheck({
24
32
  fetchWake: async () => {
25
- if (session.kind !== 'agent' || !extras.apiAuth) return { method: 'n/a' }
33
+ // A person's cloud AI (aiOf) is woken by its machine (autowork.js), never asked how.
34
+ if (session.kind !== 'agent' || session.aiOf || !extras.apiAuth) return { method: 'n/a' }
26
35
  return (await apiCall(await secretsApi(), 'GET', '/v1/agents/me/wake')).wake
27
36
  }
28
37
  })
38
+ // Seam's tools for this folder's AI sessions, in a session that is a quilt (session-seam.js), as
39
+ // this session's member: its person, or its agent on a cloud machine (seam-access.js).
40
+ // Kept on the session, so its AI's runs (autowork.js) read Seam as the same member.
41
+ if (extras.apiAuth && !session.apiAuth) session.apiAuth = extras.apiAuth
42
+ const seam = session.seamMember ? session.seamMember() : (session.seam || (session.seam = seamAccess({ api: () => appSeamUrl(), credential: sessionCredential({ apiAuth: extras.apiAuth, account: readAccount }) })))
43
+ const shared = new Set() // squares this person holds and has shared with the session's other people
44
+ const seamTool = async (b) => {
45
+ const q = session.quilt
46
+ if (!q || !q.id) return { content: [{ type: 'text', text: 'This session is not a quilt, so it has no Seam tools.' }], isError: true }
47
+ const branch = session.squareBranchKey ? session.squareBranchKey() : session.branchKey()
48
+ const at = squareFor(q, branch)
49
+ const square = at ? at.square : ((q.squares || {})[branch] || null)
50
+ const fail = (text) => ({ content: [{ type: 'text', text }], isError: true })
51
+ // The folder's live block on that square: the folder itself, following it in place, or a
52
+ // separate one (session-follow.js). Its commit is `quilt commit`'s: what the relay holds.
53
+ const f = session.squareFollower
54
+ const here = f && f.here && at && f.here.square === at.square ? f.here : null
55
+ const sep = f && f.following && at && f.following.square === at.square ? f.following : null
56
+ const live = (here && here.block) || (sep && sep.block) || null
57
+ if (here && !here.block && (b.tool === 'seam_commit' || b.tool === 'seam_merge')) return fail(here.problem ? `This folder can't follow its square yet (${here.problem}); Quilt tries again by itself.` : 'This folder is still starting to follow its square; try again in a moment.')
58
+ let c
59
+ try { c = await seam.client(q.id, square ? [square] : []) } catch (err) { return fail(err.message) }
60
+ // The folder's live work is read and committed as this member.
61
+ if (live) live.client = c.client
62
+ if (c.person && square && !shared.has(square)) {
63
+ shared.add(square)
64
+ // The square's holder shares it with the session's other people, so their AIs (working for them) write there too.
65
+ shareWithPeople(c.client, q.id, square, session.members || []).catch(() => shared.delete(square))
66
+ }
67
+ const next = at ? async () => session.conn.branchRequest({ op: 'quilt-next', token: c.token, branch }, { timeoutMs: QUILT_MOVE_TIMEOUT_MS }) : null
68
+ return runSessionSeamTool(String(b.tool || ''), b.args || {}, { client: c.client, quilt: q.id, square, live, next, branch })
69
+ }
70
+ // The app's square panel (ui-server.js) commits and merges through the same tools, as the same member.
71
+ session.seamTool = seamTool
29
72
  const routes = {
30
73
  'GET /status': (b) => {
31
74
  // To one of the AI sessions working through this app, "you" is that session (persona.js).
@@ -99,7 +142,8 @@ export async function startControl (session, extras = {}) {
99
142
  'POST /agent': (b) => { session.addAgent(b.client); return { ok: true } },
100
143
  'POST /feed': (b) => ({ entries: session.agentFeedFor(b.who, { limit: Math.min(Number(b.limit) || 40, 300) }) }),
101
144
  // The chronology: { path, by, since, task, limit } (see Session.historyQuery).
102
- 'POST /history': (b) => ({ entries: session.historyQuery(b) }),
145
+ // In a session linked to a quilt (Seam), the quilt's log too (Session.historyLinked): { entries, note? }.
146
+ 'POST /history': (b) => session.historyLinked(b),
103
147
  'GET /tree': () => session.tree(),
104
148
  'POST /sharing': (b) => ({ on: session.setAgentSharing(b.on !== false) }),
105
149
  'GET /commits': () => session.commitStatus({ includeMe: false }),
@@ -111,13 +155,21 @@ export async function startControl (session, extras = {}) {
111
155
  'POST /quilt-plan': () => session.quiltPlan(),
112
156
  // The quilt this session is (Seam), as the relay last said; null while it is not (session-seam.js).
113
157
  'GET /quilt': () => ({ quilt: session.quilt || null }),
158
+ // One of Seam's tools (session-seam.js SEAM_SESSION_TOOLS), { tool, args }: an MCP tool result.
159
+ 'POST /seam': (b) => seamTool(b),
114
160
  // `quilt session migrate`: the relay makes the session a quilt, as its owner (their sign-in).
115
161
  'POST /quilt-migrate': () => session.quiltMigrate(process.env.SEAM_TOKEN || (readAccount() || {}).token),
162
+ // "Make this a quilt" (`quilt session migrate`, the app's button): made a quilt and moved into its squares in one go.
163
+ 'POST /quilt-make': () => session.quiltMake(process.env.SEAM_TOKEN || (readAccount() || {}).token),
116
164
  // `quilt session migrate --switch` / `--back`: the session's files into its squares (layout 3), or back.
117
165
  'POST /quilt-switch': () => session.quiltSwitch(process.env.SEAM_TOKEN || (readAccount() || {}).token),
118
- 'POST /quilt-unswitch': () => session.quiltUnswitch(),
166
+ 'POST /quilt-unswitch': (b) => session.quiltUnswitch(process.env.SEAM_TOKEN || (readAccount() || {}).token, Array.isArray(b.carried) ? b.carried : []),
167
+ // `quilt session migrate --shadow <quilt>`: link the session to the quilt an import of its git remote fills (16 step 1).
168
+ 'POST /quilt-shadow': (b) => session.quiltShadowLink(process.env.SEAM_TOKEN || (readAccount() || {}).token, b.quilt ?? null),
119
169
  // `quilt session follow <folder>`: at layout 3, that folder follows this branch's square, live.
120
- 'POST /quilt-follow': (b) => session.quiltFollow(process.env.SEAM_TOKEN || (readAccount() || {}).token, b.dir),
170
+ 'POST /quilt-follow': (b) => session.quiltFollow(null, b.dir),
171
+ // `quilt commit <message>`: at layout 3, the square this folder follows is committed, as this person.
172
+ 'POST /square-commit': (b) => session.squareCommit(String(b.message || '')),
121
173
  'POST /commit-request': (b) => session.requestCommit(b.message, { files: b.files == null ? null : b.files, description: b.description || '', task: b.task || null, via: b.via ? String(b.via) : null }),
122
174
  // quilt_commit: the relay commits the session's copy of the files to GitHub (relay-commit.js).
123
175
  'POST /agent-commits': (b) => session.setAgentCommits(b.mode),
@@ -221,7 +273,8 @@ export async function startControl (session, extras = {}) {
221
273
  const auth = await secretsApi()
222
274
  return { text: await runMailTool(b.tool, b.args && typeof b.args === 'object' ? b.args : {}, { call: (method, route, body) => apiCall(auth, method, route, body), room: session.room, me: session.actorName ? session.actorName(b.via) : session.name }) }
223
275
  },
224
- 'GET /info': (b) => ({ room: session.room, dir: session.root, name: session.actorName(b.via), person: session.name, kind: session.kind, invite: extras.invite || null, viewInvite: extras.viewInvite || null, access: session.access, pid: process.pid })
276
+ // `version` and `features` (what this app tells the relay it can do): the CLI checks them before a move (bin/quilt.js).
277
+ 'GET /info': (b) => ({ room: session.room, dir: session.root, name: session.actorName(b.via), person: session.name, kind: session.kind, invite: extras.invite || null, viewInvite: extras.viewInvite || null, access: session.access, pid: process.pid, version: appVersion(), features: session.features || FEATURES })
225
278
  }
226
279
  const server = http.createServer(async (req, res) => {
227
280
  const reply = (code, body) => {
@@ -299,15 +352,76 @@ export function findDaemon (start = process.env.QUILT_DIR || process.cwd()) {
299
352
  }
300
353
  }
301
354
 
302
- /** `timeoutMs`: give up (throw) when the session hasn't answered by then (git's helpers: git must never wait on Quilt). */
303
- export async function call (daemon, method, route, body, { timeoutMs = 0 } = {}) {
304
- const res = await fetch(`http://127.0.0.1:${daemon.port}${route}`, {
305
- method,
306
- headers: { authorization: `Bearer ${daemon.token}`, 'content-type': 'application/json' },
307
- body: body ? JSON.stringify(body) : undefined,
308
- signal: timeoutMs ? AbortSignal.timeout(timeoutMs) : undefined
355
+ const appVersion = () => { try { return currentVersion() } catch { return '' } }
356
+
357
+ /**
358
+ * `timeoutMs`: give up (throw) when the session hasn't answered by then (git's helpers: git must never wait on Quilt).
359
+ * Without it, it waits as long as the session takes (a move into Seam can take minutes): node:http, not fetch, whose
360
+ * own limit gave up after 5 minutes with nothing but "fetch failed". A session that can't be reached, or that
361
+ * stopped while answering, throws with what happened and what to do (`unreachable`).
362
+ */
363
+ export function call (daemon, method, route, body, { timeoutMs = 0 } = {}) {
364
+ const data = body ? JSON.stringify(body) : null
365
+ return new Promise((resolve, reject) => {
366
+ let timer = null
367
+ let late = null // the timeout's error, once it fired: whatever the socket says after it
368
+ const failed = (err) => { clearTimeout(timer); reject(late || (err.name === 'TimeoutError' ? err : unreachable(daemon, err))) }
369
+ const req = http.request({
370
+ host: '127.0.0.1',
371
+ port: daemon.port,
372
+ path: route,
373
+ method,
374
+ // A connection each: a kept-alive one outlives an app that restarted, and the next call would find it cut.
375
+ agent: false,
376
+ headers: { authorization: `Bearer ${daemon.token}`, 'content-type': 'application/json', ...(data ? { 'content-length': Buffer.byteLength(data) } : {}) }
377
+ }, (res) => {
378
+ let raw = ''
379
+ res.setEncoding('utf8')
380
+ res.on('data', (c) => { raw += c })
381
+ res.on('error', failed)
382
+ res.on('end', () => {
383
+ clearTimeout(timer)
384
+ let json
385
+ try { json = JSON.parse(raw) } catch { return reject(new Error(`Quilt's app sent something that isn't an answer (HTTP ${res.statusCode})`)) }
386
+ if (res.statusCode < 200 || res.statusCode >= 300) return reject(Object.assign(new Error(json.error || `HTTP ${res.statusCode}`), { status: res.statusCode }))
387
+ resolve(json)
388
+ })
389
+ })
390
+ req.on('error', failed)
391
+ if (timeoutMs) {
392
+ timer = setTimeout(() => {
393
+ late = Object.assign(new Error(`The session in ${daemon.dir || 'this folder'} did not answer within ${Math.round(timeoutMs / 1000)} s`), { name: 'TimeoutError' })
394
+ req.destroy(late)
395
+ }, timeoutMs)
396
+ }
397
+ req.end(data || undefined)
309
398
  })
310
- const json = await res.json()
311
- if (!res.ok) throw new Error(json.error || `HTTP ${res.status}`)
312
- return json
399
+ }
400
+
401
+ /**
402
+ * The control server couldn't be reached (nothing listens: the app closed this session or restarted), or
403
+ * it closed the connection before answering (the session stopped meanwhile: left, quit, or sent away by
404
+ * the relay). `cut` tells the two apart for callers that say more (a move into Seam goes on without the app).
405
+ */
406
+ export function unreachable (daemon, err) {
407
+ const code = (err && err.code) || ''
408
+ const where = daemon && daemon.dir ? ` for ${daemon.dir}` : ''
409
+ const cut = code !== 'ECONNREFUSED'
410
+ const msg = cut
411
+ ? `Quilt's app${where} stopped answering in the middle of this (${code || err.message}): the session there stopped (left, Quilt quit or restarted, or the relay sent this app away to update). Open the session in Quilt again, then check before trying again.`
412
+ : `Quilt's app${where} isn't answering: the session there is not running any more (Quilt quit, restarted, or left it). Open it in Quilt (or run quilt join here), then try again.`
413
+ return Object.assign(new Error(msg), { unreachable: true, cut, code })
414
+ }
415
+
416
+ /**
417
+ * Why `quilt session migrate` (or --switch, --back) must not start through this app, from its GET /info,
418
+ * or null. The move ends by sending away every app that can't follow a square (no `quilt-folders`): the
419
+ * owner's own app too, cutting this command's answer off mid-way. An app older than this check says
420
+ * neither its features nor its version; `cliVersion` is the quilt command's own.
421
+ */
422
+ export function moveRefusal (info, cliVersion = '') {
423
+ const features = info && typeof info.features === 'string' ? info.features.split(',') : null
424
+ if (features && features.includes('quilt-folders')) return null
425
+ const was = info && info.version ? ` (${info.version})` : ''
426
+ return `Quilt's app for this folder${was} is older than this quilt command${cliVersion ? ` (${cliVersion})` : ''}: the move sends it away to update at the end, and this command would lose its answer. Update Quilt, or restart it so it runs the new version, then run this again.`
313
427
  }
@@ -0,0 +1,27 @@
1
+ // The forced date (Seam's plans/quilt-source-control/16, flag 9): QUILT_SOURCE_FORCE_AT=<date> on the
2
+ // relay. Unset (or not a date), nothing happens. From the date on, a session still in git mode is
3
+ // moved on without anyone losing work: one with no git remote becomes a quilt without history on its
4
+ // owner's next open; one with a git remote that has not cut over keeps chat, tasks, the agent feed and
5
+ // the audit trail, and its live file sync stops for good (git, its remote, clones and CI untouched).
6
+ // Pure: no I/O (test/unit/force-date.test.js). The words are in ui/force-words.js, which the app loads too.
7
+ export { syncStoppedWords, forceAheadWords } from './ui/force-words.js'
8
+
9
+ /** The date as ms, or null when unset or not a date. */
10
+ export function forceAtMs (env = process.env) {
11
+ const v = String(env.QUILT_SOURCE_FORCE_AT || '').trim()
12
+ if (!v) return null
13
+ const ms = Date.parse(v)
14
+ return Number.isFinite(ms) ? ms : null
15
+ }
16
+
17
+ /**
18
+ * What the date means for a session now: null (no date, not yet, or already a quilt in its squares),
19
+ * 'stop' (a git remote, not cut over: live sync stops), or 'migrate' (no git remote: its owner's next
20
+ * open makes it a quilt without history).
21
+ */
22
+ export function forcedAction ({ at, now = Date.now(), layout = 2, remote = false, stopped = false }) {
23
+ if (stopped) return 'stop'
24
+ if (at == null || now < at || layout === 3) return null
25
+ return remote ? 'stop' : 'migrate'
26
+ }
27
+
package/src/fsutil.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import fs from 'node:fs'
2
2
  import path from 'node:path'
3
3
  import crypto from 'node:crypto'
4
- import { makeIgnore, isIgnored, scopeIgnore, IGNORE_FILES } from './pathrules.js'
4
+ import { isUtf8 } from 'node:buffer'
5
+ import { makeIgnore, isIgnored, scopeIgnore, submodulePathsIn, IGNORE_FILES } from './pathrules.js'
5
6
 
6
7
  export { IGNORE_FILES } from './pathrules.js'
7
8
  export { MAX_TEXT_BYTES, MAX_BINARY_BYTES, LARGE_FILE_BYTES, MAX_STORED_BINARY_BYTES, CHAT_FILES, isIgnored, isSafeRelPath, globMatcher, patternsOverlap } from './pathrules.js'
@@ -27,6 +28,9 @@ export function closeWatcher (w) {
27
28
  */
28
29
  export function loadIgnore (root) {
29
30
  const ig = makeIgnore()
31
+ // A submodule (.gitmodules) is a repository of its own that git keeps as one commit id: its files
32
+ // are its own git's, never the session's (nor a Seam square's, which holds it as a gitlink).
33
+ for (const p of submodulePaths(root)) ig.add(`/${p}`)
30
34
  const visit = (dirRel) => {
31
35
  const dir = path.join(root, dirRel)
32
36
  for (const file of IGNORE_FILES) {
@@ -44,6 +48,11 @@ export function loadIgnore (root) {
44
48
  return ig
45
49
  }
46
50
 
51
+ /** The folders .gitmodules names as submodules (`path = …`), as safe relative paths. */
52
+ export function submodulePaths (root) {
53
+ try { return submodulePathsIn(fs.readFileSync(path.join(root, '.gitmodules'), 'utf8')) } catch { return [] }
54
+ }
55
+
47
56
  /**
48
57
  * Resolves a relative path to an absolute one inside root, making sure no
49
58
  * symlinked parent directory points outside of it (or nowhere: a dangling
@@ -71,8 +80,9 @@ export function resolveInside (root, rel) {
71
80
  export function looksBinary (buf) {
72
81
  const n = Math.min(buf.length, 8000)
73
82
  for (let i = 0; i < n; i++) if (buf[i] === 0) return true
74
- // Invalid UTF-8 is treated as binary so it round-trips byte for byte.
75
- return !Buffer.from(buf.toString('utf8'), 'utf8').equals(buf)
83
+ // Invalid UTF-8 is treated as binary so it round-trips byte for byte. Checked in place: decoding
84
+ // and encoding every file again to compare made two copies of each one.
85
+ return !isUtf8(buf)
76
86
  }
77
87
 
78
88
  export function sha1 (buf) {
package/src/gitstate.js CHANGED
@@ -916,8 +916,44 @@ export async function committedAs (root, sha, rel, text) {
916
916
  return blob.trim() !== '' && blob.trim() === hashed.trim()
917
917
  }
918
918
 
919
- export async function blobAt (root, sha, rel) {
920
- const out = await run(root, ['cat-file', '--filters', `${sha}:${rel}`], { buffer: true })
919
+ /** Which of `paths` are links (mode 120000) at commit `sha`: a Set, or null when git can't say. */
920
+ export async function linksAt (root, sha, paths) {
921
+ if (!paths.length) return new Set()
922
+ const out = await run(root, ['ls-tree', '-z', sha, '--', ...paths])
923
+ if (out === null) return null
924
+ return new Set(out.split('\0').filter((l) => l.startsWith('120000 ')).map((l) => l.slice(l.indexOf('\t') + 1)))
925
+ }
926
+
927
+ /**
928
+ * The paths a folder's branch changed since it left the remote's default branch: `committed`, from
929
+ * the merge base of HEAD and origin/HEAD (else origin/main, origin/master) to HEAD; `uncommitted`,
930
+ * what differs from HEAD (staged or not, deletions too) and files git does not track and does not
931
+ * ignore. `gitDir` reads a .git kept elsewhere (set aside while the folder follows a square). Two
932
+ * Sets, or null when git can't say (no remote default branch, no common history).
933
+ */
934
+ export async function ownSinceFork (root, { gitDir = null } = {}) {
935
+ const opts = gitDir ? { env: { GIT_DIR: gitDir, GIT_WORK_TREE: root } } : {}
936
+ let main = ''
937
+ for (const ref of ['refs/remotes/origin/HEAD', 'refs/remotes/origin/main', 'refs/remotes/origin/master']) {
938
+ main = ((await run(root, ['rev-parse', '-q', '--verify', `${ref}^{commit}`], opts)) || '').trim()
939
+ if (main) break
940
+ }
941
+ if (!main) return null
942
+ const fork = ((await run(root, ['merge-base', 'HEAD', main], opts)) || '').trim()
943
+ if (!fork) return null
944
+ const lists = [
945
+ await run(root, ['diff', '--no-renames', '--no-ext-diff', '--name-only', '-z', fork, 'HEAD'], opts),
946
+ await run(root, ['diff', '--no-renames', '--no-ext-diff', '--name-only', '-z', 'HEAD'], opts),
947
+ await run(root, ['ls-files', '--others', '--exclude-standard', '--exclude=/.quilt/', '-z'], opts)
948
+ ]
949
+ if (lists.some((l) => l === null)) return null
950
+ const names = (l) => l.split('\0').filter(Boolean)
951
+ return { committed: new Set(names(lists[0])), uncommitted: new Set([...names(lists[1]), ...names(lists[2])]) }
952
+ }
953
+
954
+ /** A file's bytes at commit `sha` (null when git can't say); `gitDir` reads from a .git kept elsewhere (set aside while the folder follows a square). */
955
+ export async function blobAt (root, sha, rel, { gitDir = null } = {}) {
956
+ const out = await run(root, ['cat-file', '--filters', `${sha}:${rel}`], { buffer: true, ...(gitDir ? { env: { GIT_DIR: gitDir, GIT_WORK_TREE: root } } : {}) })
921
957
  return out === null ? null : out
922
958
  }
923
959
 
package/src/history.js CHANGED
@@ -256,14 +256,16 @@ export const LEGACY_DAYS = 90
256
256
 
257
257
  /**
258
258
  * The chronology once the session became a quilt at `at` (an ISO date, meta.quilt.at): entries from
259
- * before then are its legacy tail (plans/quilt-source-control/16), marked `legacy: true` for
259
+ * before then are its legacy tail (Seam's plans/quilt-source-control/16), marked `legacy: true` for
260
260
  * LEGACY_DAYS and then left out. Not a quilt (no `at`): the entries as they are.
261
261
  */
262
- export function legacyTail (entries, { at, now = Date.now() } = {}) {
262
+ export function legacyTail (entries, { at, now = Date.now(), history = false } = {}) {
263
263
  const t = at ? Date.parse(at) : NaN
264
264
  if (!Number.isFinite(t)) return entries
265
265
  if (now - t > LEGACY_DAYS * 24 * 60 * 60 * 1000) return entries.filter((e) => !(e && e.ts < t))
266
- return entries.map((e) => e && e.ts < t ? { ...e, legacy: true } : e)
266
+ // With history (the quilt is the session's imported git remote), git's own writes before then
267
+ // (pulls, first copies) are the log's imported patches: they are dropped, not shown twice.
268
+ return entries.filter((e) => !(history && e && e.ts < t && (e.pulled || e.seed))).map((e) => e && e.ts < t ? { ...e, legacy: true } : e)
267
269
  }
268
270
 
269
271
  function ago (ts, now) {
@@ -279,6 +281,14 @@ export function formatHistory (list, { withDiff = false, now = Date.now() } = {}
279
281
  if (!list.length) return 'No changes match.'
280
282
  const out = []
281
283
  for (const e of list) {
284
+ if (e.seam) {
285
+ // A commit in the quilt's log (quilt-history.js): who, which patch, its message, how many files.
286
+ const where = e.patch ? `patch ${e.patch.line && e.patch.line !== 'main' ? `${e.patch.line}:` : ''}${e.patch.no}` : `commit ${e.short || String(e.commit || '').slice(0, 12)}`
287
+ const n = e.files ? ` (${e.files} file${e.files === 1 ? '' : 's'})` : ''
288
+ out.push(`[${ago(e.ts, now)}] ${e.by} committed ${where}: ${e.message || '(no message)'}${n}${e.task ? ` [task ${e.task.id}]` : ''}`)
289
+ if (withDiff && e.diff) out.push(e.diff, '')
290
+ continue
291
+ }
282
292
  const span = e.from && e.ts - e.from > 60000 ? ` over ${Math.round((e.ts - e.from) / 60000)}m` : ''
283
293
  const what = e.detail ? ` (${e.detail})` : ''
284
294
  const task = e.task ? ` for "${e.task.title}" [${e.task.id}]` : ''