heyquilt 0.3.122 → 0.3.140

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 (166) hide show
  1. package/RELEASES.md +279 -17
  2. package/bin/quilt.js +62 -152
  3. package/desktop/main.js +16 -2
  4. package/package.json +3 -2
  5. package/src/account.js +0 -29
  6. package/src/agent-task-workflow.js +2 -2
  7. package/src/agents/common.js +13 -0
  8. package/src/ai-vm.js +9 -1
  9. package/src/api/activity.js +5 -0
  10. package/src/api/memory-store.js +16 -38
  11. package/src/api/notify.js +134 -0
  12. package/src/api/routes/browsers.js +29 -0
  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/routes/teams.js +0 -23
  17. package/src/api/server.js +17 -25
  18. package/src/api/supabase-store.js +43 -56
  19. package/src/autowork.js +182 -16
  20. package/src/branch-view.js +6 -131
  21. package/src/connection.js +36 -9
  22. package/src/control.js +131 -48
  23. package/src/duties.js +1 -1
  24. package/src/force-date.js +27 -0
  25. package/src/fsutil.js +13 -3
  26. package/src/github-commit.js +3 -315
  27. package/src/history.js +13 -3
  28. package/src/mcp.js +93 -167
  29. package/src/merge3.js +11 -3
  30. package/src/now-summary.js +113 -0
  31. package/src/pathrules.js +12 -0
  32. package/src/protocol.js +10 -2
  33. package/src/quilt-default.js +84 -0
  34. package/src/quilt-history.js +106 -0
  35. package/src/quilt-remote.js +78 -0
  36. package/src/relay-ask.js +3 -66
  37. package/src/relay-cluster.js +3 -5
  38. package/src/relay-lease.js +6 -2
  39. package/src/relay-mcp.js +154 -432
  40. package/src/relay-prune.js +6 -145
  41. package/src/relay-turn-sync.js +1 -1
  42. package/src/release-words.js +2 -2
  43. package/src/runner.js +20 -7
  44. package/src/seam-access.js +97 -0
  45. package/src/seam-claims.js +53 -0
  46. package/src/seam-url.js +15 -0
  47. package/src/server.js +1516 -1383
  48. package/src/session-follow.js +461 -27
  49. package/src/session-layout3.js +410 -4
  50. package/src/session-migrate.js +15 -11
  51. package/src/session-quilt.js +1 -1
  52. package/src/session-seam.js +233 -20
  53. package/src/session.js +745 -3438
  54. package/src/source-live/bring-in.js +283 -0
  55. package/src/source-live/live-block.js +504 -33
  56. package/src/source-live/live-square.js +16 -5
  57. package/src/status.js +7 -58
  58. package/src/turn-sync.js +15 -2
  59. package/src/ui/agent-guide.js +1 -1
  60. package/src/ui/app.css +85 -20
  61. package/src/ui/app.js +4 -1
  62. package/src/ui/changes.js +5 -14
  63. package/src/ui/chat.js +9 -4
  64. package/src/ui/cloud-ai-model.js +13 -0
  65. package/src/ui/cloud-ai.js +10 -2
  66. package/src/ui/common.js +6 -4
  67. package/src/ui/fileview.js +20 -7
  68. package/src/ui/force-words.js +19 -0
  69. package/src/ui/git-rule.js +9 -28
  70. package/src/ui/home.js +3 -223
  71. package/src/ui/loom-model.js +15 -1
  72. package/src/ui/quiltview.js +213 -0
  73. package/src/ui/seam-model.js +251 -0
  74. package/src/ui/session.js +377 -489
  75. package/src/ui/tree.js +2 -8
  76. package/src/ui/voice-call.js +83 -14
  77. package/src/ui/voice-model.js +39 -0
  78. package/src/ui/voice.js +167 -17
  79. package/src/ui/workspaces.js +2 -122
  80. package/src/ui-server.js +81 -66
  81. package/src/update-check.js +25 -1
  82. package/src/voice.js +13 -6
  83. package/src/api/routes/github.js +0 -393
  84. package/src/branches.js +0 -194
  85. package/src/clash.js +0 -128
  86. package/src/commit-request.js +0 -59
  87. package/src/commit.js +0 -213
  88. package/src/git-hook.js +0 -48
  89. package/src/git.js +0 -147
  90. package/src/gitignore.js +0 -49
  91. package/src/gitstate.js +0 -1033
  92. package/src/quilts/AGENTS.md +0 -1
  93. package/src/quilts/CLAUDE.md +0 -1
  94. package/src/quilts/accounts.js +0 -136
  95. package/src/quilts/affected.js +0 -132
  96. package/src/quilts/archiver.js +0 -175
  97. package/src/quilts/autorevert.js +0 -49
  98. package/src/quilts/bisect-run.js +0 -31
  99. package/src/quilts/blame.js +0 -103
  100. package/src/quilts/block.js +0 -347
  101. package/src/quilts/bucket.js +0 -25
  102. package/src/quilts/cbor.js +0 -92
  103. package/src/quilts/checks.js +0 -308
  104. package/src/quilts/claims.js +0 -144
  105. package/src/quilts/cli.js +0 -536
  106. package/src/quilts/client.js +0 -137
  107. package/src/quilts/conflict-format.js +0 -190
  108. package/src/quilts/describe.js +0 -296
  109. package/src/quilts/dogfood.js +0 -82
  110. package/src/quilts/errors.js +0 -8
  111. package/src/quilts/events.js +0 -171
  112. package/src/quilts/export-git.js +0 -130
  113. package/src/quilts/export-remote.js +0 -156
  114. package/src/quilts/export.js +0 -81
  115. package/src/quilts/fastmerge.js +0 -41
  116. package/src/quilts/hash.js +0 -44
  117. package/src/quilts/history.js +0 -266
  118. package/src/quilts/ids.js +0 -12
  119. package/src/quilts/import-follow.js +0 -236
  120. package/src/quilts/import-git.js +0 -294
  121. package/src/quilts/import.js +0 -196
  122. package/src/quilts/index.js +0 -94
  123. package/src/quilts/keys.js +0 -186
  124. package/src/quilts/limits.js +0 -67
  125. package/src/quilts/lines.js +0 -49
  126. package/src/quilts/live.js +0 -50
  127. package/src/quilts/merge-smart.js +0 -114
  128. package/src/quilts/merge.js +0 -501
  129. package/src/quilts/oauth.js +0 -202
  130. package/src/quilts/objects.js +0 -144
  131. package/src/quilts/pack.js +0 -118
  132. package/src/quilts/records.js +0 -211
  133. package/src/quilts/resolutions.js +0 -69
  134. package/src/quilts/reviews.js +0 -208
  135. package/src/quilts/routes.js +0 -186
  136. package/src/quilts/runner.js +0 -95
  137. package/src/quilts/server.js +0 -144
  138. package/src/quilts/service.js +0 -963
  139. package/src/quilts/setup.js +0 -41
  140. package/src/quilts/signing.js +0 -149
  141. package/src/quilts/squares.js +0 -239
  142. package/src/quilts/store-memory.js +0 -844
  143. package/src/quilts/store-rpc.js +0 -90
  144. package/src/quilts/store-supabase.js +0 -13
  145. package/src/quilts/tags.js +0 -64
  146. package/src/quilts/textdiff.js +0 -96
  147. package/src/quilts/tokens.js +0 -66
  148. package/src/quilts/tools.js +0 -403
  149. package/src/quilts/tree.js +0 -351
  150. package/src/quilts/webhook.js +0 -116
  151. package/src/quilts/window.js +0 -121
  152. package/src/quilts/xdiff.js +0 -435
  153. package/src/relay-ci.js +0 -111
  154. package/src/relay-clash.js +0 -214
  155. package/src/relay-commit.js +0 -918
  156. package/src/relay-filekey.js +0 -46
  157. package/src/relay-git.js +0 -274
  158. package/src/relay-githttp.js +0 -166
  159. package/src/relay-github.js +0 -109
  160. package/src/relay-outbound.js +0 -1295
  161. package/src/relay-repo-moves.js +0 -43
  162. package/src/relay-sync.js +0 -795
  163. package/src/relay-upstream.js +0 -889
  164. package/src/sync-words.js +0 -42
  165. package/src/ui/branches.js +0 -111
  166. package/src/upstream.js +0 -192
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).
@@ -94,47 +137,33 @@ export async function startControl (session, extras = {}) {
94
137
  'POST /chat-link': (b) => session.createChatLink({ name: b.name, minutes: b.minutes }),
95
138
  // Owner only: how long a chat link still works. { who: name or key, minutes } -> { name, expiresAt }
96
139
  'POST /chat-link/extend': (b) => session.extendChatLink(String(b.who || ''), b.minutes),
97
- // Owner only: the relay's read-only GitHub token, for commits while everyone's offline. { token } ('' clears) -> { githubToken }
98
- 'POST /github-token': (b) => session.setGithubToken(b.token),
99
140
  'POST /agent': (b) => { session.addAgent(b.client); return { ok: true } },
100
141
  'POST /feed': (b) => ({ entries: session.agentFeedFor(b.who, { limit: Math.min(Number(b.limit) || 40, 300) }) }),
101
142
  // The chronology: { path, by, since, task, limit } (see Session.historyQuery).
102
- 'POST /history': (b) => ({ entries: session.historyQuery(b) }),
143
+ // In a session linked to a quilt (Seam), the quilt's log too (Session.historyLinked): { entries, note? }.
144
+ 'POST /history': (b) => session.historyLinked(b),
103
145
  'GET /tree': () => session.tree(),
104
146
  'POST /sharing': (b) => ({ on: session.setAgentSharing(b.on !== false) }),
105
- 'GET /commits': () => session.commitStatus({ includeMe: false }),
106
- 'GET /branches': () => ({ branches: session.status().branches, git: session.status().git }),
107
- // The relay's own list, with where each branch stands with GitHub (quilt_sync_status).
108
- 'GET /sync-status': () => ({ branches: session.branchList || [], mine: session.branchKey ? session.branchKey() : '' }),
109
- 'POST /branches/sync': () => session.syncBranchNow(),
110
147
  // `quilt session migrate --dry-run`: what making the session a quilt would do (the relay's dry run).
111
148
  'POST /quilt-plan': () => session.quiltPlan(),
112
149
  // The quilt this session is (Seam), as the relay last said; null while it is not (session-seam.js).
113
150
  'GET /quilt': () => ({ quilt: session.quilt || null }),
151
+ // One of Seam's tools (session-seam.js SEAM_SESSION_TOOLS), { tool, args }: an MCP tool result.
152
+ 'POST /seam': (b) => seamTool(b),
114
153
  // `quilt session migrate`: the relay makes the session a quilt, as its owner (their sign-in).
115
154
  'POST /quilt-migrate': () => session.quiltMigrate(process.env.SEAM_TOKEN || (readAccount() || {}).token),
155
+ // "Make this a quilt" (`quilt session migrate`, the app's button): made a quilt and moved into its squares in one go.
156
+ 'POST /quilt-make': () => session.quiltMake(process.env.SEAM_TOKEN || (readAccount() || {}).token),
116
157
  // `quilt session migrate --switch` / `--back`: the session's files into its squares (layout 3), or back.
117
158
  'POST /quilt-switch': () => session.quiltSwitch(process.env.SEAM_TOKEN || (readAccount() || {}).token),
118
- 'POST /quilt-unswitch': () => session.quiltUnswitch(),
159
+ 'POST /quilt-unswitch': (b) => session.quiltUnswitch(process.env.SEAM_TOKEN || (readAccount() || {}).token, Array.isArray(b.carried) ? b.carried : []),
160
+ // `quilt session migrate --shadow <quilt>`: link the session to the quilt an import of its git remote fills (16 step 1).
161
+ 'POST /quilt-shadow': (b) => session.quiltShadowLink(process.env.SEAM_TOKEN || (readAccount() || {}).token, b.quilt ?? null),
119
162
  // `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),
121
- '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
- // quilt_commit: the relay commits the session's copy of the files to GitHub (relay-commit.js).
123
- 'POST /agent-commits': (b) => session.setAgentCommits(b.mode),
124
- // GitHub as a node: the owner's switch, and a person adopting an agent's branch (relay-outbound.js).
125
- 'POST /github-sync': async (b) => (await session.setGithubSync(b.on !== false)).githubSync === false ? { githubSync: false } : { githubSync: true },
126
- 'POST /adopt': (b) => session.adoptBranch(String(b.from || '')),
127
- // The branch's pull requests on GitHub, and merging one, through the relay (relay-commit.js).
128
- 'POST /github/recheck': () => session.githubRecheck(),
129
- 'POST /unpublished': (b) => session.unpublished({ files: Array.isArray(b.files) ? b.files : null, task: b.task || null, column: b.column || '' }),
130
- // git in an agent's folder: what it signs in to GitHub with (`quilt git-credential`), and whether a push may go (`quilt git-pre-push`).
131
- 'POST /git/credential': (b) => session.gitCredential({ host: b.host || '' }),
132
- 'POST /git/push-check': (b) => session.pushCheck({ remote: b.remote || 'origin', refs: Array.isArray(b.refs) ? b.refs : [] }),
163
+ 'POST /quilt-follow': (b) => session.quiltFollow(null, b.dir),
164
+ // `quilt commit <message>`: at layout 3, the square this folder follows is committed, as this person.
165
+ 'POST /square-commit': (b) => session.squareCommit(String(b.message || '')),
133
166
 
134
- // A commit request committed in this folder, which has git (commit.js).
135
- 'POST /commit-request/commit': (b) => session.commitRequest(String(b.id || '')),
136
- 'GET /uncommitted': () => session.uncommittedWork(),
137
- 'POST /commit-request/done': (b) => ({ done: session.resolveCommitRequests({ ids: b.id ? [String(b.id)] : null }) }),
138
167
  'POST /work': (b) => {
139
168
  if (b.state === 'working') session.personaSays(b.via, b.note)
140
169
  const work = session.setWork(b.state, b.note)
@@ -160,17 +189,13 @@ export async function startControl (session, extras = {}) {
160
189
  session.noteTaskChange(null, task, b.via)
161
190
  return { task, tasks: session.taskList() }
162
191
  },
163
- 'POST /tasks/update': async (b) => {
192
+ 'POST /tasks/update': (b) => {
164
193
  const before = session.taskList().find((t) => t.id === b.id) || null
165
- // Done merges the task's pull request first (relay-commit.js): refused with the reason when it can't be.
166
- const finished = b.column === 'done' && before && before.column !== 'done' ? await session.finishTask(b.id) : null
167
194
  const task = session.updateTask(b)
168
195
  session.noteTaskChange(before, task, b.via)
169
196
  if (b.column === 'doing' && task) session.personaSays(b.via, task.title) // taking a task can name an AI session
170
197
  if (task && (b.column === 'doing' || b.column === 'done')) session.stitchClock(b.via, b.column === 'doing')
171
- // An agent taking a task gets a branch of its own for it (session.js branchForTask).
172
- const branch = b.column === 'doing' && task && before && before.column !== 'doing' ? await session.branchForTask(task).catch(() => null) : null
173
- return { task, tasks: session.taskList(), ...(finished && finished.merged ? { merged: finished.merged } : {}), ...(branch ? { branch } : {}) }
198
+ return { task, tasks: session.taskList() }
174
199
  },
175
200
  // What an agent gets when it picks a task up: history for its files, claims, the project's checks.
176
201
  'POST /tasks/brief': (b) => session.taskBrief(b.id),
@@ -181,10 +206,6 @@ export async function startControl (session, extras = {}) {
181
206
  return r
182
207
  },
183
208
  'POST /tasks/delete': (b) => { session.deleteTask(b.id); return { tasks: session.taskList() } },
184
- // Merges Quilt made on its own (session.js autoSettle), and files waiting for their holder to let go.
185
- 'GET /merges': () => ({ merges: session.mergeList() }),
186
- // quilt_clash: a file held because the session and GitHub changed the same lines (relay-clash.js).
187
- 'POST /clash': (b) => session.clash({ action: String(b.action || ''), path: String(b.path || ''), content: typeof b.content === 'string' ? b.content : null, checks: b.checks && typeof b.checks === 'object' ? b.checks : null, force: b.force === true }),
188
209
  'GET /rules': () => ({ text: session.projectRulesText() }),
189
210
  // Secrets (`quilt run`, quilt_secret_names): asked of the API as this session's own identity
190
211
  // (its person, or the agent it runs as). Run tokens and names only: never a value.
@@ -221,7 +242,8 @@ export async function startControl (session, extras = {}) {
221
242
  const auth = await secretsApi()
222
243
  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
244
  },
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 })
245
+ // `version` and `features` (what this app tells the relay it can do): the CLI checks them before a move (bin/quilt.js).
246
+ '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
247
  }
226
248
  const server = http.createServer(async (req, res) => {
227
249
  const reply = (code, body) => {
@@ -299,15 +321,76 @@ export function findDaemon (start = process.env.QUILT_DIR || process.cwd()) {
299
321
  }
300
322
  }
301
323
 
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
324
+ const appVersion = () => { try { return currentVersion() } catch { return '' } }
325
+
326
+ /**
327
+ * `timeoutMs`: give up (throw) when the session hasn't answered by then (git's helpers: git must never wait on Quilt).
328
+ * Without it, it waits as long as the session takes (a move into Seam can take minutes): node:http, not fetch, whose
329
+ * own limit gave up after 5 minutes with nothing but "fetch failed". A session that can't be reached, or that
330
+ * stopped while answering, throws with what happened and what to do (`unreachable`).
331
+ */
332
+ export function call (daemon, method, route, body, { timeoutMs = 0 } = {}) {
333
+ const data = body ? JSON.stringify(body) : null
334
+ return new Promise((resolve, reject) => {
335
+ let timer = null
336
+ let late = null // the timeout's error, once it fired: whatever the socket says after it
337
+ const failed = (err) => { clearTimeout(timer); reject(late || (err.name === 'TimeoutError' ? err : unreachable(daemon, err))) }
338
+ const req = http.request({
339
+ host: '127.0.0.1',
340
+ port: daemon.port,
341
+ path: route,
342
+ method,
343
+ // A connection each: a kept-alive one outlives an app that restarted, and the next call would find it cut.
344
+ agent: false,
345
+ headers: { authorization: `Bearer ${daemon.token}`, 'content-type': 'application/json', ...(data ? { 'content-length': Buffer.byteLength(data) } : {}) }
346
+ }, (res) => {
347
+ let raw = ''
348
+ res.setEncoding('utf8')
349
+ res.on('data', (c) => { raw += c })
350
+ res.on('error', failed)
351
+ res.on('end', () => {
352
+ clearTimeout(timer)
353
+ let json
354
+ try { json = JSON.parse(raw) } catch { return reject(new Error(`Quilt's app sent something that isn't an answer (HTTP ${res.statusCode})`)) }
355
+ if (res.statusCode < 200 || res.statusCode >= 300) return reject(Object.assign(new Error(json.error || `HTTP ${res.statusCode}`), { status: res.statusCode }))
356
+ resolve(json)
357
+ })
358
+ })
359
+ req.on('error', failed)
360
+ if (timeoutMs) {
361
+ timer = setTimeout(() => {
362
+ late = Object.assign(new Error(`The session in ${daemon.dir || 'this folder'} did not answer within ${Math.round(timeoutMs / 1000)} s`), { name: 'TimeoutError' })
363
+ req.destroy(late)
364
+ }, timeoutMs)
365
+ }
366
+ req.end(data || undefined)
309
367
  })
310
- const json = await res.json()
311
- if (!res.ok) throw new Error(json.error || `HTTP ${res.status}`)
312
- return json
368
+ }
369
+
370
+ /**
371
+ * The control server couldn't be reached (nothing listens: the app closed this session or restarted), or
372
+ * it closed the connection before answering (the session stopped meanwhile: left, quit, or sent away by
373
+ * the relay). `cut` tells the two apart for callers that say more (a move into Seam goes on without the app).
374
+ */
375
+ export function unreachable (daemon, err) {
376
+ const code = (err && err.code) || ''
377
+ const where = daemon && daemon.dir ? ` for ${daemon.dir}` : ''
378
+ const cut = code !== 'ECONNREFUSED'
379
+ const msg = cut
380
+ ? `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.`
381
+ : `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.`
382
+ return Object.assign(new Error(msg), { unreachable: true, cut, code })
383
+ }
384
+
385
+ /**
386
+ * Why `quilt session migrate` (or --switch, --back) must not start through this app, from its GET /info,
387
+ * or null. The move ends by sending away every app that can't follow a square (no `quilt-folders`): the
388
+ * owner's own app too, cutting this command's answer off mid-way. An app older than this check says
389
+ * neither its features nor its version; `cliVersion` is the quilt command's own.
390
+ */
391
+ export function moveRefusal (info, cliVersion = '') {
392
+ const features = info && typeof info.features === 'string' ? info.features.split(',') : null
393
+ if (features && features.includes('quilt-folders')) return null
394
+ const was = info && info.version ? ` (${info.version})` : ''
395
+ 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
396
  }
package/src/duties.js CHANGED
@@ -15,7 +15,7 @@
15
15
  //
16
16
  // Messages are chat entries ({ id, by, to, text, ts }) the reader can see.
17
17
  import { mentionsMe, mentioned, ALL_AGENTS } from './inbox.js'
18
- import { RELAY_BY } from './branches.js'
18
+ import { RELAY_NAME as RELAY_BY } from './ui/chat.js'
19
19
  import { RELAY_OLD_NAMES } from './ui/chat.js'
20
20
 
21
21
 
@@ -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) {