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
@@ -0,0 +1,78 @@
1
+ // "Make this a quilt" for a session with a git remote (Seam's plans/quilt-source-control/16, flag 9, and
2
+ // "An opt-in team's migration"): the steps the owner's one button runs, in order, each time it is
3
+ // clicked, from where the session stands. Pure: no I/O (test/unit/quilt-remote.test.js).
4
+ //
5
+ // 1. import Seam imports the GitHub repository and follows it (the owner's Seam GitHub App
6
+ // installation is the read credential; 16 step 1). The session stays git-mode.
7
+ // 2. link Once the import follows, the session reads the quilt's log (the shadow link).
8
+ // 3. cut-over In the owner's sitting: main must take pushes from Seam's export only (a ruleset only
9
+ // the repository's GitHub owner can add: plain steps), then the import is cut over
10
+ // with export to the same remote on, then the session moves in with its history.
11
+
12
+ export const SEAM_APP = { id: 5475977, slug: 'seam-by-heyquilt' }
13
+
14
+ /** owner/name of the session's GitHub remote, from its upstreams (the default branch's first), or null. */
15
+ export function githubRepoOf (upstreams, defaultKey) {
16
+ const all = Object.entries(upstreams || {})
17
+ const pick = all.find(([k]) => k === defaultKey) || all[0]
18
+ const m = pick && /^github\.com\/([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+?)(\.git)?$/.exec(String((pick[1] && pick[1].repo) || ''))
19
+ return m ? m[1] : null
20
+ }
21
+
22
+ /** Which of the owner's Seam GitHub installations reaches `repo`, or null. */
23
+ export function installationFor (gh, repo) {
24
+ const want = String(repo).toLowerCase()
25
+ return ((gh && gh.installations) || []).find((i) => (i.repos || []).some((r) => String(r.full_name || '').toLowerCase() === want)) || null
26
+ }
27
+
28
+ /** The step the session is at: 'import' (nothing yet), 'link' (importing), 'cutover' (linked to the quilt that follows). */
29
+ export function remoteStage ({ quiltImport = null, quiltShadow = null }) {
30
+ if (quiltShadow) return 'cutover'
31
+ if (quiltImport) return 'link'
32
+ return 'import'
33
+ }
34
+
35
+ /** Whether GitHub's rules for main (GET /repos/:o/:r/rules/branches/main) keep everyone but an app from pushing to it. */
36
+ export function mainGuarded (rules) {
37
+ return Array.isArray(rules) && rules.some((r) => r && r.type === 'update')
38
+ }
39
+
40
+ /** What the repository's GitHub owner does so only Seam pushes to main (16's cut-over checklist, step 5). */
41
+ export function rulesetSteps (repo) {
42
+ return [
43
+ `On GitHub, open https://github.com/${repo}/settings/rules and choose New ruleset, then New branch ruleset.`,
44
+ 'Name it "Seam is the authority on main" and set Enforcement status to Active.',
45
+ `Under Bypass list, add the app ${SEAM_APP.slug} (Seam), set to Always allow.`,
46
+ 'Under Target branches, add the default branch and the pattern release/**.',
47
+ 'Turn on Restrict updates, Restrict deletions and Block force pushes, then Create.',
48
+ 'Ask everyone to stop pushing to main and wait for CI\'s last run there. Then click Move into Seam now.'
49
+ ]
50
+ }
51
+
52
+ /** The ruleset as GitHub's API takes it (for an owner who prefers `gh api -X POST repos/<repo>/rulesets --input`). */
53
+ export function rulesetBody () {
54
+ return {
55
+ name: 'Seam is the authority on main',
56
+ target: 'branch',
57
+ enforcement: 'active',
58
+ bypass_actors: [{ actor_id: SEAM_APP.id, actor_type: 'Integration', bypass_mode: 'always' }],
59
+ conditions: { ref_name: { include: ['~DEFAULT_BRANCH', 'refs/heads/release/**'], exclude: [] } },
60
+ rules: [{ type: 'update' }, { type: 'deletion' }, { type: 'non_fast_forward' }]
61
+ }
62
+ }
63
+
64
+ /** What the owner does when no Seam GitHub installation of theirs reaches the repository. */
65
+ export function installSteps (repo, installUrl = `https://github.com/apps/${SEAM_APP.slug}/installations/new`) {
66
+ return [
67
+ 'Sign in to Seam at https://seam.heyquilt.com with the same account you use for Quilt.',
68
+ `Connect GitHub there and install the Seam app on ${repo} (${installUrl}). Seam reads the repository with it, and later pushes main to it.`,
69
+ 'Then click Make this a quilt again.'
70
+ ]
71
+ }
72
+
73
+ /** One error whose message is the plain steps, with the steps alone in `steps` (the app lists them). */
74
+ export function stepsError (lead, steps) {
75
+ const e = new Error(`${lead}\n${steps.map((s, i) => `${i + 1}. ${s}`).join('\n')}`)
76
+ e.steps = steps
77
+ return e
78
+ }
@@ -17,6 +17,7 @@ import {
17
17
  } from './github-commit.js'
18
18
  import { readTasks, addTask, updateTask } from './tasks.js'
19
19
  import { addComment } from './task-comments.js'
20
+ import { quiltOf } from './session-seam.js'
20
21
 
21
22
  export { CommitRefused }
22
23
 
@@ -566,6 +567,8 @@ export const mergeSchema = (z) => ({
566
567
  * connection (or the owner's token) covers the repository. Cheap: from the relay's record.
567
568
  */
568
569
  export function agentsCanCommit (room, key) {
570
+ // A session that is a quilt has no GitHub path for anyone's work: it lands with Seam (session-seam.js).
571
+ if (quiltOf(room.meta)) return false
569
572
  if (agentCommitsOf(room) === 'off') return false
570
573
  const found = repoRecordFor(room, key)
571
574
  if (!found) return false
@@ -676,6 +679,8 @@ export async function finishTaskFor (room, { who, key, task: taskId }, { fetch =
676
679
  const map = room.doc.getMap('tasks')
677
680
  const task = readTasks(map).find((t) => t.id === taskId)
678
681
  if (!task) return { merged: null }
682
+ // In a quilt a task's work lands with seam_merge: a pull request from before the switch is git's, and stays as it is.
683
+ if (quiltOf(room.meta)) return { merged: null }
679
684
  // A clash task (clash.js) is the merge itself.
680
685
  if (Object.values((room.meta && room.meta.upstreams) || {}).some((rec) => rec && rec.clash && rec.clash.task === taskId)) return { merged: null }
681
686
  if (!agentsCanCommit(room, key) && !task.pr) return { merged: null }
@@ -1,5 +1,5 @@
1
1
  // One live session per repository and branch (plan §8, addendum 16.6). The session that pushes
2
- // repo@branch holds its lease in the accounts API (git_sync_leases), which works across relay
2
+ // repo@branch holds its lease in the accounts API (relay_leases), which works across relay
3
3
  // machines; the relay renews every lease it holds every LEASE_HEARTBEAT_MS, for every session active
4
4
  // in the last 24 hours (loaded or not), and keeps the list on its disk so a restart picks them up.
5
5
  // A lease lapses LEASE_TTL_MS after its last heartbeat (two missed: a crashed relay's).
@@ -52,9 +52,13 @@ function localStore (now) {
52
52
  }
53
53
  }
54
54
 
55
+ /** A lease's key in the accounts API (relay_leases): "<repo> <branch>". */
56
+ export const leaseKeyOf = (repo, branch) => `${repo} ${branch}`
57
+
55
58
  function apiStore ({ apiUrl, secret, fetch }) {
56
59
  const call = async (what, body) => {
57
- const res = await fetch(`${String(apiUrl).replace(/\/+$/, '')}/v1/relay/leases/${what}`, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${secret}` }, body: JSON.stringify(body) })
60
+ // `key` (relay_leases, 16 R2) beside repo and branch, which an API from before R2 reads instead: the same lease either way.
61
+ const res = await fetch(`${String(apiUrl).replace(/\/+$/, '')}/v1/relay/leases/${what}`, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${secret}` }, body: JSON.stringify({ key: leaseKeyOf(body.repo, body.branch), ...body }) })
58
62
  if (!res.ok) throw new Error(`the accounts API answered ${res.status}`)
59
63
  return res.json()
60
64
  }
package/src/relay-mcp.js CHANGED
@@ -37,6 +37,7 @@ import { agentCommitsOf, repoRecordFor, POLICY_WORDS, pullRequestsFor, mergeForM
37
37
  import { UpdateCheck } from './update-check.js'
38
38
  import { TASK_WORKFLOW, pickupBrief, doneRefusal, verifiedEnough, verifiedLine, qaRefusal, qaNotesEnough, qaNotesLine, pickChecklist, MAX_VERIFIED } from './agent-task-workflow.js'
39
39
  import { HistoryLog, queryHistory, parseSince, formatHistory, currentTask, lineDiff, legacyTail } from './history.js'
40
+ import { linkedHistory } from './quilt-history.js'
40
41
  import { changeRefusal, TALK_REFUSED } from './session-access.js'
41
42
  import { MIGRATE_SESSION_DESCRIPTION, describeQuiltPlan } from './session-quilt.js'
42
43
  import { chatAbout, renderChatAbout, waitingOn, needsNoReply, renderUnanswered, heldRefusal, queuedFor, renderQueueNotice, renderQueued, answered, unaddressed, CHAT_RULES, RE_HELP, replyFor, renderReplied, PERSON_CHAT, PERSON_INBOX } from './duties.js'
@@ -48,7 +49,11 @@ import { isDiff, hasEntry, listPaths, readPath, readPaths, textOf, notePresent,
48
49
  import { hostedRooms, hostedIn, newHosted } from './hosted-agents.js'
49
50
  import { turnSyncStep, ROOM_KEY } from './relay-turn-sync.js'
50
51
  import { describeSecretNames, SECRET_NAMES_DESCRIPTION } from './secret-names.js'
51
- import { quiltOf, seamRefusal, seamNote } from './session-seam.js'
52
+ import { quiltOf, seamRefusal, seamNote, seamStatus, seamFileRefusal, seamToolSpec, runSessionSeamTool, seamOnly, seamWorkflow, SEAM_SESSION_TOOLS, SEAM_GUIDE_HOSTED, GIT_TOOLS } from './session-seam.js'
53
+ import { seamAccess } from './seam-access.js'
54
+ import { releaseInSeam } from './seam-claims.js'
55
+ import { releaseWords } from './release-words.js'
56
+ import { LAYOUT_QUILT } from './session-layout3.js'
52
57
  import { TRANSCRIPT_DESCRIPTION, renderLines, readLines, inVoice, transcribeSetting, voiceStatusLines } from './voice.js'
53
58
 
54
59
  const FEED_CAP = 300
@@ -154,10 +159,17 @@ function takeStock (doc, me) {
154
159
  const linkInboxes = new WeakMap()
155
160
  const text = (t) => ({ content: [{ type: 'text', text: t }] })
156
161
  const fail = (t) => ({ content: [{ type: 'text', text: t }], isError: true })
162
+ // A session that is a quilt, by its quilt's name (its slug; its id before the relay knows one), or null.
163
+ const quiltName = (room) => { const q = quiltOf(room.meta); return q ? ((room.meta.quilt && room.meta.quilt.slug) || q) : null }
157
164
  // quilt_status in a session that is a quilt starts with how work goes there (session-seam.js).
165
+ // Any other answer, the first in a session that is a quilt for this agent: its instructions may be from before the move.
166
+ const withSeamLead = (r, quilt) => {
167
+ if (r && typeof r.then === 'function') return r.then((x) => withSeamLead(x, quilt))
168
+ return r && Array.isArray(r.content) ? { ...r, content: [{ type: 'text', text: `🧵 ${seamNote(quilt, { hosted: true })} This is how work lands here, whatever your instructions said before.` }, ...r.content] } : r
169
+ }
158
170
  const withSeamNote = (r, quilt) => {
159
171
  if (r && typeof r.then === 'function') return r.then((x) => withSeamNote(x, quilt))
160
- return r && Array.isArray(r.content) ? { ...r, content: [{ type: 'text', text: seamNote(quilt) }, ...r.content] } : r
172
+ return r && Array.isArray(r.content) ? { ...r, content: [{ type: 'text', text: seamNote(quilt, { hosted: true }) }, ...r.content.map((c) => c && c.type === 'text' ? { ...c, text: seamStatus(c.text) } : c)] } : r
161
173
  }
162
174
 
163
175
  function ago (ts) {
@@ -215,7 +227,8 @@ function sessionTools (server, ctx) {
215
227
  // The branch document the agent works on (its store entry), or null for a room without branch documents.
216
228
  // The session's default branch may always start; any other new branch only for someone who may change files.
217
229
  const branchEntry = (room) => {
218
- if (!room.branchDoc) return null
230
+ // At layout 3 there are no branch documents to work on: every tool but the file tools works on the room.
231
+ if (!room.branchDoc || room.meta.layout === LAYOUT_QUILT) return null
219
232
  const key = branchOf(room)
220
233
  const editor = room.resolveKey(key) === room.defaultKey || ctx.access(room)?.role !== 'viewer'
221
234
  return room.branchDoc(key, { by: me, editor })
@@ -224,13 +237,16 @@ function sessionTools (server, ctx) {
224
237
  // Hosted agents are in several sessions at once: `session` says which this call is about.
225
238
  const { session, ...args } = given || {}
226
239
  return stale(await ctx.withSession((room) => {
227
- // A hosted agent with no branch of its own is pinned to the active branch by its first call
228
- // on files, so a read and the write after it never land on two different branches.
229
- if (ctx.pin && FILE_TOOLS.has(name)) ctx.pin(room)
230
240
  // A session that is a quilt has no GitHub path for agents: each GitHub tool names its Seam tool.
231
- const quilt = quiltOf(room.meta)
232
- const refused = seamRefusal(name, quilt)
241
+ // At layout 3 its files are in squares, not branches: the file tools name Seam's (quilt_history reads Seam's log, quilt_project_rules main's rules).
242
+ const quilt = quiltName(room)
243
+ // quilt_release lets go of the agent's claims in Seam then (seam-claims.js), when it reaches Seam.
244
+ const seamRelease = name === 'quilt_release' && !!(ctx.seam && ctx.seam())
245
+ const refused = seamRefusal(name, quilt, { hosted: true }) || (quilt && room.meta.layout === LAYOUT_QUILT && FILE_TOOLS.has(name) && name !== 'quilt_history' && name !== 'quilt_project_rules' && !seamRelease ? seamFileRefusal(quilt) : null)
233
246
  if (refused) return fail(refused)
247
+ // A hosted agent with no branch of its own is pinned to the active branch by its first call
248
+ // on files, so a read and the write after it never land on two different branches.
249
+ if (ctx.pin && FILE_TOOLS.has(name) && room.meta.layout !== LAYOUT_QUILT) ctx.pin(room)
234
250
  // The audit trail: which tool, on what (a path, a pattern or a task id; never contents).
235
251
  const caller = ctx.who(room)
236
252
  if (caller && caller.id && room.audit) {
@@ -247,7 +263,9 @@ function sessionTools (server, ctx) {
247
263
  room.auditAs = caller && caller.id
248
264
  try {
249
265
  const r = queueNote(room, fn(args || {}, parts))
250
- return name === 'quilt_status' && quilt ? withSeamNote(r, quilt) : r
266
+ // The first answer in a session that is a quilt says how work lands there (quilt_status always does).
267
+ const once = quilt && ctx.seamOnce ? ctx.seamOnce(room) : false
268
+ return name === 'quilt_status' && quilt ? withSeamNote(r, quilt) : once ? withSeamLead(r, quilt) : r
251
269
  } finally { room.auditAs = prev }
252
270
  }, session))
253
271
  })
@@ -356,11 +374,18 @@ function sessionTools (server, ctx) {
356
374
  with_diff: z.boolean().optional().describe('Include each change\'s diff (capped per change)'),
357
375
  limit: z.number().int().min(1).max(200).optional().describe('How many of the newest matching changes (default 30)')
358
376
  }
359
- }, ({ path: p, by, since, task, with_diff, limit }, { room }) => {
377
+ }, async ({ path: p, by, since, task, with_diff, limit }, { room }) => {
360
378
  const from = parseSince(since)
361
379
  if (from === undefined) return fail('since: use a duration like 2h or 3d, "today", "yesterday", or a date.')
362
380
  const q = process.env.QUILT_SOURCE_URL && room.meta && room.meta.quilt_id ? room.meta.quilt : null
363
- const list = queryHistory(legacyTail(historyOf(room).entries(), { at: q && q.at }), { path: p ? cleanPath(p) : '', by, since: from ?? undefined, task, limit: limit || 30 })
381
+ const own = legacyTail(historyOf(room).entries(), { at: q && q.at, history: !!(q && q.history) })
382
+ // Linked to a quilt (16: the session became one, or shadows one in step 1): Seam's log plus this record.
383
+ const link = room.quiltHistoryLink ? room.quiltHistoryLink() : null
384
+ if (link) {
385
+ const r = await linkedHistory({ seam: link.seam, quilt: link.quilt, entries: own, query: { path: p ? cleanPath(p) : '', by, since: from ?? undefined, task, limit: limit || 30, withDiff: !!with_diff } })
386
+ return text(`${formatHistory(r.list, { withDiff: !!with_diff })}${r.note ? `\n\n${r.note}` : ''}`)
387
+ }
388
+ const list = queryHistory(own, { path: p ? cleanPath(p) : '', by, since: from ?? undefined, task, limit: limit || 30 })
364
389
  return text(formatHistory(list, { withDiff: !!with_diff }))
365
390
  })
366
391
 
@@ -428,7 +453,7 @@ function sessionTools (server, ctx) {
428
453
  return text(`${branchesMarkdown(br, { limit: 40 })}\n\n${BRING_IN_NOTE}`)
429
454
  })
430
455
 
431
- // Seam (plans/quilt-source-control/16), behind QUILT_SOURCE_URL: what making this session a
456
+ // Seam (Seam's plans/quilt-source-control/16), behind QUILT_SOURCE_URL: what making this session a
432
457
  // quilt would do. Only the dry run: the move itself is the owner's, from the app.
433
458
  if (process.env.QUILT_SOURCE_URL) {
434
459
  tool('quilt_migrate_session', {
@@ -490,6 +515,8 @@ function sessionTools (server, ctx) {
490
515
  : pickChecklist(files.get('AGENTS.md')?.toString(), files.get('CLAUDE.md')?.toString())
491
516
  /** The project's rules (project-rules.js) on the agent's branch, a diff branch's from git where the session left them. */
492
517
  const rulesFor = async (room, branch, files) => {
518
+ // In squares (layout 3): main's rules on Seam, read with the room's read token (the branch documents are as they were at the move).
519
+ if (room.meta.layout === LAYOUT_QUILT && quiltOf(room.meta)) return rulesOnSeam(room)
493
520
  if (!branch || !isDiff(branch)) return projectRules({ list: [...files.keys()], read: (p) => files.get(p)?.toString() ?? null })
494
521
  const list = [...(await listPaths(room, branch.key, branch)).keys()]
495
522
  const got = new Map()
@@ -502,6 +529,20 @@ function sessionTools (server, ctx) {
502
529
  }
503
530
  return projectRules({ list, read: (p) => got.get(p) ?? null })
504
531
  }
532
+ const rulesOnSeam = async (room) => {
533
+ const seam = room.quiltReadClient ? room.quiltReadClient() : null
534
+ if (!seam) throw new Error(`The session has no read token for ${quiltName(room)}, so its rules can't be read here: its owner links it to Seam again. Meanwhile read AGENTS.md with seam_cat.`)
535
+ const q = room.meta.quilt_id
536
+ const list = ((await seam.tree(q, 'main')).entries || []).filter((e) => e && e.path && e.mode !== 'dir').map((e) => e.path)
537
+ const got = new Map()
538
+ for (let round = 0; round < 4; round++) {
539
+ const asked = new Set()
540
+ const r = projectRules({ list, read: (p) => { if (got.has(p)) return got.get(p); asked.add(p); return null } })
541
+ if (!asked.size) return r
542
+ for (const p of asked) got.set(p, await seam.cat(q, 'main', p).then((x) => Buffer.from(x.content || '', 'base64').toString('utf8'), () => null))
543
+ }
544
+ return projectRules({ list, read: (p) => got.get(p) ?? null })
545
+ }
505
546
  tool('quilt_move_task', {
506
547
  description: 'Move a task on the shared board. "doing" when you start it: you get a briefing (its files, recent changes to them, claims, the project\'s checks). ' +
507
548
  '"qa" when you finish implementing and testing: requires `qaNotes` (what changed and how you self-validated); without it the move is refused. ' +
@@ -541,6 +582,7 @@ function sessionTools (server, ctx) {
541
582
  const all = historyOf(room).entries()
542
583
  const history = (tf.length ? all.filter((e) => tf.includes(e.path)) : all).slice(-8)
543
584
  const claims = claimsOf(room).filter((c) => !tf.length || tf.some((f) => globMatcher(c.pattern)(f)))
585
+ if (quiltOf(room.meta)) return text(pickupBrief({ task: { ...task, comments: readComments(doc.getMap('taskComments'), task.id) }, history, claims, checklist: await checklistOf(files, room, null), me, workflow: seamWorkflow(TASK_WORKFLOW, { hosted: true }) }))
544
586
  const onto = branchForHostedTask(room, task)
545
587
  return text(pickupBrief({ task: { ...task, comments: readComments(doc.getMap('taskComments'), task.id) }, history, claims, checklist: await checklistOf(files, room, branchEntry(room)), me }) + (onto ? `\nYou are on ${onto} now (Quilt made it for this task, a copy of ${room.defaultKey}'s files): write your files here and quilt_git_commit them; the pull request opens by itself. Move the task to QA when it is done and tested.` : ''))
546
588
  }
@@ -1166,10 +1208,24 @@ function sessionTools (server, ctx) {
1166
1208
  return text(`Claimed ${pattern.trim()}. Release it with quilt_release when done.`)
1167
1209
  })
1168
1210
 
1211
+ const releaseHostedInSeam = async (room, pattern) => {
1212
+ const quilt = quiltName(room) && room.meta.quilt_id
1213
+ const access = ctx.seam && ctx.seam()
1214
+ if (!quilt || !access) return fail(seamFileRefusal(quiltName(room)))
1215
+ try {
1216
+ const asked = room.claimRequest(who(room), { op: 'quilt-claims' })
1217
+ const { gone, ...r } = await releaseInSeam({ access, api: process.env.QUILT_SOURCE_URL, quilt, asked, pattern: pattern ? pattern.trim() : '*' })
1218
+ if (gone.length) room.claimRequest(who(room), { op: 'quilt-released', ids: gone })
1219
+ return text(releaseWords(r, { cli: 'quilt_release' }))
1220
+ } catch (err) { return fail(err.message) }
1221
+ }
1222
+
1169
1223
  tool('quilt_release', {
1170
1224
  description: 'Release a claim (or all of your claims when no pattern is given).',
1171
1225
  inputSchema: { pattern: z.string().optional() }
1172
1226
  }, ({ pattern }, { room }) => {
1227
+ // At layout 3 the claims are Seam's: the agent lets go of its own there, as itself.
1228
+ if (room.meta.layout === LAYOUT_QUILT) return releaseHostedInSeam(room, pattern)
1173
1229
  // Releasing everything keeps boxes: a box opens when it's committed (quilt_git_commit) or unpacked (quilt_unpack).
1174
1230
  const mine = claimsOf(room).filter((c) => c.by === me && (pattern ? c.pattern === pattern.trim() : !c.box))
1175
1231
  // A file someone waits for is handed off, not released.
@@ -1430,6 +1486,13 @@ export function hostedWorkspaceAccess ({ apiUrl, pass, fetch: fetchImpl = global
1430
1486
  return { call, fetchBytes: bytesFetcher(fetchImpl, { timeoutMs: WORKSPACE_TRANSFER_TIMEOUT_MS }), put, saveDir: null, readLocal: null }
1431
1487
  }
1432
1488
 
1489
+ /** A request's JSON body, read here (the transport takes it parsed). */
1490
+ async function readJsonBody (req) {
1491
+ const chunks = []
1492
+ for await (const c of req) chunks.push(c)
1493
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'))
1494
+ }
1495
+
1433
1496
  async function serve (mcp, req, res, body) {
1434
1497
  const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true })
1435
1498
  res.on('close', () => { transport.close().catch(() => {}); mcp.close().catch(() => {}) })
@@ -1446,11 +1509,30 @@ async function serve (mcp, req, res, body) {
1446
1509
  */
1447
1510
  export async function handleHostedMcp ({ req, res, pass, relay, workspaces = null, secrets = null, body = undefined }) {
1448
1511
  const image = String(req.headers['x-quilt-image'] || '').trim()
1449
- // The library's guide is part of the instructions only when its tools are offered.
1450
- const mcp = new McpServer({ name: 'quilt', version: '0.2.0' }, { instructions: (workspaces ? `${HOSTED_INSTRUCTIONS}\n\n${WORKSPACE_GUIDE_HOSTED}` : HOSTED_INSTRUCTIONS) })
1451
1512
  const me = pass.name
1452
1513
  const account = `${pass.kind}:${pass.sub}`
1453
1514
  const touched = new Set()
1515
+ // Whether the agent's sessions on this machine are quilts (some, all): an agent whose sessions all
1516
+ // are reads only Seam's way, with no git in its instructions or tool list (session-seam.js).
1517
+ const quilts = (() => {
1518
+ let any = false; let all = true; let seen = 0
1519
+ for (const name of hostedRooms(relay.hosted.get(account))) {
1520
+ if (relay.roomEnded(name)) continue
1521
+ const room = relay.getRoom(name)
1522
+ if (!room || !room.exists) continue
1523
+ touched.add(room)
1524
+ seen++
1525
+ if (quiltOf(room.meta)) any = true; else all = false
1526
+ }
1527
+ return { any, all: any && all && seen > 0 }
1528
+ })()
1529
+ // The library's guide is part of the instructions only when its tools are offered.
1530
+ const base = quilts.all ? `${seamOnly(HOSTED_INSTRUCTIONS)} In a session that is a quilt: ${SEAM_GUIDE_HOSTED}` : HOSTED_INSTRUCTIONS
1531
+ const mcp = new McpServer({ name: 'quilt', version: '0.2.0' }, { instructions: (workspaces ? `${base}\n\n${WORKSPACE_GUIDE_HOSTED}` : base) })
1532
+ // Every tool it registers, so the git-era ones can be left out for an agent whose sessions are all quilts.
1533
+ const registered = new Map()
1534
+ const register = mcp.registerTool.bind(mcp)
1535
+ mcp.registerTool = (name, spec, handler) => { const t = register(name, spec, handler); registered.set(name, t); return t }
1454
1536
  const done = () => { for (const room of touched) if (room && !room.conns.size && room.onEmpty) room.onEmpty() }
1455
1537
  res.on('close', done)
1456
1538
  // Which sessions the agent is in, for the accounts API (x-quilt-rooms), and which one this
@@ -1548,6 +1630,8 @@ export async function handleHostedMcp ({ req, res, pass, relay, workspaces = nul
1548
1630
  multi: true, // every tool takes `session`
1549
1631
  who: () => ({ name: me, id: account }),
1550
1632
  kind: pass.kind === 'agent' ? 'agent' : 'human',
1633
+ // This agent to Seam (its pass, traded for an agent token), for what it does there as itself.
1634
+ seam: () => process.env.QUILT_SOURCE_URL && pass.kind === 'agent' && req.headers['x-quilt-pass'] ? hostedSeam(account, String(req.headers['x-quilt-pass'])) : null,
1551
1635
  access: (room) => room.hostedAccess(pass),
1552
1636
  branch: (room) => room.hostedBranch(account),
1553
1637
  setBranch: (room, key, opts) => room.setHostedBranch(account, key, opts),
@@ -1588,6 +1672,14 @@ export async function handleHostedMcp ({ req, res, pass, relay, workspaces = nul
1588
1672
  } : null,
1589
1673
  updates: relay.updates || null,
1590
1674
  image: () => image,
1675
+ // Whether this agent is yet to be told the Seam way in `room` (once per quilt, kept with its record there).
1676
+ seamOnce: (room) => {
1677
+ const r = hostedIn(relay.hosted.get(account), room.name)
1678
+ if (!r || !room.meta.quilt_id || r.seamTold === room.meta.quilt_id) return false
1679
+ r.seamTold = room.meta.quilt_id
1680
+ relay.saveHosted()
1681
+ return true
1682
+ },
1591
1683
  withSession: (fn, session) => {
1592
1684
  const c = current(session)
1593
1685
  return c.error ? fail(c.error) : fn(c.room)
@@ -1688,7 +1780,7 @@ export async function handleHostedMcp ({ req, res, pass, relay, workspaces = nul
1688
1780
  }, async ({ branch, create, session }) => {
1689
1781
  const c = current(session)
1690
1782
  if (c.error) return fail(c.error)
1691
- { const no = seamRefusal('quilt_git_branch', quiltOf(c.room.meta)); if (no) return fail(no) }
1783
+ { const no = seamRefusal('quilt_git_branch', quiltName(c.room), { hosted: true }); if (no) return fail(no) }
1692
1784
  const room = c.room
1693
1785
  if (!branch) {
1694
1786
  const members = []
@@ -1736,7 +1828,7 @@ export async function handleHostedMcp ({ req, res, pass, relay, workspaces = nul
1736
1828
  }, async ({ session }) => {
1737
1829
  const c = current(session)
1738
1830
  if (c.error) return fail(c.error)
1739
- { const no = seamRefusal('quilt_sync_branch', quiltOf(c.room.meta)); if (no) return fail(no) }
1831
+ { const no = seamRefusal('quilt_sync_branch', quiltName(c.room), { hosted: true }); if (no) return fail(no) }
1740
1832
  const room = c.room
1741
1833
  if (room.meta.layout !== 2 || !room.syncUpstreamNow) return fail('This session has no branches on the relay.')
1742
1834
  try {
@@ -1752,7 +1844,7 @@ export async function handleHostedMcp ({ req, res, pass, relay, workspaces = nul
1752
1844
  }, ({ token, session }) => {
1753
1845
  const c = current(session)
1754
1846
  if (c.error) return fail(c.error)
1755
- { const no = seamRefusal('quilt_github_token', quiltOf(c.room.meta)); if (no) return fail(no) }
1847
+ { const no = seamRefusal('quilt_github_token', quiltName(c.room), { hosted: true }); if (no) return fail(no) }
1756
1848
  if (!c.access || !c.access.owner) return fail('Only the session owner can set the GitHub token: ask them.')
1757
1849
  try {
1758
1850
  const r = c.room.setGithubToken(token)
@@ -1808,11 +1900,53 @@ export async function handleHostedMcp ({ req, res, pass, relay, workspaces = nul
1808
1900
  }
1809
1901
 
1810
1902
  sessionTools(mcp, ctx)
1903
+ // Seam's tools for an agent in a session that is a quilt (session-seam.js), as itself: its
1904
+ // pass, traded once for an agent token of its own (seam-access.js). Only while one of its
1905
+ // sessions here is a quilt, so every other agent's tool list stays as it was.
1906
+ const rawPass = String(req.headers['x-quilt-pass'] || '')
1907
+ // Hidden from the list, but a call still answers with the Seam way: an agent's client keeps the
1908
+ // list it read before its session moved into Seam, and a hidden tool would answer only "disabled".
1909
+ if (quilts.all) {
1910
+ if (body === undefined && req.method === 'POST') {
1911
+ try { body = await readJsonBody(req) } catch { res.writeHead(400, { 'content-type': 'application/json' }); res.end(JSON.stringify({ jsonrpc: '2.0', error: { code: -32700, message: 'Parse error' }, id: null })); return }
1912
+ }
1913
+ const calls = [].concat(body || []).some((m) => m && m.method === 'tools/call')
1914
+ if (!calls) for (const [name, t] of registered) if (GIT_TOOLS.has(name)) t.disable()
1915
+ }
1916
+ if (process.env.QUILT_SOURCE_URL && pass.kind === 'agent' && rawPass && quilts.any) {
1917
+ const seam = hostedSeam(account, rawPass)
1918
+ for (const name of SEAM_SESSION_TOOLS) {
1919
+ const spec = seamToolSpec(name, { hosted: true })
1920
+ mcp.registerTool(name, { ...spec, inputSchema: spec.inputSchema.extend({ session: SESSION_ARG }) }, async ({ session, ...args } = {}) => {
1921
+ const c = current(session)
1922
+ if (c.error) return fail(c.error)
1923
+ const quilt = quiltOf(c.room.meta)
1924
+ if (!quilt) return fail('This session is not a quilt: its source control is git, as quilt_status says.')
1925
+ let client
1926
+ try { client = (await seam.client(quilt)).client } catch (e) { return fail(e.message) }
1927
+ return runSessionSeamTool(name, args, { client, quilt, hosted: true })
1928
+ })
1929
+ }
1930
+ }
1811
1931
  // The library works whether or not the agent is in a session (it never touches relay.hosted).
1812
1932
  if (workspaces) registerWorkspaceTools(mcp, { ...hostedWorkspaceAccess(workspaces), webhook: false })
1813
1933
  await serve(mcp, req, res, body)
1814
1934
  }
1815
1935
 
1936
+ // Each hosted agent's way into Seam, kept between its requests (each is a new MCP server): the
1937
+ // agent token it traded its pass for lasts hours, while passes last minutes.
1938
+ const hostedSeams = new Map() // account -> { access, pass }
1939
+ function hostedSeam (account, pass) {
1940
+ let e = hostedSeams.get(account)
1941
+ if (!e) {
1942
+ e = { pass }
1943
+ e.access = seamAccess({ api: () => process.env.QUILT_SOURCE_URL, credential: async () => `QuiltPass ${e.pass}` })
1944
+ hostedSeams.set(account, e)
1945
+ }
1946
+ e.pass = pass
1947
+ return e.access
1948
+ }
1949
+
1816
1950
  /**
1817
1951
  * Handles one MCP request (stateless: a fresh server per request) for the older
1818
1952
  * link-based flow: a browser's token tied to a room by /agent/link.
@@ -50,7 +50,7 @@ const plural = (k, w) => `${k} ${w}${k === 1 ? '' : 's'}`
50
50
 
51
51
  /** Whether branch `key` of the room syncs live to GitHub: the mirror is on, the owner hasn't switched it off, and the relay can write to the repository. */
52
52
  export function liveSyncOn (room, key) {
53
- if (!room.cfg || !room.cfg.gitSync || !room.mirrors || room.meta.githubSync === false) return false
53
+ if (!room.cfg || !room.cfg.gitSync || !room.mirrors || room.meta.githubSync === false || room.meta.syncStopped) return false
54
54
  const rec = upstreamsOf(room)[key]
55
55
  if (!rec || !rec.repo || !rec.ref) return false
56
56
  // Agents' policy "off" (addendum 16.7): an agent's own branch stays in the session; a person adopts its work (adoptBranch).
@@ -4,7 +4,7 @@
4
4
 
5
5
  const list = (xs) => xs.length === 1 ? xs[0] : `${xs.slice(0, -1).join(', ')} and ${xs[xs.length - 1]}`
6
6
 
7
- /** r: { released, held?, branches?, coveredBy?, holder?, yours? }; `cli`: the command to name (quilt release, or quilt_release). */
7
+ /** r: { released, held?, seam? (claims are Seam's: layout 3), branches?, coveredBy?, holder?, yours? }; `cli`: the command to name (quilt release, or quilt_release). */
8
8
  export function releaseWords (r, { cli = 'quilt release' } = {}) {
9
9
  const n = (r && r.released) || 0
10
10
  let s = `Released ${n} claim(s)${r && r.branches && r.branches.length ? ` (on ${list([...new Set(r.branches)])})` : ''}.`
@@ -13,6 +13,6 @@ export function releaseWords (r, { cli = 'quilt release' } = {}) {
13
13
  ? ` That path is not a claim of its own: your claim on ${r.coveredBy} covers it. Let go of that with ${cli} ${r.coveredBy}.`
14
14
  : ` That path is not a claim of its own: ${r.holder || 'someone'}'s claim on ${r.coveredBy} covers it; only they (or the session owner) can let go of it.`
15
15
  }
16
- if (r && r.held && r.held.length) s += ` Kept ${r.held.join(', ')}: someone is waiting for ${r.held.length === 1 ? 'it' : 'them'}; hand off with quilt_handoff.`
16
+ if (r && r.held && r.held.length) s += ` Kept ${r.held.join(', ')}: someone is waiting for ${r.held.length === 1 ? 'it' : 'them'}; ${r.seam ? `hand it on in Seam with a note (seam claim handoff ${r.held[0]} --note "…")` : 'hand off with quilt_handoff'}.`
17
17
  return s
18
18
  }
package/src/runner.js CHANGED
@@ -22,6 +22,8 @@ import { keptInProject } from './agent-join.js'
22
22
 
23
23
  export { JOIN_HOST }
24
24
 
25
+ const STATUS_FILE_MS = 1500 // .quilt/STATUS.md is written at most this often
26
+
25
27
  /**
26
28
  * An invite link: https://join.heyquilt.com/<room>#<secret> for sessions on Quilt's relay,
27
29
  * or https://<relay>/join/<room>#<secret> for any other relay (development relays).
@@ -101,7 +103,7 @@ export async function describeOtherSync (dir) {
101
103
  * optionally overrides the relay address given out in invites (e.g. a public
102
104
  * tunnel URL when the relay runs on this machine).
103
105
  */
104
- export async function runSession ({ dir, conn, name, tool, toolGuessed = false, color = null, shareByDefault = true, summarizeByDefault = false, joined = false, prefer = 'remote', inviteServer, onLog, onFatal, onDebug, kind = 'human', agentFeed = true, readerOptions = {}, passes = null, identity = null, startName = '', startAgentCommits = '', workspace = '', apiAuth = null }) {
106
+ export async function runSession ({ dir, conn, name, tool, color = null, shareByDefault = true, summarizeByDefault = false, joined = false, prefer = 'remote', inviteServer, onLog, onFatal, onDebug, kind = 'human', agentFeed = true, readerOptions = {}, passes = null, identity = null, startName = '', startAgentCommits = '', newSession = false, workspace = '', apiAuth = null }) {
105
107
  dir = path.resolve(dir)
106
108
  if (!/^wss?:\/\//.test(conn.server)) throw new Error('The relay address must start with ws:// or wss://')
107
109
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
@@ -139,7 +141,7 @@ export async function runSession ({ dir, conn, name, tool, toolGuessed = false,
139
141
 
140
142
  // An agent whose keys are kept in its project (quilt agent join --here) is in a sandbox that is cleaned up after every run: it works in turns.
141
143
  const turns = kind === 'agent' && keptInProject(name)
142
- const session = new Session({ dir, ...conn, name, tool, toolGuessed, color, prefer, kind, shareAgent, identity, passes, startName, startAgentCommits, workspace: savedWorkspace, turns })
144
+ const session = new Session({ dir, ...conn, name, tool, color, prefer, kind, shareAgent, identity, passes, startName, startAgentCommits, newSession, workspace: savedWorkspace, turns, apiAuth })
143
145
  const summarizer = () => createSummarizer({ onWarn: (msg) => session.log(`✂️ ${msg}`) })
144
146
  if (summarize) session.summarizer = summarizer()
145
147
  session.taskTitler = createTaskTitler({ onWarn: (msg) => session.log(`📋 ${msg}`) })
@@ -147,12 +149,24 @@ export async function runSession ({ dir, conn, name, tool, toolGuessed = false,
147
149
  if (onDebug) session.on('debug', onDebug)
148
150
  session.on('fatal', (err) => onFatal && onFatal(err))
149
151
  const statusFile = path.join(dir, '.quilt', 'STATUS.md')
152
+ // Written at most every STATUS_FILE_MS: each write works out the whole status again.
153
+ let statusWrite = null
150
154
  session.on('status-changed', () => {
151
- // Read config fresh: the workspace can change after the session started (the app's
152
- // "move to workspace", or a later quilt_start_session call writing it here).
153
- try { fs.writeFileSync(statusFile, renderStatus({ ...session.status(), workspace: readConfig(dir)?.workspace })) } catch {}
155
+ if (statusWrite) return
156
+ statusWrite = setTimeout(() => {
157
+ statusWrite = null
158
+ if (session.stopped) return
159
+ // Read config fresh: the workspace can change after the session started (the app's
160
+ // "move to workspace", or a later quilt_start_session call writing it here).
161
+ try { fs.writeFileSync(statusFile, renderStatus({ ...session.status(), workspace: readConfig(dir)?.workspace })) } catch {}
162
+ }, STATUS_FILE_MS)
163
+ statusWrite.unref?.()
154
164
  })
155
165
 
166
+ // Work for this person's AI can run on its own here, when they turned that on (autowork.js). It
167
+ // listens before the session starts: what was written to their cloud AI while it was away arrives
168
+ // as the session syncs, before start() returns, and is its work.
169
+ autowork().attach(session)
156
170
  try {
157
171
  await session.start({ waitTimeoutMs: 15000 })
158
172
  } catch (err) {
@@ -164,8 +178,6 @@ export async function runSession ({ dir, conn, name, tool, toolGuessed = false,
164
178
  session.on('identity', ({ name }) => saveName(name))
165
179
  // apiAuth: { api, token } for the accounts API as this session's person or agent (secrets, `quilt run`).
166
180
  const control = await startControl(session, { invite, viewInvite, joined, apiAuth })
167
- // Work for this person's AI can run on its own here, when they turned that on (autowork.js).
168
- autowork().attach(session)
169
181
  remember({ dir, room: conn.room, server: conn.server, name: session.name, tool, kind, workspace: savedWorkspace })
170
182
 
171
183
  // Every AI tool on this computer can reach Quilt (its MCP server, by absolute path), with nothing to set up.
@@ -198,6 +210,7 @@ export async function runSession ({ dir, conn, name, tool, toolGuessed = false,
198
210
  if (stopped) return
199
211
  stopped = true
200
212
  if (readers) readers.stop()
213
+ clearTimeout(statusWrite)
201
214
  await control.close()
202
215
  await session.stop()
203
216
  }