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/control.js CHANGED
@@ -1,5 +1,6 @@
1
1
  // Local control API (127.0.0.1 only) so the CLI and the MCP server can talk to
2
2
  // a running session. Discovery info is written to .quilt/daemon.json.
3
+ import { readAccount } from './account.js'
3
4
  import http from 'node:http'
4
5
  import fs from 'node:fs'
5
6
  import path from 'node:path'
@@ -10,6 +11,14 @@ import { createRun, waitRun, endRun, secretNames, apiCall } from './secrets-clie
10
11
  import { runMailTool, MAIL_TOOLS } from './mail-tools.js'
11
12
  import { runWakeTool } from './wake-tool.js'
12
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'
13
22
 
14
23
  export async function startControl (session, extras = {}) {
15
24
  const token = crypto.randomBytes(16).toString('hex')
@@ -21,10 +30,45 @@ export async function startControl (session, extras = {}) {
21
30
  // Step 2 of joining (wake.js): an agent that hasn't chosen how Quilt wakes it is reminded with each answer.
22
31
  const wake = wakeCheck({
23
32
  fetchWake: async () => {
24
- 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' }
25
35
  return (await apiCall(await secretsApi(), 'GET', '/v1/agents/me/wake')).wake
26
36
  }
27
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
28
72
  const routes = {
29
73
  'GET /status': (b) => {
30
74
  // To one of the AI sessions working through this app, "you" is that session (persona.js).
@@ -98,7 +142,8 @@ export async function startControl (session, extras = {}) {
98
142
  'POST /agent': (b) => { session.addAgent(b.client); return { ok: true } },
99
143
  'POST /feed': (b) => ({ entries: session.agentFeedFor(b.who, { limit: Math.min(Number(b.limit) || 40, 300) }) }),
100
144
  // The chronology: { path, by, since, task, limit } (see Session.historyQuery).
101
- '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),
102
147
  'GET /tree': () => session.tree(),
103
148
  'POST /sharing': (b) => ({ on: session.setAgentSharing(b.on !== false) }),
104
149
  'GET /commits': () => session.commitStatus({ includeMe: false }),
@@ -106,6 +151,25 @@ export async function startControl (session, extras = {}) {
106
151
  // The relay's own list, with where each branch stands with GitHub (quilt_sync_status).
107
152
  'GET /sync-status': () => ({ branches: session.branchList || [], mine: session.branchKey ? session.branchKey() : '' }),
108
153
  'POST /branches/sync': () => session.syncBranchNow(),
154
+ // `quilt session migrate --dry-run`: what making the session a quilt would do (the relay's dry run).
155
+ 'POST /quilt-plan': () => session.quiltPlan(),
156
+ // The quilt this session is (Seam), as the relay last said; null while it is not (session-seam.js).
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),
160
+ // `quilt session migrate`: the relay makes the session a quilt, as its owner (their sign-in).
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),
164
+ // `quilt session migrate --switch` / `--back`: the session's files into its squares (layout 3), or back.
165
+ 'POST /quilt-switch': () => session.quiltSwitch(process.env.SEAM_TOKEN || (readAccount() || {}).token),
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),
169
+ // `quilt session follow <folder>`: at layout 3, that folder follows this branch's square, live.
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 || '')),
109
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 }),
110
174
  // quilt_commit: the relay commits the session's copy of the files to GitHub (relay-commit.js).
111
175
  'POST /agent-commits': (b) => session.setAgentCommits(b.mode),
@@ -209,7 +273,8 @@ export async function startControl (session, extras = {}) {
209
273
  const auth = await secretsApi()
210
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 }) }
211
275
  },
212
- '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 })
213
278
  }
214
279
  const server = http.createServer(async (req, res) => {
215
280
  const reply = (code, body) => {
@@ -287,15 +352,76 @@ export function findDaemon (start = process.env.QUILT_DIR || process.cwd()) {
287
352
  }
288
353
  }
289
354
 
290
- /** `timeoutMs`: give up (throw) when the session hasn't answered by then (git's helpers: git must never wait on Quilt). */
291
- export async function call (daemon, method, route, body, { timeoutMs = 0 } = {}) {
292
- const res = await fetch(`http://127.0.0.1:${daemon.port}${route}`, {
293
- method,
294
- headers: { authorization: `Bearer ${daemon.token}`, 'content-type': 'application/json' },
295
- body: body ? JSON.stringify(body) : undefined,
296
- 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)
297
398
  })
298
- const json = await res.json()
299
- if (!res.ok) throw new Error(json.error || `HTTP ${res.status}`)
300
- 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.`
301
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
@@ -252,6 +252,22 @@ export function queryHistory (entries, { path, by, since, task, limit = 50 } = {
252
252
  return list
253
253
  }
254
254
 
255
+ export const LEGACY_DAYS = 90
256
+
257
+ /**
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 (Seam's plans/quilt-source-control/16), marked `legacy: true` for
260
+ * LEGACY_DAYS and then left out. Not a quilt (no `at`): the entries as they are.
261
+ */
262
+ export function legacyTail (entries, { at, now = Date.now(), history = false } = {}) {
263
+ const t = at ? Date.parse(at) : NaN
264
+ if (!Number.isFinite(t)) return entries
265
+ if (now - t > LEGACY_DAYS * 24 * 60 * 60 * 1000) return entries.filter((e) => !(e && e.ts < t))
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)
269
+ }
270
+
255
271
  function ago (ts, now) {
256
272
  const s = Math.max(0, Math.round((now - ts) / 1000))
257
273
  if (s < 60) return `${s}s ago`
@@ -265,10 +281,18 @@ export function formatHistory (list, { withDiff = false, now = Date.now() } = {}
265
281
  if (!list.length) return 'No changes match.'
266
282
  const out = []
267
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
+ }
268
292
  const span = e.from && e.ts - e.from > 60000 ? ` over ${Math.round((e.ts - e.from) / 60000)}m` : ''
269
293
  const what = e.detail ? ` (${e.detail})` : ''
270
294
  const task = e.task ? ` for "${e.task.title}" [${e.task.id}]` : ''
271
- out.push(`[${ago(e.ts, now)}] ${e.by} ${e.kind} ${e.path}${what}${e.pulled ? ' by pulling from git' : ''}${span}${task}`)
295
+ out.push(`[${ago(e.ts, now)}] ${e.by} ${e.kind} ${e.path}${what}${e.pulled ? ' by pulling from git' : ''}${span}${task}${e.legacy ? ' (before the session became a quilt)' : ''}`)
272
296
  if (withDiff && e.diff) out.push(e.diff, '')
273
297
  }
274
298
  return out.join('\n').replace(/\n+$/, '')
package/src/mcp.js CHANGED
@@ -46,6 +46,7 @@ import { CLASH_DESCRIPTION, clashSchema } from './relay-clash.js'
46
46
  import { describeSecretNames, SECRET_NAMES_DESCRIPTION, RUN_GUIDE } from './secret-names.js'
47
47
  import { appWorkspaces } from './app-workspaces.js'
48
48
  import { TRANSCRIPT_DESCRIPTION, renderLines } from './voice.js'
49
+ import { seamGate, seamNote, seamStatus, seamToolSpec, seamOnly, seamWorkflow, seamRefusal, SEAM_SESSION_TOOLS, GIT_TOOLS } from './session-seam.js'
49
50
 
50
51
  // The board's columns, from tasks.js, so this and the hosted MCP (relay-mcp.js) never drift apart.
51
52
  const COLUMN_IDS_LIST = COLUMNS.map((c) => c.id)
@@ -118,11 +119,25 @@ export const MCP_INSTRUCTIONS =
118
119
  MAIL_GUIDE + ' ' +
119
120
  TASK_WORKFLOW
120
121
 
122
+ /**
123
+ * The instructions for a folder in a session that is a quilt (session-seam.js): every sentence
124
+ * about git left out, and how work lands on Seam in its place. Nothing there names git or GitHub.
125
+ */
126
+ export const mcpQuiltInstructions = (quilt) => `${seamOnly(MCP_INSTRUCTIONS)} ${seamNote(quilt)}`
127
+
128
+ /** The session this folder is in, as its app says, when it answers at once: whether it is a quilt picks the instructions. */
129
+ async function quiltAtStart () {
130
+ const d = findDaemon()
131
+ if (!d) return null
132
+ try { return (await rawCall(d, 'GET', '/quilt', undefined, { timeoutMs: 1500 })).quilt || null } catch { return null }
133
+ }
134
+
121
135
  export async function runMcp () {
136
+ const startQuilt = await quiltAtStart()
122
137
  const server = new McpServer(
123
138
  { name: 'quilt', version: '0.1.0' },
124
139
  // The channel capability lets Claude Code (started with the quilt channel) take inbox events as turns.
125
- { instructions: MCP_INSTRUCTIONS, capabilities: { resources: { subscribe: true }, experimental: { 'claude/channel': {} } } }
140
+ { instructions: startQuilt && startQuilt.id ? mcpQuiltInstructions(startQuilt.slug || startQuilt.id) : MCP_INSTRUCTIONS, capabilities: { resources: { subscribe: true }, experimental: { 'claude/channel': {} } } }
126
141
  )
127
142
 
128
143
  // This AI session, by an id of its own, so the app can tell it from the others working through
@@ -199,10 +214,19 @@ export async function runMcp () {
199
214
  // When this person lets their AI pick up work by itself: the next task, said as it finishes one.
200
215
  const nextUp = async (d) => { try { const n = renderNextTask((await call(d, 'GET', '/duties')).next); return n ? `\n\n➡️ ${n}` : '' } catch { return '' } }
201
216
  const gates = (...list) => async (d) => { for (const g of list) { const r = g && await g(d); if (r) return r } return '' }
217
+ // An AI session started before its session moved into Seam (or back to git) was told the other
218
+ // way in its instructions, which never change once given: the next answer says the way now, once.
219
+ let toldQuilt = !!(startQuilt && startQuilt.id)
220
+ const moved = async () => {
221
+ const q = await quiltNow()
222
+ if (q === undefined || !!q === toldQuilt) return ''
223
+ toldQuilt = !!q
224
+ return q ? `🧵 Since you started, ${seamNote(q.slug || q.id).replace(/^This/, 'this')} Leave aside what your instructions said about how work lands.\n\n` : '🧵 Since you started, this session went back to its git branches: quilt_status says how work lands now.\n\n'
225
+ }
202
226
  const withDaemon = async (fn, { inbox = true, gate = null } = {}) => {
203
227
  const d = findDaemon(joined ? joined.dir : undefined)
204
228
  if (!d) return { content: [{ type: 'text', text: NOT_RUNNING + stale() }], isError: true }
205
- const before = async () => introduce() + (await notices(d)) + (inbox ? await arrivals(d) : '')
229
+ const before = async () => introduce() + (await moved()) + (await notices(d)) + (inbox ? await arrivals(d) : '')
206
230
  const waiting = async () => { const n = renderQueueNotice(await queuedNow(d)); return n ? `\n\n📥 ${n}` : '' }
207
231
  try {
208
232
  const refused = gate ? await gate(d) : ''
@@ -221,12 +245,51 @@ export async function runMcp () {
221
245
  return `${label ? `Assigned to ${label}.` : 'Unassigned.'}${files}`
222
246
  }
223
247
 
248
+ // Seam (Seam's plans/quilt-source-control/12, 16): in a session that is a quilt, the GitHub tools
249
+ // refuse with the Seam tool to use instead, the same for every AI tool (session-seam.js).
250
+ // undefined when the app can't say (none, or no answer): unknown, not "not a quilt".
251
+ const quiltNow = async () => {
252
+ const d = findDaemon(joined ? joined.dir : undefined)
253
+ if (!d) return undefined
254
+ let q = null
255
+ try { q = (await call(d, 'GET', '/quilt')).quilt || null } catch { return undefined }
256
+ syncSeamTools(q)
257
+ return q
258
+ }
259
+ // The git-era tools are hidden while the session is a quilt (GIT_TOOLS), and refuse with the Seam tool if called.
260
+ const gitTools = new Map()
261
+ const gated = seamGate(server.registerTool.bind(server), quiltNow)
262
+ server.registerTool = (name, spec, handler) => {
263
+ const t = gated(name, spec, handler)
264
+ if (GIT_TOOLS.has(name)) gitTools.set(name, t)
265
+ return t
266
+ }
267
+ // Seam's everyday tools, served by this server itself while the session is a quilt (none
268
+ // otherwise): the app runs them as this folder's member, on its square (control.js /seam).
269
+ // The client is told when they come and go, as with the workspace library's tools.
270
+ const seamTools = new Map()
271
+ const seamCall = (name, args) => withDaemon(async (d) => {
272
+ await quiltNow() // the session may have stopped being a quilt: the tool list follows
273
+ const r = await call(d, 'POST', '/seam', { tool: name, args: args || {} })
274
+ const text = ((r && r.content) || []).map((c) => c.text || '').join('\n')
275
+ if (r && r.isError) throw new Error(text)
276
+ return text
277
+ }, { gate: name === 'seam_commit' || name === 'seam_merge' ? gateFor(name) : null })
278
+ const syncSeamTools = (q) => {
279
+ const on = !!(q && q.id)
280
+ if (on && !seamTools.size) for (const name of SEAM_SESSION_TOOLS) seamTools.set(name, server.registerTool(name, seamToolSpec(name), (args) => seamCall(name, args)))
281
+ for (const t of seamTools.values()) if (t.enabled !== on) { if (on) t.enable(); else t.disable() }
282
+ for (const t of gitTools.values()) if (t.enabled === on) { if (on) t.disable(); else t.enable() }
283
+ }
284
+
224
285
  server.registerTool('quilt_status', {
225
286
  description: 'See who else is in the live session, what they are working on, which files they recently edited or claimed, recent messages, and which open tasks are assigned to you. Call this before starting work.',
226
287
  inputSchema: {}
227
288
  }, () => withDaemon(async (d) => {
228
289
  const st = await call(d, 'GET', '/status')
229
- return renderStatus(st, { asAi: taskReader(st).asAi, mentionYours: true })
290
+ const q = await quiltNow()
291
+ const md = renderStatus(st, { asAi: taskReader(st).asAi, mentionYours: true })
292
+ return q ? `${seamNote(q.slug || q.id)}\n\n${seamStatus(md)}` : md
230
293
  }))
231
294
 
232
295
  server.registerTool('quilt_tasks', {
@@ -281,8 +344,11 @@ export async function runMcp () {
281
344
  if (column === 'qa') patch.qaNotes = qaNotes
282
345
  if (column === 'done') patch.verified = verified
283
346
  const { task, merged, branch } = await call(d, 'POST', '/tasks/update', patch)
347
+ const q = column === 'doing' || column === 'qa' ? await quiltNow() : null
348
+ if (q && column === 'doing') return pickupBrief({ ...brief, task: { ...task, comments: brief.task.comments }, workflow: seamWorkflow(TASK_WORKFLOW) })
284
349
  if (column === 'doing') return pickupBrief({ ...brief, task: { ...task, comments: brief.task.comments } }) + (branch ? `\nYour folder is on ${branch} now (Quilt made it for this task): commit only the files you change and push there; the pull request opens by itself. Move the task to QA when it is done and tested.` : '')
285
- if (column === 'qa') return `Moved "${task.title}" to QA. Notes: ${qaNotesLine(task)}` + (task.pr ? ` Pull request #${task.pr.number} waits for a person to move the task to Done, which merges it.` : '') + await nextUp(d)
350
+ // In a quilt a pull request from before the switch is git's: the task's work lands with seam_merge.
351
+ if (column === 'qa') return `Moved "${task.title}" to QA. Notes: ${qaNotesLine(task)}` + (task.pr && !q ? ` Pull request #${task.pr.number} waits for a person to move the task to Done, which merges it.` : '') + await nextUp(d)
286
352
  if (column === 'done') return `Moved "${task.title}" to Done.${merged ? ` Merged pull request #${merged.number} (${merged.head} → ${merged.base}) as ${String(merged.sha).slice(0, 7)}.` : ''} Verified: ${verifiedLine(task)}` + await nextUp(d)
287
353
  return `Moved "${task.title}" to ${columnName(task.column)}.`
288
354
  }, { gate: gates(gateFor('quilt_move_task'), column === 'done' && queueGate('move the task again')) }))
@@ -719,6 +785,8 @@ export async function runMcp () {
719
785
  readerOptions: { chatDir: process.cwd() },
720
786
  onLog: (line) => { logs.push(line); if (logs.length > 50) logs.shift() },
721
787
  onFatal: async (err) => { logs.push(`stopped: ${err.message}`); await leave() },
788
+ // A session this agent starts is a quilt from the start when the API says so (src/quilt-default.js).
789
+ newSession: starting && !inviteServer,
722
790
  workspace
723
791
  })
724
792
  joined = { run, dir, invite: run.invite }
@@ -1041,8 +1109,8 @@ export async function runMcp () {
1041
1109
  limit: z.number().int().min(1).max(200).optional().describe('How many of the newest matching changes (default 30)')
1042
1110
  }
1043
1111
  }, ({ path: p, by, since, task, with_diff, limit }) => withDaemon(async (d) => {
1044
- const { entries } = await call(d, 'POST', '/history', { path: p, by, since, task, limit: limit || 30 })
1045
- return formatHistory(entries, { withDiff: !!with_diff })
1112
+ const { entries, note } = await call(d, 'POST', '/history', { path: p, by, since, task, limit: limit || 30, with_diff: !!with_diff })
1113
+ return `${formatHistory(entries, { withDiff: !!with_diff })}${note ? `\n\n${note}` : ''}`
1046
1114
  }))
1047
1115
 
1048
1116
  server.registerTool('quilt_partner_feed', {
@@ -1111,12 +1179,30 @@ export async function runMcp () {
1111
1179
  if (d && client && client.name) call(d, 'POST', '/agent', { client: client.name }).catch(() => {}) // says hello too
1112
1180
  }
1113
1181
 
1114
- await server.connect(new StdioServerTransport())
1182
+ const transport = new StdioServerTransport()
1183
+ await server.connect(transport)
1184
+ // A git-era tool hidden while the session is a quilt, called from a tool list read before the
1185
+ // move (a client that missed the list's change): answered with the Seam way, not "disabled".
1186
+ const onmessage = transport.onmessage
1187
+ transport.onmessage = (msg, extra) => {
1188
+ const name = msg && msg.method === 'tools/call' && msg.params && msg.params.name
1189
+ const t = name && gitTools.get(name)
1190
+ if (!t || t.enabled) return onmessage(msg, extra)
1191
+ quiltNow().then((q) => {
1192
+ const no = q && seamRefusal(name, q.slug || q.id)
1193
+ if (!no) return onmessage(msg, extra)
1194
+ return transport.send({ jsonrpc: '2.0', id: msg.id, result: { content: [{ type: 'text', text: no }], isError: true } })
1195
+ }).catch(() => onmessage(msg, extra))
1196
+ }
1115
1197
  // The workspace library tools arrive a moment later (the client is told the tool list
1116
1198
  // changed), so startup never waits on the API.
1117
1199
  // fromPath reads only from the project: the session's folder, or where the agent runs.
1118
1200
  const projectDirs = () => [joined?.dir, findDaemon(joined ? joined.dir : undefined)?.dir, process.env.QUILT_DIR || process.cwd()].filter(Boolean)
1119
1201
  addWorkspaceTools(server, { projectDirs }).catch(() => {})
1202
+ // In a session that is a quilt, Seam's tools come the same way; looked at again every half minute.
1203
+ quiltNow().catch(() => {})
1204
+ const seamTimer = setInterval(() => quiltNow().catch(() => {}), 30_000)
1205
+ seamTimer.unref()
1120
1206
  }
1121
1207
 
1122
1208
  /**
package/src/merge3.js CHANGED
@@ -79,7 +79,8 @@ export function hasMarkers (text) {
79
79
  /**
80
80
  * merge3, plus the clashes where neither side lost anything, resolved the way a person would:
81
81
  * - both sides only added lines in the same place (base had nothing there): ours, then theirs,
82
- * with lines both start (or end) with written once, so two new sections under the same
82
+ * with lines both start (or end) with written once (not a line of only brackets and
83
+ * punctuation such as `],`: each block keeps its own), so two new sections under the same
83
84
  * heading become one heading with both sections' lines;
84
85
  * - both sides changed the same one-line `import { … } from '…'` of the same module: one import
85
86
  * with every name either side has (a name either side removed stays removed).
@@ -100,12 +101,19 @@ export const mergeAdditive = eolAware(function mergeAdditive (base, ours, theirs
100
101
  return { text: out.join('\n'), conflicts }
101
102
  })
102
103
 
104
+ /**
105
+ * Whether a line both added blocks start or end with can be written once: one with words in it, or a
106
+ * blank one. A line of only brackets and punctuation (`],`, `}`, `)`) closes each block, so writing
107
+ * it once drops one block's closer (two new entries in ci/policy.json came out as invalid JSON).
108
+ */
109
+ const sharable = (line) => /\w/.test(line) || !line.trim()
110
+
103
111
  /** Two blocks inserted at one place, as one: shared leading and trailing lines once, the middles in order. */
104
112
  function addedBoth (a, b) {
105
113
  let pre = 0
106
- while (pre < a.length && pre < b.length && a[pre] === b[pre]) pre++
114
+ while (pre < a.length && pre < b.length && a[pre] === b[pre] && sharable(a[pre])) pre++
107
115
  let post = 0
108
- while (post < a.length - pre && post < b.length - pre && a[a.length - 1 - post] === b[b.length - 1 - post]) post++
116
+ while (post < a.length - pre && post < b.length - pre && a[a.length - 1 - post] === b[b.length - 1 - post] && sharable(a[a.length - 1 - post])) post++
109
117
  return [...a.slice(0, pre), ...a.slice(pre, a.length - post), ...b.slice(pre, b.length - post), ...a.slice(a.length - post)]
110
118
  }
111
119