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/mcp.js CHANGED
@@ -46,7 +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 } from './session-seam.js'
49
+ import { seamGate, seamNote, seamStatus, seamToolSpec, seamOnly, seamWorkflow, seamRefusal, SEAM_SESSION_TOOLS, GIT_TOOLS } from './session-seam.js'
50
50
 
51
51
  // The board's columns, from tasks.js, so this and the hosted MCP (relay-mcp.js) never drift apart.
52
52
  const COLUMN_IDS_LIST = COLUMNS.map((c) => c.id)
@@ -119,11 +119,25 @@ export const MCP_INSTRUCTIONS =
119
119
  MAIL_GUIDE + ' ' +
120
120
  TASK_WORKFLOW
121
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
+
122
135
  export async function runMcp () {
136
+ const startQuilt = await quiltAtStart()
123
137
  const server = new McpServer(
124
138
  { name: 'quilt', version: '0.1.0' },
125
139
  // The channel capability lets Claude Code (started with the quilt channel) take inbox events as turns.
126
- { 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': {} } } }
127
141
  )
128
142
 
129
143
  // This AI session, by an id of its own, so the app can tell it from the others working through
@@ -200,10 +214,19 @@ export async function runMcp () {
200
214
  // When this person lets their AI pick up work by itself: the next task, said as it finishes one.
201
215
  const nextUp = async (d) => { try { const n = renderNextTask((await call(d, 'GET', '/duties')).next); return n ? `\n\nāž”ļø ${n}` : '' } catch { return '' } }
202
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
+ }
203
226
  const withDaemon = async (fn, { inbox = true, gate = null } = {}) => {
204
227
  const d = findDaemon(joined ? joined.dir : undefined)
205
228
  if (!d) return { content: [{ type: 'text', text: NOT_RUNNING + stale() }], isError: true }
206
- 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) : '')
207
230
  const waiting = async () => { const n = renderQueueNotice(await queuedNow(d)); return n ? `\n\nšŸ“„ ${n}` : '' }
208
231
  try {
209
232
  const refused = gate ? await gate(d) : ''
@@ -222,14 +245,42 @@ export async function runMcp () {
222
245
  return `${label ? `Assigned to ${label}.` : 'Unassigned.'}${files}`
223
246
  }
224
247
 
225
- // Seam (plans/quilt-source-control/12, 16): in a session that is a quilt, the GitHub tools
248
+ // Seam (Seam's plans/quilt-source-control/12, 16): in a session that is a quilt, the GitHub tools
226
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".
227
251
  const quiltNow = async () => {
228
252
  const d = findDaemon(joined ? joined.dir : undefined)
229
- if (!d) return null
230
- try { return (await call(d, 'GET', '/quilt')).quilt || null } catch { return null }
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() }
231
283
  }
232
- server.registerTool = seamGate(server.registerTool.bind(server), quiltNow)
233
284
 
234
285
  server.registerTool('quilt_status', {
235
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.',
@@ -237,7 +288,8 @@ export async function runMcp () {
237
288
  }, () => withDaemon(async (d) => {
238
289
  const st = await call(d, 'GET', '/status')
239
290
  const q = await quiltNow()
240
- return (q ? seamNote(q.slug || q.id) + '\n\n' : '') + renderStatus(st, { asAi: taskReader(st).asAi, mentionYours: true })
291
+ const md = renderStatus(st, { asAi: taskReader(st).asAi, mentionYours: true })
292
+ return q ? `${seamNote(q.slug || q.id)}\n\n${seamStatus(md)}` : md
241
293
  }))
242
294
 
243
295
  server.registerTool('quilt_tasks', {
@@ -292,8 +344,11 @@ export async function runMcp () {
292
344
  if (column === 'qa') patch.qaNotes = qaNotes
293
345
  if (column === 'done') patch.verified = verified
294
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) })
295
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.` : '')
296
- 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)
297
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)
298
353
  return `Moved "${task.title}" to ${columnName(task.column)}.`
299
354
  }, { gate: gates(gateFor('quilt_move_task'), column === 'done' && queueGate('move the task again')) }))
@@ -730,6 +785,8 @@ export async function runMcp () {
730
785
  readerOptions: { chatDir: process.cwd() },
731
786
  onLog: (line) => { logs.push(line); if (logs.length > 50) logs.shift() },
732
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,
733
790
  workspace
734
791
  })
735
792
  joined = { run, dir, invite: run.invite }
@@ -1052,8 +1109,8 @@ export async function runMcp () {
1052
1109
  limit: z.number().int().min(1).max(200).optional().describe('How many of the newest matching changes (default 30)')
1053
1110
  }
1054
1111
  }, ({ path: p, by, since, task, with_diff, limit }) => withDaemon(async (d) => {
1055
- const { entries } = await call(d, 'POST', '/history', { path: p, by, since, task, limit: limit || 30 })
1056
- 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}` : ''}`
1057
1114
  }))
1058
1115
 
1059
1116
  server.registerTool('quilt_partner_feed', {
@@ -1122,12 +1179,30 @@ export async function runMcp () {
1122
1179
  if (d && client && client.name) call(d, 'POST', '/agent', { client: client.name }).catch(() => {}) // says hello too
1123
1180
  }
1124
1181
 
1125
- 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
+ }
1126
1197
  // The workspace library tools arrive a moment later (the client is told the tool list
1127
1198
  // changed), so startup never waits on the API.
1128
1199
  // fromPath reads only from the project: the session's folder, or where the agent runs.
1129
1200
  const projectDirs = () => [joined?.dir, findDaemon(joined ? joined.dir : undefined)?.dir, process.env.QUILT_DIR || process.cwd()].filter(Boolean)
1130
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()
1131
1206
  }
1132
1207
 
1133
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
 
@@ -0,0 +1,113 @@
1
+ // The website dashboard's summary of now (plans/dashboard ticket 9): for one person, what is going on in
2
+ // a session this relay holds. Who is in it and what each AI is doing, the board in brief, the latest
3
+ // file changes, held files and who waits for them, and what needs this person: messages to them not
4
+ // answered, tasks for them, files someone waits for from them, and, for the owner, commit requests and
5
+ // people asking to join. The accounts API asks for it (GET /v1/me/now, every 15 to 30 seconds from the
6
+ // page) and merges what each relay machine says; nothing here is streamed or stored.
7
+ import { waitingOn } from './duties.js'
8
+ import { readTasks, activeTasks } from './tasks.js'
9
+
10
+ export const NOW_LIMITS = { people: 24, recent: 8, detailRecent: 80, held: 24, needs: 24, text: 160 }
11
+ const EDIT_FRESH_MS = 4 * 60 * 1000 // a peer's edit counts as current this long (as relay-mcp.js)
12
+
13
+ const clip = (s, n = NOW_LIMITS.text) => {
14
+ const t = String(s || '').replace(/\s+/g, ' ').trim()
15
+ return t.length > n ? `${t.slice(0, n - 1)}…` : t
16
+ }
17
+
18
+ /** The name `account` ('person:<id>') has in `room`, or '' when it isn't the owner or a member. */
19
+ export function nameIn (room, account) {
20
+ const meta = room && room.meta
21
+ if (!meta || typeof account !== 'string') return ''
22
+ if (meta.ownerSub && meta.ownerSub === account) return meta.ownerName || 'owner'
23
+ const m = meta.members && Object.prototype.hasOwnProperty.call(meta.members, account) ? meta.members[account] : null
24
+ return m && !m.chat && typeof m.name === 'string' ? m.name : ''
25
+ }
26
+
27
+ /** The files a peer edited in the last few minutes, newest first. */
28
+ function freshEdits (editing, now) {
29
+ return Object.entries(editing || {})
30
+ .filter(([, ts]) => Number.isFinite(ts) && now - ts < EDIT_FRESH_MS)
31
+ .sort((a, b) => b[1] - a[1])
32
+ .map(([p]) => p)
33
+ }
34
+
35
+ /** Who is in the room now, each with what their AI is doing. */
36
+ function peopleIn (room, now) {
37
+ const out = []
38
+ const seen = new Set()
39
+ const states = room.awareness && room.awareness.getStates ? [...room.awareness.getStates().values()] : []
40
+ for (const s of states) {
41
+ if (!s || typeof s.name !== 'string' || !s.name || seen.has(s.name)) continue
42
+ seen.add(s.name)
43
+ const ag = s.agent || {}
44
+ const working = ag.sharing !== false && (ag.status === 'working' || (s.work && s.work.state === 'working'))
45
+ out.push({
46
+ name: clip(s.name, 64),
47
+ kind: s.kind === 'agent' ? 'agent' : 'person',
48
+ tool: clip(ag.tool || s.tool || '', 40),
49
+ ai: ag.sharing === false ? 'paused' : working ? 'working' : (ag.tool ? 'idle' : ''),
50
+ focus: clip(s.focus || (s.work && s.work.state === 'working' ? s.work.note : '') || '', 120),
51
+ editing: freshEdits(s.editing, now).slice(0, 3)
52
+ })
53
+ }
54
+ for (const h of room.hostedOnline ? room.hostedOnline() : []) {
55
+ if (!h || !h.name || seen.has(h.name)) continue
56
+ seen.add(h.name)
57
+ out.push({ name: clip(h.name, 64), kind: h.kind === 'agent' ? 'agent' : 'person', tool: 'hosted', ai: '', focus: '', editing: [] })
58
+ }
59
+ return out.slice(0, NOW_LIMITS.people)
60
+ }
61
+
62
+ /**
63
+ * The summary of `room` for `account`, or null when the account isn't in it. `now` is the time;
64
+ * `detail` (one session's page) keeps more of the latest file changes, for its Activity tab.
65
+ * The room is read through what Room already offers (server.js): meta, awareness, doc, claimList, pendingList.
66
+ */
67
+ export function nowSummary (room, account, { now = Date.now(), detail = false } = {}) {
68
+ const me = nameIn(room, account)
69
+ if (!me) return null
70
+ const owner = !!(room.meta.ownerSub && room.meta.ownerSub === account)
71
+ const doc = room.doc
72
+ const chat = doc ? doc.getArray('chat').toArray() : []
73
+ const tasks = doc ? activeTasks(readTasks(doc.getMap('tasks'))) : []
74
+ const claims = room.claimList ? room.claimList() : []
75
+ const people = peopleIn(room, now)
76
+
77
+ const needs = []
78
+ for (const m of waitingOn(chat.filter((x) => x && (!x.to || x.to === me || x.by === me)), me, { now })) {
79
+ needs.push({ kind: m.kind, by: clip(m.by, 64), text: clip(m.text), at: m.ts })
80
+ }
81
+ for (const t of tasks) {
82
+ if (t.column === 'todo' && t.assignee === me && !t.forAi) needs.push({ kind: 'task', by: clip(t.by, 64), text: clip(t.title), at: t.ts, task: t.id })
83
+ }
84
+ for (const c of claims) {
85
+ if (c.by === me && c.queue && c.queue.length) needs.push({ kind: 'file', by: clip(c.queue[0].by, 64), text: clip(c.pattern, 200), at: c.queue[0].ts || c.ts, waiting: c.queue.length })
86
+ }
87
+ if (owner && doc) {
88
+ for (const r of doc.getMap('commitRequests').values()) {
89
+ if (r && r.id && r.state === 'open') needs.push({ kind: 'commit', by: clip(r.by, 64), text: clip(r.message), at: r.ts, files: Array.isArray(r.files) ? r.files.length : 0 })
90
+ }
91
+ for (const p of room.pendingList ? room.pendingList() : []) needs.push({ kind: 'join', by: clip(p.name, 64), text: p.kind === 'agent' ? 'An agent asks to join' : 'Asks to join', at: p.since, agent: p.kind === 'agent' })
92
+ }
93
+ needs.sort((a, b) => (b.at || 0) - (a.at || 0))
94
+
95
+ const byColumn = { todo: 0, doing: 0, qa: 0 }
96
+ for (const t of tasks) if (t.column in byColumn) byColumn[t.column]++
97
+ const recent = (doc ? doc.getArray('activity').toArray() : []).filter((x) => x && x.by).slice(-(detail ? NOW_LIMITS.detailRecent : NOW_LIMITS.recent)).reverse()
98
+ .map((x) => ({ by: clip(x.by, 64), kind: clip(x.kind, 20), path: clip(x.path || x.detail || '', 200), at: x.ts }))
99
+
100
+ return {
101
+ room: room.name,
102
+ name: clip(room.meta.name || '', 80),
103
+ me,
104
+ owner,
105
+ people,
106
+ working: people.filter((p) => p.ai === 'working').length,
107
+ tasks: { open: byColumn.todo + byColumn.doing + byColumn.qa, ...byColumn, mine: tasks.filter((t) => t.column !== 'done' && t.assignee === me).length },
108
+ recent,
109
+ held: claims.slice(0, NOW_LIMITS.held).map((c) => ({ path: clip(c.pattern, 200), by: clip(c.by, 64), box: !!c.box, waiting: (c.queue || []).map((q) => clip(q.by, 64)) })),
110
+ needs: needs.slice(0, NOW_LIMITS.needs),
111
+ at: now
112
+ }
113
+ }
package/src/pathrules.js CHANGED
@@ -20,6 +20,8 @@ export const CHAT_FILES = 'Chat files'
20
20
  // copies of the project.
21
21
  export const ALWAYS_IGNORED = [
22
22
  '.git', '.quilt', '.cowove', 'node_modules', '.DS_Store', 'Thumbs.db', '.claude/worktrees',
23
+ // A Seam block's own record (a folder checked out with seam checkout): Seam's, never the session's.
24
+ '.seam',
23
25
  `/${CHAT_FILES}`,
24
26
  // Each person's own Claude Code settings (permissions, Quilt's hooks) stay on their machine.
25
27
  '.claude/settings.local.json',
@@ -66,6 +68,16 @@ export function makeIgnore (texts = []) {
66
68
  }
67
69
  }
68
70
 
71
+ /** The folders a .gitmodules text names as submodules (`path = …`), as safe relative paths. */
72
+ export function submodulePathsIn (text) {
73
+ const out = []
74
+ for (const m of String(text || '').matchAll(/^\s*path\s*=\s*(.+?)\s*$/gm)) {
75
+ const p = m[1].replace(/^"(.*)"$/, '$1').replace(/\/+$/, '')
76
+ if (isSafeRelPath(p)) out.push(p)
77
+ }
78
+ return out
79
+ }
80
+
69
81
  export function isIgnored (ig, rel) {
70
82
  if (!rel || rel === '.') return false
71
83
  // Check every ancestor directory so "dist/" also excludes "dist/a/b.js".
package/src/protocol.js CHANGED
@@ -17,7 +17,7 @@ export const MSG_ADMIN = 14 // client (owner) -> relay: JSON { id, op: 'approve'
17
17
  export const MSG_MEMBERS = 15 // relay -> client: JSON { members, sessionName, pending?, reply?: { id, ok, error } }
18
18
  export const MSG_PASS = 16 // client -> relay: JSON { pass }: a fresh session pass, sent at least every 5 minutes
19
19
  export const MSG_BRANCH = 17 // client -> relay: JSON { id, op: 'join'|'confirm'|'remove', branch, base?, adopt?, sv? }
20
- export const MSG_BRANCHES = 18 // relay -> client: JSON { branches, reply?: { id, op, ok, error?, branch?, created?, base? } }
20
+ export const MSG_BRANCHES = 18 // relay -> client: JSON { branches, quilt?, shadow?, reply?: { id, op, ok, error?, branch?, created?, base? } }
21
21
 
22
22
  // Every sync message names its document: the room's (chat, tasks, the feed, commit requests,
23
23
  // activity) is '', a branch's (its files) is the branch key. Git never allows an empty branch name.
@@ -25,7 +25,15 @@ export const ROOM_DOC = ''
25
25
 
26
26
  // What this app can do, sent when it connects. The relay turns away apps without what a session needs.
27
27
  // branch-diffs: this app reads a branch document that holds only what differs from git (branch-view.js).
28
- export const FEATURES = 'large-files,branches,branch-diffs'
28
+ // quilts: this app works in a session whose files are in its squares' live documents (layout 3,
29
+ // Seam's plans/quilt-source-control/16 flag 4: sent by every app before anyone opens a block).
30
+ // quilt-folders: this app's folder follows its branch's square in place at layout 3 (session-follow.js);
31
+ // an app with `quilts` but not this (0.3.125) kept syncing to the read-only branch documents.
32
+ // quilt-moves: this app's folder follows a square whose base moves (main brought in): it re-projects onto
33
+ // the new head, says which head each edit was made on, and takes in only its own edits. The relay brings
34
+ // main into a square only when every folder on it can follow that (server.js outdatedOn); 0.3.133 and
35
+ // 0.3.134, which can but don't say so, are known by their asking to bring main in.
36
+ export const FEATURES = 'large-files,branches,branch-diffs,quilts,quilt-folders,quilt-moves'
29
37
 
30
38
  // WebSocket close codes the relay uses to refuse a client for good.
31
39
  export const CLOSE_AUTH_FAILED = 4401
@@ -0,0 +1,84 @@
1
+ // New sessions are quilts from the start (Seam's plans/quilt-source-control/16, flags order 8,
2
+ // QUILT_SOURCE_DEFAULT=1 on the accounts API): the app that starts a session (the desktop app,
3
+ // `quilt start`, or an AI's quilt_start_session; every tool goes through the same Session) makes it
4
+ // a quilt without history as soon as the folder's files have reached the relay: patch 1 holds them
5
+ // (binaries and modes too), and the room moves to layout 3 with a square for the folder.
6
+ //
7
+ // Nothing is lost when it can't: a folder with a git remote is not made a quilt here (16: a room
8
+ // with a git remote becomes a quilt only with its history, which flag 9's "Make this a quilt"
9
+ // starts), and any failure leaves the session a working git-mode session, with the reason said to
10
+ // the owner and their AIs and `quilt session migrate` to try again by hand.
11
+ import { execFileSync } from 'node:child_process'
12
+ import { apiUrl } from './account.js'
13
+
14
+ const FEATURES_TIMEOUT_MS = 5000
15
+ const SYNC_TRIES = 20
16
+ const SYNC_WAIT_MS = 500
17
+
18
+ /** Whether the accounts API at `api` has new sessions start as quilts (GET /v1/features `quilt_source_default`). False when it can't say. */
19
+ export async function quiltDefaultOn ({ api = apiUrl(), fetch: fetchImpl = globalThis.fetch } = {}) {
20
+ try {
21
+ const res = await fetchImpl(`${String(api).replace(/\/+$/, '')}/v1/features`, { signal: AbortSignal.timeout(FEATURES_TIMEOUT_MS) })
22
+ if (!res.ok) return false
23
+ const f = await res.json()
24
+ return !!(f && f.quilt_source_default === true)
25
+ } catch { return false }
26
+ }
27
+
28
+ /** Whether the folder at `root` is a git repository with a remote (its history belongs in the quilt: 16 "With history"). */
29
+ export function folderHasGitRemote (root) {
30
+ try {
31
+ return execFileSync('git', ['--no-optional-locks', 'remote'], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 10_000 }).trim().length > 0
32
+ } catch { return false }
33
+ }
34
+
35
+ const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
36
+
37
+ /**
38
+ * Makes the new session `session` a quilt (migrate, then the layout-3 switch) as its owner, once.
39
+ * Resolves to { done: true, quilt } or { done: false, why }; never throws.
40
+ */
41
+ export async function startAsQuilt (session, { flagOn = quiltDefaultOn, credential, hasRemote = folderHasGitRemote, syncTries = SYNC_TRIES, syncWaitMs = SYNC_WAIT_MS } = {}) {
42
+ const skip = (why) => ({ done: false, why })
43
+ try {
44
+ if (!(await flagOn())) return skip('off')
45
+ if (session.stopped) return skip('stopped')
46
+ if (session.quilt) return skip('already a quilt')
47
+ if (session.root && hasRemote(session.root)) {
48
+ session.log('🧵 this folder has a git remote, so the new session stays on git: it becomes a quilt with its history later (Make this a quilt).')
49
+ return skip('git remote')
50
+ }
51
+ if (session.branchForm === 'diff') return skip('diff branch')
52
+ // The folder's files are its patch 1: they must all be on the relay first.
53
+ let synced = false
54
+ for (let i = 0; i < syncTries && !synced && !session.stopped; i++) {
55
+ if (session.ready && !session.seeding) {
56
+ try { session.flushPending?.() } catch {}
57
+ try { await session.conn.confirmBranch(); synced = true; break } catch {}
58
+ }
59
+ await wait(syncWaitMs)
60
+ }
61
+ if (session.stopped) return skip('stopped')
62
+ if (!synced) throw new Error("the folder's files had not all reached the relay")
63
+ if (session.quilt) return skip('already a quilt')
64
+ const token = await credential()
65
+ if (!token) throw new Error('no sign-in to reach Seam with')
66
+ const m = await session.quiltMigrate(token)
67
+ await session.quiltSwitch(token)
68
+ // The folder equals patch 1, so the switch opened no square for it: one is opened now, as the
69
+ // owner, and the folder follows it (16: a square for the session's first folder). Should that
70
+ // fail, the session is a quilt still, and the folder's first edit asks for its square (readOnly).
71
+ for (let i = 0; i < syncTries * 4 && !session.stopped && !(session.quilt && session.quilt.layout === 3); i++) await wait(syncWaitMs / 4)
72
+ const branch = typeof session.branchKey === 'function' ? session.branchKey() : session.branch
73
+ if (session.quilt && session.quilt.layout === 3 && !(session.quilt.live || {})[branch]) {
74
+ try { await session.openBranchSquare(branch, { quiet: false, token }) } catch (err) { session.log(`🧵 couldn't open a square for this folder yet (${err.message}); its first edit asks again.`) }
75
+ }
76
+ session.log(`🧵 new session: its code is in Seam from the start (quilt ${(m.quilt && m.quilt.slug) || ''}, patch 1).`)
77
+ return { done: true, quilt: m.quilt }
78
+ } catch (err) {
79
+ const why = String((err && err.message) || err)
80
+ session.log(`🧵 this new session's code could not go into Seam (${why}); the session works as before, on git. The owner can try again with: quilt session migrate`)
81
+ if (session.notice) { try { session.notice(`This new session's code could not go into Seam (${why}), so it stays on git for now. The session owner can try again with \`quilt session migrate\`.`) } catch {} }
82
+ return skip(why)
83
+ }
84
+ }
@@ -0,0 +1,106 @@
1
+ // quilt_history in a session linked to a quilt (Seam's plans/quilt-source-control/16, step 1
2
+ // "Shadow", and after the session became a quilt): the chronology is Seam's log (the commits and
3
+ // patches, imported from git or made in Seam) plus the session's own live record of changes not
4
+ // committed yet (the branch document's `history`, history.js), oldest first, newest kept.
5
+ //
6
+ // Quilt reads Seam over its HTTP API (a client with the @heyquilt/seam quiltClient shape: `log`,
7
+ // `show`), as the member (Session.seamMember) or, on the relay, with the room's read token. When
8
+ // Seam can't be read, the session's own record is shown with a note, never an error: the tool
9
+ // worked before the link and keeps working without Seam. The same for every AI tool: this runs
10
+ // behind quilt_history in both MCP servers (mcp.js through the folder's daemon, relay-mcp.js).
11
+ import { MAX_DIFF_CHARS, queryHistory } from './history.js'
12
+
13
+ /**
14
+ * The quilt a room's chronology reads from: `{ id, slug, at?, shadow? }`, or null. A quilt the
15
+ * session became (meta.quilt_id), or the quilt it shadows while git is still the authority
16
+ * (meta.quiltShadow, 16 step 1: an import following the session's git remote). Only with Seam
17
+ * on (QUILT_SOURCE_URL), so every other session reads its own record exactly as before.
18
+ */
19
+ export function historyQuilt (meta, env = process.env) {
20
+ if (!env || !env.QUILT_SOURCE_URL || !meta) return null
21
+ if (typeof meta.quilt_id === 'string' && meta.quilt_id) {
22
+ const q = meta.quilt || {}
23
+ return { id: meta.quilt_id, slug: q.slug || '', ...(q.at ? { at: q.at } : {}) }
24
+ }
25
+ const s = meta.quiltShadow
26
+ if (s && typeof s.id === 'string' && s.id) return { id: s.id, slug: s.slug || '', shadow: true }
27
+ return null
28
+ }
29
+
30
+ /** The part of a path or glob Seam's log can filter by: a file, or the folder before the first wildcard. */
31
+ export function seamPathOf (p) {
32
+ const s = String(p || '').replace(/\\/g, '/').replace(/^\.\//, '')
33
+ if (!s) return ''
34
+ const wild = s.search(/[*?[{]/)
35
+ if (wild < 0) return s.replace(/\/+$/, '')
36
+ const dir = s.slice(0, wild)
37
+ return dir.includes('/') ? dir.slice(0, dir.lastIndexOf('/')) : ''
38
+ }
39
+
40
+ const MAX_SHOWN = 10 // commits whose diffs are read when asked for them
41
+
42
+ /**
43
+ * The quilt's log as chronology entries, oldest first: one per commit, `{ seam: true, commit,
44
+ * short, patch, by, ts, message, files, task, diff }`. `since` is a timestamp. Throws what Seam
45
+ * said when it can't be read.
46
+ */
47
+ export async function seamHistory (seam, quilt, { path = '', by, since, task, limit = 30, withDiff = false, rev = 'main' } = {}) {
48
+ const p = seamPathOf(path)
49
+ const r = await seam.log(quilt, { rev, limit: Math.min(Number(limit) || 30, 200), ...(p ? { paths: [p] } : {}), ...(by ? { author: by } : {}), ...(typeof since === 'number' ? { since: String(Math.floor(since)) } : {}), ...(task ? { task } : {}) })
50
+ const out = []
51
+ for (const c of (r && r.commits) || []) {
52
+ if (!c || !c.id) continue
53
+ const a = c.author || {}
54
+ out.push({
55
+ id: `seam:${c.id}`,
56
+ seam: true,
57
+ commit: c.id,
58
+ short: c.short || c.id.slice(0, 12),
59
+ ...(c.patch && c.patch.patch_no != null ? { patch: { line: c.patch.line || rev, no: c.patch.patch_no } } : {}),
60
+ by: String(a.name || a.member_id || ''),
61
+ ts: Number(c.at ?? c.authored_at) || 0,
62
+ message: String(c.message || '').split('\n')[0].slice(0, 200),
63
+ files: Number(c.files) || (Array.isArray(c.changes) ? c.changes.length : 0),
64
+ task: c.task_id ? { id: String(c.task_id), title: '' } : null,
65
+ path: p,
66
+ diff: ''
67
+ })
68
+ }
69
+ if (withDiff) {
70
+ for (const e of out.slice(0, MAX_SHOWN)) {
71
+ try {
72
+ const s = await seam.show(quilt, e.commit, { format: 'unified', ...(p ? { paths: [p] } : {}) })
73
+ const text = ((s && s.diff && s.diff.files) || []).map((f) => f.patch || (f.binary ? `${f.path}: binary` : '')).filter(Boolean).join('\n')
74
+ e.diff = text.length > MAX_DIFF_CHARS ? `${text.slice(0, MAX_DIFF_CHARS)}\n… (diff truncated)` : text
75
+ } catch { e.diff = '' }
76
+ }
77
+ }
78
+ return out.sort((x, y) => x.ts - y.ts)
79
+ }
80
+
81
+ /**
82
+ * The chronology of a linked session: the quilt's log (`seam`, a client) and the session's own
83
+ * record (`entries`, history.js), filtered by `query` (path, by, since timestamp, task, limit,
84
+ * withDiff), oldest first, the newest `limit` kept. A change git pulled into the session is the
85
+ * log's (an imported patch) and is left out of the session's record when the log answered, so it
86
+ * is not counted twice (16: "entries with `pulled` or `seed` are dropped", here only `pulled`:
87
+ * a seed is the session's first copy, which the log has no entry for). Returns { list, note }:
88
+ * `note` says why the log is missing, when it is.
89
+ */
90
+ export async function linkedHistory ({ seam, quilt, entries = [], query = {} }) {
91
+ const { path = '', by, since, task, limit = 30, withDiff = false } = query
92
+ let fromSeam = []
93
+ let note = ''
94
+ if (!seam) note = `Seam's log for ${quilt.slug || quilt.id} was not read: the session has no read token for it (its owner links it again); this is the session's own record.`
95
+ else {
96
+ try { fromSeam = await seamHistory(seam, quilt.id, { path, by, since, task, limit, withDiff }) } catch (err) { note = `Seam's log for ${quilt.slug || quilt.id} could not be read (${String(err.message || err).slice(0, 200)}); this is the session's own record.` }
97
+ }
98
+ return { list: mergeLinked(entries, fromSeam, { ...query, logRead: !note }), note }
99
+ }
100
+
101
+ /** The session's own entries (filtered by `query`; git's pulls left out when `logRead`) with the log's, oldest first, the newest `limit`. */
102
+ export function mergeLinked (entries, fromSeam, { path = '', by, since, task, limit = 30, logRead = true } = {}) {
103
+ const own = queryHistory(logRead ? entries.filter((e) => !(e && e.pulled)) : entries, { path, by, since, task, limit: 0 })
104
+ const list = [...own, ...(fromSeam || [])].sort((a, b) => a.ts - b.ts)
105
+ return limit && list.length > limit ? list.slice(-limit) : list
106
+ }