heyquilt 0.3.122 → 0.3.140

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (166) hide show
  1. package/RELEASES.md +279 -17
  2. package/bin/quilt.js +62 -152
  3. package/desktop/main.js +16 -2
  4. package/package.json +3 -2
  5. package/src/account.js +0 -29
  6. package/src/agent-task-workflow.js +2 -2
  7. package/src/agents/common.js +13 -0
  8. package/src/ai-vm.js +9 -1
  9. package/src/api/activity.js +5 -0
  10. package/src/api/memory-store.js +16 -38
  11. package/src/api/notify.js +134 -0
  12. package/src/api/routes/browsers.js +29 -0
  13. package/src/api/routes/relay.js +26 -3
  14. package/src/api/routes/session-migrate.js +7 -4
  15. package/src/api/routes/sessions.js +73 -1
  16. package/src/api/routes/teams.js +0 -23
  17. package/src/api/server.js +17 -25
  18. package/src/api/supabase-store.js +43 -56
  19. package/src/autowork.js +182 -16
  20. package/src/branch-view.js +6 -131
  21. package/src/connection.js +36 -9
  22. package/src/control.js +131 -48
  23. package/src/duties.js +1 -1
  24. package/src/force-date.js +27 -0
  25. package/src/fsutil.js +13 -3
  26. package/src/github-commit.js +3 -315
  27. package/src/history.js +13 -3
  28. package/src/mcp.js +93 -167
  29. package/src/merge3.js +11 -3
  30. package/src/now-summary.js +113 -0
  31. package/src/pathrules.js +12 -0
  32. package/src/protocol.js +10 -2
  33. package/src/quilt-default.js +84 -0
  34. package/src/quilt-history.js +106 -0
  35. package/src/quilt-remote.js +78 -0
  36. package/src/relay-ask.js +3 -66
  37. package/src/relay-cluster.js +3 -5
  38. package/src/relay-lease.js +6 -2
  39. package/src/relay-mcp.js +154 -432
  40. package/src/relay-prune.js +6 -145
  41. package/src/relay-turn-sync.js +1 -1
  42. package/src/release-words.js +2 -2
  43. package/src/runner.js +20 -7
  44. package/src/seam-access.js +97 -0
  45. package/src/seam-claims.js +53 -0
  46. package/src/seam-url.js +15 -0
  47. package/src/server.js +1516 -1383
  48. package/src/session-follow.js +461 -27
  49. package/src/session-layout3.js +410 -4
  50. package/src/session-migrate.js +15 -11
  51. package/src/session-quilt.js +1 -1
  52. package/src/session-seam.js +233 -20
  53. package/src/session.js +745 -3438
  54. package/src/source-live/bring-in.js +283 -0
  55. package/src/source-live/live-block.js +504 -33
  56. package/src/source-live/live-square.js +16 -5
  57. package/src/status.js +7 -58
  58. package/src/turn-sync.js +15 -2
  59. package/src/ui/agent-guide.js +1 -1
  60. package/src/ui/app.css +85 -20
  61. package/src/ui/app.js +4 -1
  62. package/src/ui/changes.js +5 -14
  63. package/src/ui/chat.js +9 -4
  64. package/src/ui/cloud-ai-model.js +13 -0
  65. package/src/ui/cloud-ai.js +10 -2
  66. package/src/ui/common.js +6 -4
  67. package/src/ui/fileview.js +20 -7
  68. package/src/ui/force-words.js +19 -0
  69. package/src/ui/git-rule.js +9 -28
  70. package/src/ui/home.js +3 -223
  71. package/src/ui/loom-model.js +15 -1
  72. package/src/ui/quiltview.js +213 -0
  73. package/src/ui/seam-model.js +251 -0
  74. package/src/ui/session.js +377 -489
  75. package/src/ui/tree.js +2 -8
  76. package/src/ui/voice-call.js +83 -14
  77. package/src/ui/voice-model.js +39 -0
  78. package/src/ui/voice.js +167 -17
  79. package/src/ui/workspaces.js +2 -122
  80. package/src/ui-server.js +81 -66
  81. package/src/update-check.js +25 -1
  82. package/src/voice.js +13 -6
  83. package/src/api/routes/github.js +0 -393
  84. package/src/branches.js +0 -194
  85. package/src/clash.js +0 -128
  86. package/src/commit-request.js +0 -59
  87. package/src/commit.js +0 -213
  88. package/src/git-hook.js +0 -48
  89. package/src/git.js +0 -147
  90. package/src/gitignore.js +0 -49
  91. package/src/gitstate.js +0 -1033
  92. package/src/quilts/AGENTS.md +0 -1
  93. package/src/quilts/CLAUDE.md +0 -1
  94. package/src/quilts/accounts.js +0 -136
  95. package/src/quilts/affected.js +0 -132
  96. package/src/quilts/archiver.js +0 -175
  97. package/src/quilts/autorevert.js +0 -49
  98. package/src/quilts/bisect-run.js +0 -31
  99. package/src/quilts/blame.js +0 -103
  100. package/src/quilts/block.js +0 -347
  101. package/src/quilts/bucket.js +0 -25
  102. package/src/quilts/cbor.js +0 -92
  103. package/src/quilts/checks.js +0 -308
  104. package/src/quilts/claims.js +0 -144
  105. package/src/quilts/cli.js +0 -536
  106. package/src/quilts/client.js +0 -137
  107. package/src/quilts/conflict-format.js +0 -190
  108. package/src/quilts/describe.js +0 -296
  109. package/src/quilts/dogfood.js +0 -82
  110. package/src/quilts/errors.js +0 -8
  111. package/src/quilts/events.js +0 -171
  112. package/src/quilts/export-git.js +0 -130
  113. package/src/quilts/export-remote.js +0 -156
  114. package/src/quilts/export.js +0 -81
  115. package/src/quilts/fastmerge.js +0 -41
  116. package/src/quilts/hash.js +0 -44
  117. package/src/quilts/history.js +0 -266
  118. package/src/quilts/ids.js +0 -12
  119. package/src/quilts/import-follow.js +0 -236
  120. package/src/quilts/import-git.js +0 -294
  121. package/src/quilts/import.js +0 -196
  122. package/src/quilts/index.js +0 -94
  123. package/src/quilts/keys.js +0 -186
  124. package/src/quilts/limits.js +0 -67
  125. package/src/quilts/lines.js +0 -49
  126. package/src/quilts/live.js +0 -50
  127. package/src/quilts/merge-smart.js +0 -114
  128. package/src/quilts/merge.js +0 -501
  129. package/src/quilts/oauth.js +0 -202
  130. package/src/quilts/objects.js +0 -144
  131. package/src/quilts/pack.js +0 -118
  132. package/src/quilts/records.js +0 -211
  133. package/src/quilts/resolutions.js +0 -69
  134. package/src/quilts/reviews.js +0 -208
  135. package/src/quilts/routes.js +0 -186
  136. package/src/quilts/runner.js +0 -95
  137. package/src/quilts/server.js +0 -144
  138. package/src/quilts/service.js +0 -963
  139. package/src/quilts/setup.js +0 -41
  140. package/src/quilts/signing.js +0 -149
  141. package/src/quilts/squares.js +0 -239
  142. package/src/quilts/store-memory.js +0 -844
  143. package/src/quilts/store-rpc.js +0 -90
  144. package/src/quilts/store-supabase.js +0 -13
  145. package/src/quilts/tags.js +0 -64
  146. package/src/quilts/textdiff.js +0 -96
  147. package/src/quilts/tokens.js +0 -66
  148. package/src/quilts/tools.js +0 -403
  149. package/src/quilts/tree.js +0 -351
  150. package/src/quilts/webhook.js +0 -116
  151. package/src/quilts/window.js +0 -121
  152. package/src/quilts/xdiff.js +0 -435
  153. package/src/relay-ci.js +0 -111
  154. package/src/relay-clash.js +0 -214
  155. package/src/relay-commit.js +0 -918
  156. package/src/relay-filekey.js +0 -46
  157. package/src/relay-git.js +0 -274
  158. package/src/relay-githttp.js +0 -166
  159. package/src/relay-github.js +0 -109
  160. package/src/relay-outbound.js +0 -1295
  161. package/src/relay-repo-moves.js +0 -43
  162. package/src/relay-sync.js +0 -795
  163. package/src/relay-upstream.js +0 -889
  164. package/src/sync-words.js +0 -42
  165. package/src/ui/branches.js +0 -111
  166. package/src/upstream.js +0 -192
package/src/relay-mcp.js CHANGED
@@ -10,7 +10,6 @@
10
10
  // The older link-based server (handleAgentMcp) is kept for relays without sign-in.
11
11
  import { authorOf } from './ui/chat.js'
12
12
  import { projectRules, renderRules } from './project-rules.js'
13
- import { cleanGit, branchBoard, branchesMarkdown } from './branches.js'
14
13
  import crypto from 'node:crypto'
15
14
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
16
15
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
@@ -27,28 +26,25 @@ import { applyTextDiff } from './textdiff.js'
27
26
  import { parseInvite } from './ui/invite.js'
28
27
  import { scanInbox, renderInbox } from './inbox.js'
29
28
  import { withContext, queryConversation, renderConversation, CONVERSATION_DESCRIPTION } from './conversation.js'
30
- import { cleanFiles, describeCommitRequests, REQUEST_COMMIT_DESCRIPTION, MAX_REQUEST_FILES } from './commit.js'
31
- import { commitRequestMessage } from './commit-request.js'
32
- import { GIT_COMMIT_DESCRIPTION, gitCommitSchema, agentPrefix, slug } from './github-commit.js'
33
- import { liveSyncOn, agentCommitRefusal, ownFiles } from './relay-outbound.js'
34
- import { syncStatusText, SYNC_STATUS_DESCRIPTION } from './sync-words.js'
35
- import { clashAction, CLASH_DESCRIPTION, clashSchema } from './relay-clash.js'
36
- import { agentCommitsOf, repoRecordFor, POLICY_WORDS, pullRequestsFor, mergeForMember, describePulls, describeMerge, PULLS_DESCRIPTION, MERGE_DESCRIPTION, mergeSchema, agentsCanCommit, unpublishedFor, unpublishedRefusal, finishTaskFor, gitCommitFor, describeGitCommit, gitStatusFor, describeGitStatus, gitLogFor, describeGitLog, gitDiffFor, describeGitDiff, GIT_STATUS_DESCRIPTION, GIT_LOG_DESCRIPTION, GIT_DIFF_DESCRIPTION, GIT_REVERT_DESCRIPTION } from './relay-commit.js'
37
29
  import { UpdateCheck } from './update-check.js'
38
30
  import { TASK_WORKFLOW, pickupBrief, doneRefusal, verifiedEnough, verifiedLine, qaRefusal, qaNotesEnough, qaNotesLine, pickChecklist, MAX_VERIFIED } from './agent-task-workflow.js'
39
31
  import { HistoryLog, queryHistory, parseSince, formatHistory, currentTask, lineDiff, legacyTail } from './history.js'
32
+ import { linkedHistory } from './quilt-history.js'
40
33
  import { changeRefusal, TALK_REFUSED } from './session-access.js'
41
34
  import { MIGRATE_SESSION_DESCRIPTION, describeQuiltPlan } from './session-quilt.js'
42
35
  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'
43
36
  import { describeSubscription, describeWebhookTest, COALESCE_HELP, WEBHOOK_EVENTS } from './webhooks.js'
44
37
  import { registerWorkspaceTools, bytesFetcher, WORKSPACE_GUIDE_HOSTED } from './workspace-tools.js'
45
38
  import { DEFAULT_KEY, validBranchKey } from './branchdocs.js'
46
- import { LOAD_FILES } from './relay-upstream.js'
47
39
  import { isDiff, hasEntry, listPaths, readPath, readPaths, textOf, notePresent, removePath, baseOf, gitForSync } from './branch-view.js'
48
40
  import { hostedRooms, hostedIn, newHosted } from './hosted-agents.js'
49
41
  import { turnSyncStep, ROOM_KEY } from './relay-turn-sync.js'
50
42
  import { describeSecretNames, SECRET_NAMES_DESCRIPTION } from './secret-names.js'
51
- import { quiltOf, seamRefusal, seamNote } from './session-seam.js'
43
+ import { quiltOf, seamRefusal, seamNote, seamStatus, seamFileRefusal, seamToolSpec, runSessionSeamTool, seamOnly, seamWorkflow, SEAM_SESSION_TOOLS, SEAM_GUIDE_HOSTED, GIT_TOOLS } from './session-seam.js'
44
+ import { seamAccess } from './seam-access.js'
45
+ import { releaseInSeam } from './seam-claims.js'
46
+ import { releaseWords } from './release-words.js'
47
+ import { LAYOUT_QUILT } from './session-layout3.js'
52
48
  import { TRANSCRIPT_DESCRIPTION, renderLines, readLines, inVoice, transcribeSetting, voiceStatusLines } from './voice.js'
53
49
 
54
50
  const FEED_CAP = 300
@@ -95,8 +91,6 @@ export const HOSTED_INSTRUCTIONS =
95
91
  'Do not edit files someone else has claimed: a refused write tells you who holds the file; ask for it in its file queue with quilt_request_file (a title, and up to 300 characters on what you plan) and carry on with other work: you are woken when it is handed to you. ' +
96
92
  'When someone waits in the queue for a file you hold, every answer says so: finish your change, then hand it off with quilt_handoff and your context (what you changed, what is left). You cannot finish a task or release the file before. A claim whose holder does nothing for 20 minutes goes to the next in its queue. ' +
97
93
  'Read what people said about a file before you change it: quilt_read_messages, and each write tells you what was said about that file. ' +
98
- 'On a branch that follows a GitHub repository, what you write stays in a box until you commit it (quilt_git_commit): everyone sees the change, but it is in nobody else\'s folder, so nobody else\'s git can commit it. Committing opens the box by itself; releasing everything does not. ' +
99
- 'quilt_unpack shares a box early when someone needs it: decide yourself, and ask your person first for a large, risky or critical change or when the project\'s rules say so. A file boxed by someone else (quilt_status lists Boxed work, and quilt_read_file says so) is their unfinished work: do not build on it; ask for it with quilt_request_file. ' +
100
94
  'This rule is enforced for agents: while someone who messaged or mentioned you waits for an answer, writes, claims and task changes are refused until you answer with quilt_message. ' +
101
95
  PERSON_CHAT + ' ' +
102
96
  'Everyone sees your changes on their own disk within moments. ' +
@@ -106,11 +100,7 @@ export const HOSTED_INSTRUCTIONS =
106
100
  'You can be in several sessions at once (quilt_join_session each): every tool then takes session: <id> (quilt_session_info lists them; a webhook event or inbox entry says which session it is from), and a call that names none is refused, so an answer never lands in the wrong session. ' +
107
101
  'You are connected over HTTP, so you show as online for 30 minutes after each tool call; while idle, people reach you in the way you chose with quilt_wake. ' +
108
102
  'HTTP is only for an agent with no computer. If you can run shell commands anywhere (a computer, a cloud machine, a sandbox), you must use the Quilt CLI there instead (`quilt agent join`, then `quilt join <invite> --agent <your name>`): files sync to disk and you show as live. ' +
109
- 'A session has a branch for each git branch its members work on, each with its own files: quilt_status and quilt_branches list them and who is on each, and your first call on files puts you on the busiest one, where you stay until you pick another with quilt_git_branch (create: true loads a branch that is on GitHub but not yet in the session, at its latest commit; any other new branch starts from a copy of the files you have). ' +
110
- 'Commits pushed or merged on GitHub come into your branch by themselves: through a member\'s folder on it when one is online and can fetch, otherwise the relay brings them in from GitHub about every 10 minutes. quilt_sync_branch asks for that now (at most once a minute per branch), for example after a PR merged. ' +
111
- 'When those commits and the session\'s uncommitted work changed the same files, Quilt merges them on its own (where both changed the same lines, the newer edit stays, and whoever wrote the other lines is told): there is never a merge for you to do. ' +
112
- 'A private repository needs a read-only GitHub token on the relay: when quilt_status says the relay can\'t read it, ask the session owner (quilt_github_token sets it, owner only). You have no folder here, so git is the quilt_git_* tools: taking a task (quilt_move_task to doing) puts you on a branch of your own, quilt/<you>/<task>; quilt_git_commit commits your changed files there and opens the pull request tied to the task; quilt_git_status says where you stand, quilt_git_diff what differs from GitHub, quilt_git_log what was committed, quilt_git_revert puts GitHub\'s version of a file back. The session\'s branch itself is open to agents only when the owner lets agents push to any branch. Work only in the session has not shipped: commit before you move a task to QA. Done merges the task\'s pull request once its checks pass: a person moves it to Done, or you when the owner lets agents commit to any branch. quilt_git_pull_requests lists open pull requests with their checks; quilt_git_merge merges one (agents: when the owner lets agents commit to any branch). When agents may not commit, ask a person with quilt_request_commit. Pushes and merges on GitHub reach the session within seconds. ' +
113
- 'When a branch syncs with GitHub (quilt_git_status and quilt_branches say so), pushes and merges on GitHub come into the session by themselves, and nothing goes to GitHub until someone commits: quilt_git_commit commits your own changes (with_others: true takes others\' changes in the same files too, as co-authors). A file GitHub and the session both changed is held, and its merger proposes the merge with quilt_clash (the reviewer accepts). Releasing a file only lets go of your claim: it doesn\'t publish anything by itself. ' +
103
+ 'A session has a branch for each branch its members work on, each with its own files: your first call on files puts you on the busiest one. A session in Seam keeps its work in squares: the seam_* tools commit, merge and read history. ' +
114
104
  MAIL_GUIDE + ' ' +
115
105
  TASK_WORKFLOW
116
106
 
@@ -127,21 +117,6 @@ const REMOVED = 'You are no longer in that session. Ask for a new invite and cal
127
117
  const NEEDS_ROOM_PASS = 'Reconnecting you to the session. Call the same tool again.'
128
118
 
129
119
  const id = () => crypto.randomBytes(8).toString('hex')
130
- const BRING_IN_NOTE = 'Commits pushed or merged elsewhere come into the session by themselves: the folder of a member on that branch fetches about once a minute and brings them in. While no folder on a branch can (none online, or only ones that can\'t fetch, are held or diverged, or are a viewer\'s), the relay brings them in from GitHub itself, about every 10 minutes; quilt_sync_branch asks it to look now.'
131
-
132
- /** For a hosted agent: what the relay does about new commits on its branch, and when it last looked; or ''. */
133
- function relayStatus (room, key) {
134
- const rec = room.meta && room.meta.upstreams ? room.meta.upstreams[room.resolveKey ? room.resolveKey(key) : key] : null
135
- if (!rec || !rec.repo) return ''
136
- const up = rec.name || 'its upstream'
137
- if (!rec.checkedAt) return `The relay brings commits from ${up} into \`${key}\` while no folder on it is online; it hasn't looked yet.`
138
- const bits = [`The relay last checked ${up} for new commits ${ago(rec.checkedAt)}.`]
139
- if (rec.brought && rec.brought.at) bits.push(`It brought in ${rec.brought.count} commit${rec.brought.count === 1 ? '' : 's'} (${rec.brought.files} file${rec.brought.files === 1 ? '' : 's'}) ${ago(rec.brought.at)}.`)
140
- if (rec.clash) bits.push(`Its clash with ${up} is task ${rec.clash.task} on the board.`)
141
- if (rec.problem) bits.push(`Note: ${rec.problem}.`)
142
- return bits.join(' ')
143
- }
144
-
145
120
  /** The chat `me` can see: public messages, and direct ones to or from them. */
146
121
  const chatFor = (doc, me) => doc.getArray('chat').toArray().filter((m) => m && m.id && (!m.to || m.to === me || m.by === me))
147
122
 
@@ -154,10 +129,17 @@ function takeStock (doc, me) {
154
129
  const linkInboxes = new WeakMap()
155
130
  const text = (t) => ({ content: [{ type: 'text', text: t }] })
156
131
  const fail = (t) => ({ content: [{ type: 'text', text: t }], isError: true })
132
+ // A session that is a quilt, by its quilt's name (its slug; its id before the relay knows one), or null.
133
+ const quiltName = (room) => { const q = quiltOf(room.meta); return q ? ((room.meta.quilt && room.meta.quilt.slug) || q) : null }
157
134
  // quilt_status in a session that is a quilt starts with how work goes there (session-seam.js).
135
+ // Any other answer, the first in a session that is a quilt for this agent: its instructions may be from before the move.
136
+ const withSeamLead = (r, quilt) => {
137
+ if (r && typeof r.then === 'function') return r.then((x) => withSeamLead(x, quilt))
138
+ 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
139
+ }
158
140
  const withSeamNote = (r, quilt) => {
159
141
  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
142
+ 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
143
  }
162
144
 
163
145
  function ago (ts) {
@@ -193,29 +175,11 @@ function sessionTools (server, ctx) {
193
175
  }
194
176
  // Files, claims and history are a branch's: the agent's own (hosted agents can choose one), or the session's active branch.
195
177
  const branchOf = (room) => ctx.branch ? ctx.branch(room) : room.activeBranch ? room.activeBranch() : null
196
- /**
197
- * An agent without a folder taking a task goes onto a branch of its own for it (quilt/<agent>/<task>,
198
- * a copy of the default branch's files), as a folder agent's checkout does. Stays on a branch of its own
199
- * that still has an open task; nothing when agents may not commit, or this relay has no branch documents.
200
- */
201
- const branchForHostedTask = (room, task) => {
202
- if (ctx.kind !== 'agent' || !ctx.setBranch || !task || !task.title || room.meta.layout !== 2) return null
203
- if (!agentsCanCommit(room, room.defaultKey)) return null
204
- const prefix = agentPrefix(me)
205
- const here = branchOf(room) || room.defaultKey
206
- if (here.startsWith(prefix) && readTasks(room.doc.getMap('tasks')).some((t) => t && !t.archived && t.column !== 'done' && t.pr && t.pr.branch === here)) return null
207
- let name = `${prefix}${slug(task.title)}`
208
- for (let n = 2; room.meta.branches[room.resolveKey(name)] && n < 50; n++) name = `${prefix}${slug(task.title)}-${n}`
209
- try {
210
- if (here !== room.defaultKey) ctx.setBranch(room, room.defaultKey, {})
211
- ctx.setBranch(room, name, { create: true, by: me, editor: true })
212
- return name
213
- } catch { return null }
214
- }
215
178
  // The branch document the agent works on (its store entry), or null for a room without branch documents.
216
179
  // The session's default branch may always start; any other new branch only for someone who may change files.
217
180
  const branchEntry = (room) => {
218
- if (!room.branchDoc) return null
181
+ // At layout 3 there are no branch documents to work on: every tool but the file tools works on the room.
182
+ if (!room.branchDoc || room.meta.layout === LAYOUT_QUILT) return null
219
183
  const key = branchOf(room)
220
184
  const editor = room.resolveKey(key) === room.defaultKey || ctx.access(room)?.role !== 'viewer'
221
185
  return room.branchDoc(key, { by: me, editor })
@@ -224,13 +188,16 @@ function sessionTools (server, ctx) {
224
188
  // Hosted agents are in several sessions at once: `session` says which this call is about.
225
189
  const { session, ...args } = given || {}
226
190
  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
191
  // 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)
192
+ // 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).
193
+ const quilt = quiltName(room)
194
+ // quilt_release lets go of the agent's claims in Seam then (seam-claims.js), when it reaches Seam.
195
+ const seamRelease = name === 'quilt_release' && !!(ctx.seam && ctx.seam())
196
+ 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
197
  if (refused) return fail(refused)
198
+ // A hosted agent with no branch of its own is pinned to the active branch by its first call
199
+ // on files, so a read and the write after it never land on two different branches.
200
+ if (ctx.pin && FILE_TOOLS.has(name) && room.meta.layout !== LAYOUT_QUILT) ctx.pin(room)
234
201
  // The audit trail: which tool, on what (a path, a pattern or a task id; never contents).
235
202
  const caller = ctx.who(room)
236
203
  if (caller && caller.id && room.audit) {
@@ -247,7 +214,9 @@ function sessionTools (server, ctx) {
247
214
  room.auditAs = caller && caller.id
248
215
  try {
249
216
  const r = queueNote(room, fn(args || {}, parts))
250
- return name === 'quilt_status' && quilt ? withSeamNote(r, quilt) : r
217
+ // The first answer in a session that is a quilt says how work lands there (quilt_status always does).
218
+ const once = quilt && ctx.seamOnce ? ctx.seamOnce(room) : false
219
+ return name === 'quilt_status' && quilt ? withSeamNote(r, quilt) : once ? withSeamLead(r, quilt) : r
251
220
  } finally { room.auditAs = prev }
252
221
  }, session))
253
222
  })
@@ -274,13 +243,6 @@ function sessionTools (server, ctx) {
274
243
  for (const h of room.hostedOnline ? room.hostedOnline() : []) if (h.name !== me && !out.some((p) => p.name === h.name)) out.push({ name: h.name, kind: h.kind, tool: 'hosted', hosted: true })
275
244
  return out
276
245
  }
277
- // Each member's folder tells the room its git (branch, upstream, the repository's branches): one list.
278
- const branchesOf = (room) => {
279
- const members = []
280
- for (const st of room.awareness.getStates().values()) if (st && st.name && st.git) members.push({ name: st.name, git: cleanGit(st.git) })
281
- for (const p of peers(room)) if (p.persona) members.push({ name: p.name, git: null, persona: true })
282
- return branchBoard(members, room.branchList ? room.branchList() : [])
283
- }
284
246
  // Claims as people see them: an older app's AI session ("Dana · login", of: Dana) is its person.
285
247
  const claimsOf = (room) => (room.claimList ? room.claimList(branchOf(room)) : []).map((c) => (c.of || (c.queue || []).some((r) => r && r.of) ? { ...c, by: c.of || c.by, queue: (c.queue || []).map((r) => (r && r.of ? { ...r, by: r.of } : r)) } : c))
286
248
  // Claims are per branch: who the agent is, on the branch it works on.
@@ -312,7 +274,8 @@ function sessionTools (server, ctx) {
312
274
  // Claims follow this agent's writes: a file it changes that nobody holds is claimed for it, and let
313
275
  // go when it hasn't written the file for a while (it has no end of turn Quilt can see).
314
276
  const autoHeld = new Map() // `${roomId}\0${rel}` -> timer
315
- const boxable = (room, branch) => !!(room.meta && repoRecordFor(room, branch || room.defaultKey))
277
+ // Boxes opened when a commit to the branch's repository landed: no session commits to git now.
278
+ const boxable = () => false
316
279
  const roomKey = (room) => `${room.id || room.name || ''}\0${branchOf(room)}` // claims held per branch
317
280
  const autoClaim = (room, rel) => {
318
281
  const key = `${roomKey(room)}\0${rel}`
@@ -356,11 +319,18 @@ function sessionTools (server, ctx) {
356
319
  with_diff: z.boolean().optional().describe('Include each change\'s diff (capped per change)'),
357
320
  limit: z.number().int().min(1).max(200).optional().describe('How many of the newest matching changes (default 30)')
358
321
  }
359
- }, ({ path: p, by, since, task, with_diff, limit }, { room }) => {
322
+ }, async ({ path: p, by, since, task, with_diff, limit }, { room }) => {
360
323
  const from = parseSince(since)
361
324
  if (from === undefined) return fail('since: use a duration like 2h or 3d, "today", "yesterday", or a date.')
362
325
  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 })
326
+ const own = legacyTail(historyOf(room).entries(), { at: q && q.at, history: !!(q && q.history) })
327
+ // Linked to a quilt (16: the session became one, or shadows one in step 1): Seam's log plus this record.
328
+ const link = room.quiltHistoryLink ? room.quiltHistoryLink() : null
329
+ if (link) {
330
+ 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 } })
331
+ return text(`${formatHistory(r.list, { withDiff: !!with_diff })}${r.note ? `\n\n${r.note}` : ''}`)
332
+ }
333
+ const list = queryHistory(own, { path: p ? cleanPath(p) : '', by, since: from ?? undefined, task, limit: limit || 30 })
364
334
  return text(formatHistory(list, { withDiff: !!with_diff }))
365
335
  })
366
336
 
@@ -393,42 +363,19 @@ function sessionTools (server, ctx) {
393
363
  if (away.length) lines.push('', '## Away (agents that work in turns, between turns: they read what you write at their next turn)', ...away.map((m) => `- ${m.name} (agent) · last here ${ago(m.lastSeen)}`))
394
364
  const all = claimsOf(room)
395
365
  const bxs = all.filter((c) => c.box)
396
- if (bxs.length) lines.push('', '## Boxed work', ...bxs.map((c) => `- ${c.pattern}: ${c.by === me ? 'your unfinished work, in nobody else\'s folder until you commit it (quilt_git_commit) or quilt_unpack it' : `${c.by}'s unfinished work: not in anyone else's folder until they commit or unpack it`}`))
366
+ if (bxs.length) lines.push('', '## Boxed work', ...bxs.map((c) => `- ${c.pattern}: ${c.by === me ? 'your unfinished work, in nobody else\'s folder until you commit it or quilt_unpack it' : `${c.by}'s unfinished work: not in anyone else's folder until they commit or unpack it`}`))
397
367
  const cl = all.filter((c) => !c.box)
398
368
  lines.push('', '## Claimed files', ...(cl.length ? cl.map((c) => `- ${c.pattern} by ${c.by}${c.note ? ` (${c.note})` : ''}${c.queue.length ? ` · waiting: ${c.queue.map((r) => `${r.by} ("${r.title}")`).join(', ')}` : ''}`) : ['- None.']))
399
369
  const acts = activity.toArray().filter(Boolean).slice(-12).reverse()
400
370
  lines.push('', '## Recent file changes', ...(acts.length ? acts.map((x) => x.kind === 'pulled' ? `- ${x.by} pulled ${x.detail || 'commits'} (${ago(x.ts)})` : x.kind === 'brought' ? `- ${x.by} brought in ${x.detail || 'commits'}${x.branch ? ` on ${x.branch}` : ''} (${ago(x.ts)})` : x.kind === 'switched' ? `- ${x.by} switched to ${x.branch} (${ago(x.ts)})` : `- ${x.by} ${x.kind} ${x.path} (${ago(x.ts)})`) : ['- None yet.']))
401
371
  const msgs = chat.toArray().filter(visible).slice(-8)
402
372
  lines.push('', '## Recent messages', ...(msgs.length ? msgs.map(fmtMsg) : ['- None.']))
403
- const asked = [...doc.getMap('commitRequests').values()].filter((r) => r && r.id && r.state === 'open').sort((x, y) => x.ts - y.ts)
404
- if (asked.length) lines.push('', '## Commit requests', ...asked.map((r) => `- [${r.id}] ${r.by === me ? 'you' : r.by} (${ago(r.ts)}): ${r.message}${Array.isArray(r.files) ? ` (${r.files.length} files)` : ''}${r.error ? `. Last try: ${r.error}` : ''}`))
405
373
  lines.push('', '## Tasks', taskMarkdown(readTasks(doc.getMap('tasks')), me, { tool: ctx.tool(), asAi: false, mentionYours: true }))
406
- const br = branchesOf(room)
407
- if (br.length) {
408
- lines.push('', '## Branches', branchesMarkdown(br, { limit: 6, mine: branchOf(room) }))
409
- if (ctx.branch) lines.push('Work on another branch with quilt_git_branch.')
410
- // Hosted agents learn when the relay last looked for new commits on their branch (relay-upstream.js).
411
- const said = relayStatus(room, branchOf(room))
412
- if (said) lines.push(said)
413
- }
414
374
  if (ctx.webhook) { const w = ctx.webhook.get(); lines.push('', w ? (w.email ? 'Quilt wakes you by email (quilt_wake changes it).' : `Webhook: Quilt POSTs to ${w.url} on ${w.events.join(', ')}.`) : 'No webhook or email wake: choose how Quilt wakes you with quilt_wake.') }
415
375
  return text(lines.join('\n') + ctx.warn(room))
416
376
  })
417
377
 
418
- tool('quilt_sync_status', {
419
- description: SYNC_STATUS_DESCRIPTION,
420
- inputSchema: {}
421
- }, (_, { room, branch }) => text(syncStatusText(room.branchList ? room.branchList() : [], { mine: branch ? branch.key : '' })))
422
-
423
- tool('quilt_branches', {
424
- description: 'The session\'s git branches: which branch each member\'s folder is on, which AI sessions and worktrees work on which branch, how each stands against its upstream (behind, ahead, diverged), and who committed last.',
425
- inputSchema: {}
426
- }, (_, { room }) => {
427
- const br = branchesOf(room)
428
- return text(`${branchesMarkdown(br, { limit: 40 })}\n\n${BRING_IN_NOTE}`)
429
- })
430
-
431
- // Seam (plans/quilt-source-control/16), behind QUILT_SOURCE_URL: what making this session a
378
+ // Seam (Seam's plans/quilt-source-control/16), behind QUILT_SOURCE_URL: what making this session a
432
379
  // quilt would do. Only the dry run: the move itself is the owner's, from the app.
433
380
  if (process.env.QUILT_SOURCE_URL) {
434
381
  tool('quilt_migrate_session', {
@@ -490,6 +437,8 @@ function sessionTools (server, ctx) {
490
437
  : pickChecklist(files.get('AGENTS.md')?.toString(), files.get('CLAUDE.md')?.toString())
491
438
  /** The project's rules (project-rules.js) on the agent's branch, a diff branch's from git where the session left them. */
492
439
  const rulesFor = async (room, branch, files) => {
440
+ // 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).
441
+ if (room.meta.layout === LAYOUT_QUILT && quiltOf(room.meta)) return rulesOnSeam(room)
493
442
  if (!branch || !isDiff(branch)) return projectRules({ list: [...files.keys()], read: (p) => files.get(p)?.toString() ?? null })
494
443
  const list = [...(await listPaths(room, branch.key, branch)).keys()]
495
444
  const got = new Map()
@@ -502,6 +451,20 @@ function sessionTools (server, ctx) {
502
451
  }
503
452
  return projectRules({ list, read: (p) => got.get(p) ?? null })
504
453
  }
454
+ const rulesOnSeam = async (room) => {
455
+ const seam = room.quiltReadClient ? room.quiltReadClient() : null
456
+ 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.`)
457
+ const q = room.meta.quilt_id
458
+ const list = ((await seam.tree(q, 'main')).entries || []).filter((e) => e && e.path && e.mode !== 'dir').map((e) => e.path)
459
+ const got = new Map()
460
+ for (let round = 0; round < 4; round++) {
461
+ const asked = new Set()
462
+ const r = projectRules({ list, read: (p) => { if (got.has(p)) return got.get(p); asked.add(p); return null } })
463
+ if (!asked.size) return r
464
+ for (const p of asked) got.set(p, await seam.cat(q, 'main', p).then((x) => Buffer.from(x.content || '', 'base64').toString('utf8'), () => null))
465
+ }
466
+ return projectRules({ list, read: (p) => got.get(p) ?? null })
467
+ }
505
468
  tool('quilt_move_task', {
506
469
  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
470
  '"qa" when you finish implementing and testing: requires `qaNotes` (what changed and how you self-validated); without it the move is refused. ' +
@@ -514,11 +477,6 @@ function sessionTools (server, ctx) {
514
477
  }
515
478
  }, async ({ id, column, qaNotes, verified }, { room, doc, files, branch }) => {
516
479
  { const w = waitRefusal(doc, 'quilt_move_task', room); if (w) return fail(w) }
517
- // An agent's finished work goes to git before QA, when it can commit itself (relay-commit.js); Done merges the task's pull request.
518
- if (column === 'qa' && ctx.kind === 'agent' && !Object.values(room.meta && room.meta.upstreams || {}).some((rec) => rec && rec.clash && rec.clash.task === id)) {
519
- const u = await unpublishedFor(room, { key: branch ? branch.key : DEFAULT_KEY, who: me, task: id, column }, { fetch: room.githubFetch })
520
- if (u.unpublished.length) return fail(unpublishedRefusal(readTasks(taskMap(doc)).find((t) => t.id === id), u.unpublished, column, []))
521
- }
522
480
  const err = writable(room)
523
481
  if (err) return fail(err)
524
482
  try {
@@ -527,11 +485,6 @@ function sessionTools (server, ctx) {
527
485
  if (column === 'qa' && !qaNotesEnough(qaNotes)) return fail(qaRefusal({ task: cur, checklist: await checklistOf(files, room, branch) }))
528
486
  if (column === 'done' && !verifiedEnough(verified)) return fail(doneRefusal({ task: cur, checklist: await checklistOf(files, room, branch) }))
529
487
  { const q = (column === 'done' || column === 'qa') && renderQueued(queuedFor(claimsOf(room), me), 'move the task again'); if (q) return fail(q) }
530
- let merged = null
531
- if (column === 'done' && cur.column !== 'done') {
532
- const f = await finishTaskFor(room, { who: { name: me, kind: ctx.kind || 'human', access: ctx.access(room) }, key: branch ? branch.key : DEFAULT_KEY, task: id }, { fetch: room.githubFetch })
533
- merged = f.merged
534
- }
535
488
  const patch = { id, column }
536
489
  if (column === 'qa') patch.qaNotes = qaNotes
537
490
  if (column === 'done') patch.verified = verified
@@ -541,11 +494,11 @@ function sessionTools (server, ctx) {
541
494
  const all = historyOf(room).entries()
542
495
  const history = (tf.length ? all.filter((e) => tf.includes(e.path)) : all).slice(-8)
543
496
  const claims = claimsOf(room).filter((c) => !tf.length || tf.some((f) => globMatcher(c.pattern)(f)))
544
- const onto = branchForHostedTask(room, task)
545
- 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.` : ''))
497
+ 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 }) }))
498
+ return text(pickupBrief({ task: { ...task, comments: readComments(doc.getMap('taskComments'), task.id) }, history, claims, checklist: await checklistOf(files, room, branchEntry(room)), me }))
546
499
  }
547
500
  if (column === 'qa') return text(`Moved "${task.title}" to QA. Notes: ${qaNotesLine(task)}`)
548
- if (column === 'done') return text(`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)}`)
501
+ if (column === 'done') return text(`Moved "${task.title}" to Done. Verified: ${verifiedLine(task)}`)
549
502
  return text(`Moved "${task.title}" to ${columnName(task.column)}.`)
550
503
  } catch (e) { return fail(e.message) }
551
504
  })
@@ -797,247 +750,6 @@ function sessionTools (server, ctx) {
797
750
  return text(msgs.length ? msgs.map(fmtMsg).join('\n') : 'No messages yet.')
798
751
  })
799
752
 
800
- // ------------------------------------------------------ commit timing --
801
- // The same requests as a folder's quilt_request_commit (Session.requestCommit): a message to
802
- // whoever commits, so viewers and members who may not post are refused.
803
- const commitMap = (doc) => doc.getMap('commitRequests')
804
- const commitState = (room, doc) => {
805
- const busy = []
806
- for (const st of room.awareness.getStates().values()) {
807
- if (!st || !st.name || st.name === me) continue
808
- const a = st.agent
809
- if (a && a.sharing !== false && a.status === 'working') busy.push(`${st.name}'s ${a.tool || 'AI'} is working`)
810
- else if (st.work && st.work.state === 'working') busy.push(`${st.name} is working${st.work.note ? `: ${String(st.work.note).slice(0, 200)}` : ''}`)
811
- }
812
- const requests = [...commitMap(doc).values()].filter((r) => r && r.id).sort((a, b) => a.ts - b.ts)
813
- return { busy, open: requests.filter((r) => r.state === 'open'), recent: requests.filter((r) => r.state === 'done').slice(-5) }
814
- }
815
- const describeCommits = (c) => [
816
- c.busy.length ? `⏳ Still working: ${c.busy.join('; ')}` : '✅ Everyone else\'s AI is idle: a good moment to commit.',
817
- describeCommitRequests(c)
818
- ].join('\n')
819
-
820
- tool('quilt_request_commit', {
821
- description: REQUEST_COMMIT_DESCRIPTION,
822
- inputSchema: {
823
- message: z.string().max(500).optional().describe('The commit message: what the change does (a task\'s title by default)'),
824
- files: z.array(z.string().max(1024)).max(500).optional().describe('The files you changed, relative to the project\'s top folder (deleted ones too)'),
825
- task: z.string().max(40).optional().describe('The task this work was for: its title is the message and its changes the files, unless you give them')
826
- }
827
- }, ({ message, files, task }, { room, doc, fdoc }) => {
828
- { const w = waitRefusal(doc, 'quilt_request_commit', room); if (w) return fail(w) }
829
- const err = writable(room)
830
- if (err) return fail(err)
831
- const a = ctx.access(room)
832
- if (a && a.role === 'viewer') return fail('Viewers can\'t ask for commits.')
833
- if (ctx.kind === 'agent' && agentsCanCommit(room, branchOf(room) || DEFAULT_KEY)) return fail('Commit it yourself: quilt_git_commit (the files you changed and a message) commits them to the branch you are on, with nobody else needed; on a branch of your own the pull request opens by itself. Asking a person is for when agents may not commit here.')
834
- if (a && a.talk === false) return fail(TALK_REFUSED)
835
- const t = task ? readTasks(taskMap(doc)).find((x) => x.id === String(task)) : null
836
- if (task && !t) return fail(`No task ${task} on the board.`)
837
- const msg = String(message || (t ? t.title : '')).trim().slice(0, 500)
838
- if (!msg) return fail('Say what the commit is for.')
839
- let list = cleanFiles(files || [])
840
- if (files && files.length && !list.length) return fail('None of those are files in the project (give paths relative to its top folder).')
841
- if (!list.length) {
842
- // What was changed for the task, or by this agent, on record in the branch's history.
843
- for (const e of fdoc.getArray('history').toArray()) {
844
- if (!e || e.pulled || typeof e.path !== 'string') continue
845
- if (t ? e.task && e.task.id === t.id : e.by === me) { if (!list.includes(e.path)) list.push(e.path) }
846
- }
847
- if (t) for (const f of t.files || []) if (!list.includes(f)) list.push(f)
848
- list = cleanFiles(list.slice(-MAX_REQUEST_FILES))
849
- }
850
- if (!list.length) return fail(t ? `Task ${t.id} has no changed files on record: give them in files.` : 'Quilt has no changes of yours on record: give the files in files.')
851
- const key = branchOf(room)
852
- const r = { id: crypto.randomBytes(6).toString('hex'), by: me, message: msg, ...(key ? { branch: key } : {}), ts: Date.now(), state: 'open', files: list, ...(t ? { task: { id: t.id, title: t.title } } : {}) }
853
- // The session owner is told with a direct message, as from a folder (commit-request.js).
854
- const note = commitRequestMessage(r, { owner: room.meta && room.meta.ownerName, id: id(), defaultBranch: DEFAULT_KEY })
855
- doc.transact(() => {
856
- const map = commitMap(doc)
857
- map.set(r.id, r)
858
- if (note) {
859
- const chat = doc.getArray('chat')
860
- chat.push([note])
861
- if (chat.length > 500) chat.delete(0, chat.length - 500)
862
- }
863
- const done = [...map.values()].filter((x) => x && x.state === 'done').sort((x, y) => x.ts - y.ts)
864
- for (const x of done.slice(0, Math.max(0, done.length - 100))) map.delete(x.id)
865
- }, AGENT)
866
- return text(`Asked for a commit [${r.id}] of ${list.length} file${list.length === 1 ? '' : 's'}: ${list.slice(0, 12).join(', ')}${list.length > 12 ? ', …' : ''}. ` +
867
- 'A person with git on this branch commits it from their Quilt; you are told the commit when it is made.')
868
- })
869
-
870
- if (ctx.kind) {
871
- // Git, for an agent with no folder: the quilt_git_* tools work on the branch the agent is on (its branch document).
872
- const whoAmI = (room) => ({ name: me, kind: ctx.kind, access: ctx.access(room) })
873
- const keyOf = (branch) => branch ? branch.key : DEFAULT_KEY
874
-
875
- tool('quilt_git_commit', {
876
- description: GIT_COMMIT_DESCRIPTION,
877
- inputSchema: gitCommitSchema(z)
878
- }, async (args, { room, doc, branch }) => {
879
- { const w = waitRefusal(doc, 'quilt_git_commit', room); if (w) return fail(w) }
880
- // A branch that syncs with GitHub through the relay's mirror: your own changes (or the files named, or the task's)
881
- // become one commit now, pushed forward only. Others' changes in those files go in only with with_others.
882
- // An agent the owner's policy keeps off this branch commits to a branch of its own instead (below, with its pull request).
883
- if (room.outbound && liveSyncOn(room, keyOf(branch)) && !(ctx.kind === 'agent' && agentCommitRefusal(room, keyOf(branch), me))) {
884
- const key = keyOf(branch)
885
- const own = ownFiles(room, key, { who: me, files: args.files, task: args.task })
886
- if (own.refused) return fail(own.refused)
887
- const t = args.task ? room.doc.getMap('tasks').get(String(args.task)) : null
888
- const message = String(args.message || (t && t.title) || '').trim().slice(0, 2000)
889
- if (!message) return fail('Say what the commit does (message).')
890
- const r = await room.outbound.flush(key, { message, by: me, only: own.list, own: { who: me, withOthers: args.with_others === true } })
891
- if (r.state === 'others') return fail(`These files also hold changes by others that are not in git yet: ${[...r.others].map(([rel, names]) => `${rel} (${names.join(', ')})`).join('; ')}. Leave them out (files), ask those people to commit first, or commit them together with with_others: true (they are named as co-authors).`)
892
- const rec = (room.meta.upstreams || {})[key] || {}
893
- if (r.state === 'pushed') return text(`Pushed ${r.sha.slice(0, 7)} to ${rec.repo || 'GitHub'} ${rec.ref || key}: "${r.subject}" (${r.changed.length} file${r.changed.length === 1 ? '' : 's'}: ${r.changed.slice(0, 10).join(', ')}${r.changed.length > 10 ? ', …' : ''})${r.live ? `, through pull request work on ${r.live}` : ''}.`)
894
- if (r.state === 'nothing') return text(`Nothing to commit: ${own.list.length === 1 ? own.list[0] : 'those files'} on ${key} ${own.list.length === 1 ? 'is' : 'are'} already on GitHub as the session has ${own.list.length === 1 ? 'it' : 'them'}${r.skipped && r.skipped.claimed.length ? ` (held by someone: ${r.skipped.claimed.join(', ')})` : ''}.`)
895
- if (r.state === 'paused') return fail(`GitHub can't take the commit right now (${rec.problem || r.why}): Quilt keeps it and makes it when GitHub answers.`)
896
- if (r.state === 'protected') return fail(`${rec.ref || key} is protected on GitHub: ${r.why}`)
897
- if (r.state === 'refused') return fail(`Not committed: ${r.why}.`)
898
- return fail(`Could not push ${key} right now (${r.state}${r.why ? `: ${r.why}` : ''}): try quilt_git_commit again in a moment.`)
899
- }
900
- if (!room.commitFor) return fail('This relay can\'t commit yet.')
901
- try {
902
- const r = await gitCommitFor(room, { who: whoAmI(room), key: keyOf(branch), message: args.message, files: args.files, task: args.task, withOthers: args.with_others === true }, { fetch: room.githubFetch, log: room.log || (() => {}) })
903
- return text(describeGitCommit(r, { policy: agentCommitsOf(room) }))
904
- } catch (err) { return fail(err.message) }
905
- })
906
-
907
- tool('quilt_clash', {
908
- description: CLASH_DESCRIPTION,
909
- inputSchema: clashSchema(z)
910
- }, async (args, { room, branch }) => {
911
- try {
912
- const r = await clashAction(room, { who: { name: me, kind: ctx.kind === 'agent' ? 'agent' : 'human', access: ctx.access(room) }, key: keyOf(branch), action: args.action, path: args.path || '', content: args.content ?? null, checks: args.checks || null, force: args.force === true })
913
- return r.ok ? text(r.text) : fail(r.text)
914
- } catch (err) { return fail(err.message) }
915
- })
916
-
917
- tool('quilt_git_status', {
918
- description: GIT_STATUS_DESCRIPTION,
919
- inputSchema: {}
920
- }, async (_, { room, branch }) => {
921
- try { return text(describeGitStatus(await gitStatusFor(room, { who: whoAmI(room), key: keyOf(branch) }, { fetch: room.githubFetch }))) } catch (err) { return fail(err.message) }
922
- })
923
-
924
- tool('quilt_git_log', {
925
- description: GIT_LOG_DESCRIPTION,
926
- inputSchema: { n: z.number().int().min(1).max(50).optional().describe('How many commits (default 10)') }
927
- }, async ({ n }, { room, branch }) => {
928
- try { return text(describeGitLog(await gitLogFor(room, { key: keyOf(branch), n: n || 10 }, { fetch: room.githubFetch }))) } catch (err) { return fail(err.message) }
929
- })
930
-
931
- tool('quilt_git_diff', {
932
- description: GIT_DIFF_DESCRIPTION,
933
- inputSchema: { files: z.array(z.string().min(1).max(500)).max(20).optional().describe('The files to compare; default: your uncommitted changes') }
934
- }, async ({ files: list }, { room, branch }) => {
935
- try { return text(describeGitDiff(await gitDiffFor(room, { who: whoAmI(room), key: keyOf(branch), files: list }, { fetch: room.githubFetch, lineDiff }))) } catch (err) { return fail(err.message) }
936
- })
937
-
938
- tool('quilt_git_revert', {
939
- description: GIT_REVERT_DESCRIPTION,
940
- inputSchema: { files: z.array(z.string().min(1).max(500)).min(1).max(20).describe('The files to put back as GitHub has them') }
941
- }, async ({ files: list }, { room, doc, fdoc, branch, files, blobs, activity }) => {
942
- { const w = waitRefusal(doc, 'quilt_git_revert', room); if (w) return fail(w) }
943
- const err = writable(room) || branchWritable(room, branch)
944
- if (err) return fail(err)
945
- const a = ctx.access(room)
946
- if (a && a.role === 'viewer') return fail('You can only view this session; file changes are refused.')
947
- try {
948
- const d = await gitDiffFor(room, { who: whoAmI(room), key: keyOf(branch), files: list }, { fetch: room.githubFetch, lineDiff })
949
- const { githubRepoOf, credentialsFor } = await import('./relay-commit.js')
950
- const { githubWriter } = await import('./github-commit.js')
951
- const { repo } = githubRepoOf(room, keyOf(branch))
952
- const gh = githubWriter({ fetch: room.githubFetch, token: await credentialsFor(room, repo), owner: repo.owner, name: repo.name })
953
- const done = []; const skipped = []
954
- for (const f of d.files) {
955
- const rel = cleanPath(f.path)
956
- if (f.note === 'same') { skipped.push(`${rel} (already as on GitHub)`); continue }
957
- if (f.note) { skipped.push(`${rel} (${f.note})`); continue }
958
- const refusal = a && changeRefusal(a, rel)
959
- if (refusal) { skipped.push(`${rel} (${refusal})`); continue }
960
- const claim = claimsOf(room).find((c) => c.by !== me && globMatcher(c.pattern)(rel))
961
- if (claim) { skipped.push(`${rel} (held by ${claim.by})`); continue }
962
- const was = d.head ? await gh.content(d.head, rel) : null
963
- const diff = branch && isDiff(branch)
964
- let fromGit = null
965
- if (diff && !hasEntry(branch, rel) && !branch.gone.has(rel)) { const r = await readPath(room, branch.key, branch, rel); fromGit = r.kind === 'text' ? r.text : null }
966
- fdoc.transact(() => doc.transact(() => {
967
- const existed = files.has(rel) || blobs.has(rel) || fromGit !== null
968
- let before = fromGit || ''
969
- if (was === null || was === undefined) {
970
- const y = files.get(rel); if (y) before = y.toString()
971
- if (diff) removePath(branch, rel, { by: me, base: baseOf(room, branch.key, branch) })
972
- else { files.delete(rel); blobs.delete(rel) }
973
- } else {
974
- blobs.delete(rel)
975
- let ytext = files.get(rel)
976
- if (!ytext) { ytext = new Y.Text(); if (fromGit) ytext.insert(0, fromGit); files.set(rel, ytext) }
977
- if (diff) notePresent(branch, rel, { base: baseOf(room, branch.key, branch) })
978
- before = ytext.toString()
979
- applyTextDiff(ytext, was.toString('utf8'))
980
- }
981
- const kind = was == null ? 'deleted' : existed ? 'edited' : 'created'
982
- activity.push([{ by: me, path: rel, kind, detail: `reverted to GitHub (${d.against})`, ...(branch ? { branch: branch.key } : {}), ts: Date.now() }])
983
- if (activity.length > ACTIVITY_CAP) activity.delete(0, activity.length - ACTIVITY_CAP)
984
- historyOf(room).record({ by: me, path: rel, kind, before, after: was == null ? '' : was.toString('utf8'), task: currentTask(readTasks(taskMap(doc)), me) })
985
- }, AGENT), AGENT)
986
- done.push(rel)
987
- }
988
- return text(`${done.length ? `Put back as GitHub's ${d.against} has ${done.length === 1 ? 'it' : 'them'}: ${done.join(', ')}.` : 'Nothing reverted.'}${skipped.length ? ` Left alone: ${skipped.join('; ')}.` : ''}`)
989
- } catch (err) { return fail(err.message) }
990
- })
991
-
992
- tool('quilt_git_pull_requests', {
993
- description: PULLS_DESCRIPTION,
994
- inputSchema: {}
995
- }, async (_, { room, branch }) => {
996
- try { return text(describePulls(await pullRequestsFor(room, { key: keyOf(branch) }, { fetch: room.githubFetch }))) } catch (err) { return fail(err.message) }
997
- })
998
-
999
- tool('quilt_git_merge', {
1000
- description: MERGE_DESCRIPTION,
1001
- inputSchema: mergeSchema(z)
1002
- }, async (args, { room, doc, branch }) => {
1003
- { const w = waitRefusal(doc, 'quilt_git_merge', room); if (w) return fail(w) }
1004
- try {
1005
- const r = await mergeForMember(room, { who: whoAmI(room), key: keyOf(branch), number: args.number, branch: args.branch, method: args.method, deleteBranch: args.delete_branch, force: args.force === true }, { fetch: room.githubFetch, log: () => {} })
1006
- return text(describeMerge(r))
1007
- } catch (err) { return fail(err.message) }
1008
- })
1009
-
1010
- tool('quilt_agent_commits', {
1011
- description: 'Session owner only: git for this session, for everyone in it (people and agents push with git through one GitHub connection, nothing for them to set up). "off": off for everyone (nobody pushes through Quilt; agents ask a person with quilt_request_commit, who commits from the app); "branches" (the default): on, agents push to branches of their own, quilt/<agent>/…, with pull requests for people to merge; "any": on, agents push to any branch, the session\'s own included. People push any branch while it is on. A rule its org, team or workspace sets is a ceiling: you may be stricter, never looser. For a hard limit on main, protect the branch on GitHub.',
1012
- inputSchema: { mode: z.enum(['off', 'branches', 'any']) }
1013
- }, ({ mode }, { room }) => {
1014
- if (!ctx.access(room)?.owner) return fail('Only the session owner decides whether git is on here.')
1015
- try { return text(`Now ${POLICY_WORDS[room.setAgentCommits ? room.setAgentCommits(mode) : mode]}.`) } catch (err) { return fail(err.message) }
1016
- })
1017
- }
1018
-
1019
- tool('quilt_commit_status', {
1020
- description: 'Is it a good moment to commit? Lists open commit requests and whose AI is still working (not counting yours).',
1021
- inputSchema: {}
1022
- }, (_, { room, doc }) => text(describeCommits(commitState(room, doc))))
1023
-
1024
- tool('quilt_commit_request_done', {
1025
- description: 'Close commit requests whose work was committed with git by hand (or isn\'t needed any more). Without an id, every open request is closed.',
1026
- inputSchema: {
1027
- id: z.string().max(40).optional().describe('One request id; omit for all open ones'),
1028
- hash: z.string().max(64).optional().describe('The commit that did it, if you know it')
1029
- }
1030
- }, ({ id: one, hash }, { room, doc }) => {
1031
- { const w = waitRefusal(doc, 'quilt_commit_request_done', room); if (w) return fail(w) }
1032
- const err = writable(room)
1033
- if (err) return fail(err)
1034
- const open = [...commitMap(doc).values()].filter((r) => r && r.state === 'open' && (!one || r.id === one))
1035
- if (!open.length) return text(one ? `No open commit request ${one}.` : 'No open commit requests.')
1036
- const h = typeof hash === 'string' && /^[0-9a-f]{7,64}$/.test(hash) ? hash : ''
1037
- doc.transact(() => { for (const r of open) commitMap(doc).set(r.id, { ...r, state: 'done', doneBy: me, hash: h, doneAt: Date.now() }) }, AGENT)
1038
- return text(`Marked ${open.length} commit request${open.length === 1 ? '' : 's'} done.`)
1039
- })
1040
-
1041
753
  tool('quilt_list_files', {
1042
754
  description: 'List the shared project files with who is working on them (claims).',
1043
755
  inputSchema: { under: z.string().optional().describe('Only files under this folder') }
@@ -1166,10 +878,24 @@ function sessionTools (server, ctx) {
1166
878
  return text(`Claimed ${pattern.trim()}. Release it with quilt_release when done.`)
1167
879
  })
1168
880
 
881
+ const releaseHostedInSeam = async (room, pattern) => {
882
+ const quilt = quiltName(room) && room.meta.quilt_id
883
+ const access = ctx.seam && ctx.seam()
884
+ if (!quilt || !access) return fail(seamFileRefusal(quiltName(room)))
885
+ try {
886
+ const asked = room.claimRequest(who(room), { op: 'quilt-claims' })
887
+ const { gone, ...r } = await releaseInSeam({ access, api: process.env.QUILT_SOURCE_URL, quilt, asked, pattern: pattern ? pattern.trim() : '*' })
888
+ if (gone.length) room.claimRequest(who(room), { op: 'quilt-released', ids: gone })
889
+ return text(releaseWords(r, { cli: 'quilt_release' }))
890
+ } catch (err) { return fail(err.message) }
891
+ }
892
+
1169
893
  tool('quilt_release', {
1170
894
  description: 'Release a claim (or all of your claims when no pattern is given).',
1171
895
  inputSchema: { pattern: z.string().optional() }
1172
896
  }, ({ pattern }, { room }) => {
897
+ // At layout 3 the claims are Seam's: the agent lets go of its own there, as itself.
898
+ if (room.meta.layout === LAYOUT_QUILT) return releaseHostedInSeam(room, pattern)
1173
899
  // Releasing everything keeps boxes: a box opens when it's committed (quilt_git_commit) or unpacked (quilt_unpack).
1174
900
  const mine = claimsOf(room).filter((c) => c.by === me && (pattern ? c.pattern === pattern.trim() : !c.box))
1175
901
  // A file someone waits for is handed off, not released.
@@ -1430,6 +1156,13 @@ export function hostedWorkspaceAccess ({ apiUrl, pass, fetch: fetchImpl = global
1430
1156
  return { call, fetchBytes: bytesFetcher(fetchImpl, { timeoutMs: WORKSPACE_TRANSFER_TIMEOUT_MS }), put, saveDir: null, readLocal: null }
1431
1157
  }
1432
1158
 
1159
+ /** A request's JSON body, read here (the transport takes it parsed). */
1160
+ async function readJsonBody (req) {
1161
+ const chunks = []
1162
+ for await (const c of req) chunks.push(c)
1163
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'))
1164
+ }
1165
+
1433
1166
  async function serve (mcp, req, res, body) {
1434
1167
  const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true })
1435
1168
  res.on('close', () => { transport.close().catch(() => {}); mcp.close().catch(() => {}) })
@@ -1446,11 +1179,30 @@ async function serve (mcp, req, res, body) {
1446
1179
  */
1447
1180
  export async function handleHostedMcp ({ req, res, pass, relay, workspaces = null, secrets = null, body = undefined }) {
1448
1181
  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
1182
  const me = pass.name
1452
1183
  const account = `${pass.kind}:${pass.sub}`
1453
1184
  const touched = new Set()
1185
+ // Whether the agent's sessions on this machine are quilts (some, all): an agent whose sessions all
1186
+ // are reads only Seam's way, with no git in its instructions or tool list (session-seam.js).
1187
+ const quilts = (() => {
1188
+ let any = false; let all = true; let seen = 0
1189
+ for (const name of hostedRooms(relay.hosted.get(account))) {
1190
+ if (relay.roomEnded(name)) continue
1191
+ const room = relay.getRoom(name)
1192
+ if (!room || !room.exists) continue
1193
+ touched.add(room)
1194
+ seen++
1195
+ if (quiltOf(room.meta)) any = true; else all = false
1196
+ }
1197
+ return { any, all: any && all && seen > 0 }
1198
+ })()
1199
+ // The library's guide is part of the instructions only when its tools are offered.
1200
+ const base = quilts.all ? `${seamOnly(HOSTED_INSTRUCTIONS)} In a session that is a quilt: ${SEAM_GUIDE_HOSTED}` : HOSTED_INSTRUCTIONS
1201
+ const mcp = new McpServer({ name: 'quilt', version: '0.2.0' }, { instructions: (workspaces ? `${base}\n\n${WORKSPACE_GUIDE_HOSTED}` : base) })
1202
+ // Every tool it registers, so the git-era ones can be left out for an agent whose sessions are all quilts.
1203
+ const registered = new Map()
1204
+ const register = mcp.registerTool.bind(mcp)
1205
+ mcp.registerTool = (name, spec, handler) => { const t = register(name, spec, handler); registered.set(name, t); return t }
1454
1206
  const done = () => { for (const room of touched) if (room && !room.conns.size && room.onEmpty) room.onEmpty() }
1455
1207
  res.on('close', done)
1456
1208
  // Which sessions the agent is in, for the accounts API (x-quilt-rooms), and which one this
@@ -1548,6 +1300,8 @@ export async function handleHostedMcp ({ req, res, pass, relay, workspaces = nul
1548
1300
  multi: true, // every tool takes `session`
1549
1301
  who: () => ({ name: me, id: account }),
1550
1302
  kind: pass.kind === 'agent' ? 'agent' : 'human',
1303
+ // This agent to Seam (its pass, traded for an agent token), for what it does there as itself.
1304
+ seam: () => process.env.QUILT_SOURCE_URL && pass.kind === 'agent' && req.headers['x-quilt-pass'] ? hostedSeam(account, String(req.headers['x-quilt-pass'])) : null,
1551
1305
  access: (room) => room.hostedAccess(pass),
1552
1306
  branch: (room) => room.hostedBranch(account),
1553
1307
  setBranch: (room, key, opts) => room.setHostedBranch(account, key, opts),
@@ -1588,6 +1342,14 @@ export async function handleHostedMcp ({ req, res, pass, relay, workspaces = nul
1588
1342
  } : null,
1589
1343
  updates: relay.updates || null,
1590
1344
  image: () => image,
1345
+ // Whether this agent is yet to be told the Seam way in `room` (once per quilt, kept with its record there).
1346
+ seamOnce: (room) => {
1347
+ const r = hostedIn(relay.hosted.get(account), room.name)
1348
+ if (!r || !room.meta.quilt_id || r.seamTold === room.meta.quilt_id) return false
1349
+ r.seamTold = room.meta.quilt_id
1350
+ relay.saveHosted()
1351
+ return true
1352
+ },
1591
1353
  withSession: (fn, session) => {
1592
1354
  const c = current(session)
1593
1355
  return c.error ? fail(c.error) : fn(c.room)
@@ -1678,88 +1440,6 @@ export async function handleHostedMcp ({ req, res, pass, relay, workspaces = nul
1678
1440
  return text(`Left room ${name}.${left.length ? ` You are still in ${left.join(', ')}.` : ''}`)
1679
1441
  })
1680
1442
 
1681
- mcp.registerTool('quilt_git_branch', {
1682
- description: 'Work on another branch of the session: your file tools, claims and history then read and write that branch. quilt_status lists the branches and who is on each. create: true adds a branch that isn\'t in the session yet: one that is on GitHub is loaded at its latest commit (and the relay brings later commits on it in); any other starts from a copy of the files on the one you are on.',
1683
- inputSchema: {
1684
- branch: z.string().min(1).max(200).optional().describe('The branch to work on, e.g. quilt/<you>/<topic> or feature/login; without it, the session\'s branches are listed'),
1685
- create: z.boolean().optional().describe('Add it to the session: from GitHub when it is there, otherwise a copy of your current branch\'s files'),
1686
- session: SESSION_ARG
1687
- }
1688
- }, async ({ branch, create, session }) => {
1689
- const c = current(session)
1690
- if (c.error) return fail(c.error)
1691
- { const no = seamRefusal('quilt_git_branch', quiltOf(c.room.meta)); if (no) return fail(no) }
1692
- const room = c.room
1693
- if (!branch) {
1694
- const members = []
1695
- for (const st of room.awareness.getStates().values()) if (st && st.name && st.git) members.push({ name: st.name, git: cleanGit(st.git) })
1696
- const br = branchBoard(members, room.branchList ? room.branchList() : [])
1697
- return text(br.length ? `${branchesMarkdown(br, { limit: 20, mine: room.hostedBranch(account) })}\nSwitch with quilt_git_branch and a branch name; create: true adds one.` : 'This session has one branch, and you are on it.')
1698
- }
1699
- const key = String(branch).trim()
1700
- const editor = ctx.access(room)?.role !== 'viewer'
1701
- let gh = null
1702
- try {
1703
- if (create && validBranchKey(key) && key !== DEFAULT_KEY && !room.meta.branches[room.resolveKey(key)]) {
1704
- room.assertMigrated()
1705
- room.assertBranchRoom(editor, 0) // refused before asking GitHub anything
1706
- gh = await room.branchFromGitHub(account, key)
1707
- if (!(gh && gh.state === 'loaded') && room.readyToCut) await room.readyToCut(account)
1708
- }
1709
- const r = room.setHostedBranch(account, key, { create: !!create, by: me, editor, github: gh && gh.state === 'loaded' ? gh : null })
1710
- if (r.created && gh && gh.state === 'loaded' && gh.diff) return text(`Started ${r.branch} from GitHub (${gh.repo} at ${gh.head.slice(0, 7)}). Your file tools, claims and history use ${r.branch} now: files you haven't changed are read from git. People work on it with \`git checkout ${r.branch}\`, and their folder follows. The relay brings new commits on ${gh.upstream.name} in while no folder on it can; quilt_sync_branch asks for them now.`)
1711
- if (r.created && gh && gh.state === 'loaded') {
1712
- const n = gh.texts.size + gh.blobs.size
1713
- const left = gh.skipped.length ? ` ${gh.skipped.length} file${gh.skipped.length === 1 ? ' was' : 's were'} too large for the relay and left out (${gh.skipped.slice(0, 5).join(', ')}${gh.skipped.length > 5 ? ', …' : ''}): a member's folder on it brings ${gh.skipped.length === 1 ? 'it' : 'them'} in.` : ''
1714
- return text(`Loaded ${r.branch} from GitHub (${gh.repo} at ${gh.head.slice(0, 7)}, ${n} file${n === 1 ? '' : 's'}).${left} Your file tools, claims and history use ${r.branch} now. People work on it with \`git checkout ${r.branch}\`, and their folder follows. The relay brings new commits on ${gh.upstream.name} in while no folder on it can; quilt_sync_branch asks for them now.`)
1715
- }
1716
- if (r.created) {
1717
- const why = !gh ? '' : {
1718
- 'not-github': `The session's repository (${gh.repo}) isn't on GitHub, so the relay can't load ${r.branch} from it: `,
1719
- 'no-branch': `${r.branch} isn't a branch on GitHub (${gh.repo}), so `,
1720
- private: `The relay can't read ${gh.repo} on GitHub (a private repository needs a read-only token: ask the session owner to add one), so `,
1721
- 'token-refused': `The relay's GitHub token can't read ${gh.repo} (ask the session owner to give it read access), so `,
1722
- 'too-many': `${r.branch} on GitHub has more files than the relay loads at once (${LOAD_FILES}), so `,
1723
- backoff: 'GitHub asked the relay to slow down, so ',
1724
- error: `The relay couldn't read ${r.branch} from GitHub (${gh.message || 'no answer'}), so `
1725
- }[gh.state] || ''
1726
- const said = `Started ${r.branch} from ${r.from}, with a copy of its files. Your file tools, claims and history use ${r.branch} now. It isn't in git yet: people work on it with \`git checkout -b ${r.branch}\` (or \`git checkout ${r.branch}\` once it is pushed), and their folder follows.`
1727
- return text(why ? `${why}${said[0].toLowerCase()}${said.slice(1)}` : said)
1728
- }
1729
- return text(`You are on ${r.branch} now: your file tools, claims and history use it.`)
1730
- } catch (e) { return fail(e.message) }
1731
- })
1732
-
1733
- mcp.registerTool('quilt_sync_branch', {
1734
- description: 'Bring commits made outside the session (a PR merged on GitHub, a push) into your branch now. A member\'s folder on the branch that can fetch does this about once a minute; otherwise the relay asks GitHub itself, about every 10 minutes, and this asks it now (at most once a minute per branch). It never merges git history: files that clash with the session\'s work become one task on the board for one agent to merge.',
1735
- inputSchema: { session: SESSION_ARG }
1736
- }, async ({ session }) => {
1737
- const c = current(session)
1738
- if (c.error) return fail(c.error)
1739
- { const no = seamRefusal('quilt_sync_branch', quiltOf(c.room.meta)); if (no) return fail(no) }
1740
- const room = c.room
1741
- if (room.meta.layout !== 2 || !room.syncUpstreamNow) return fail('This session has no branches on the relay.')
1742
- try {
1743
- const key = room.hostedBranch(account)
1744
- const r = await room.syncUpstreamNow(key)
1745
- return text(r.said)
1746
- } catch (e) { return fail(e.message) }
1747
- })
1748
-
1749
- mcp.registerTool('quilt_github_token', {
1750
- description: 'Session owner only: set (or clear, with an empty token) the GitHub token the relay uses to bring commits in from a private repository while no folder can, to load branches from it, and, when it can write, to commit members\' work (quilt_commit). A fine-grained token for the repository with "Contents: Read" brings commits in; "Contents: Read and write" and "Pull requests: Read and write" let agents commit and open pull requests, within what quilt_agent_commits allows. It stays on the relay: it is never shown again, in any answer, status or log.',
1751
- inputSchema: { token: z.string().max(300).describe('The token (github_pat_… or ghp_…), or "" to remove it'), session: SESSION_ARG }
1752
- }, ({ token, session }) => {
1753
- const c = current(session)
1754
- if (c.error) return fail(c.error)
1755
- { const no = seamRefusal('quilt_github_token', quiltOf(c.room.meta)); if (no) return fail(no) }
1756
- if (!c.access || !c.access.owner) return fail('Only the session owner can set the GitHub token: ask them.')
1757
- try {
1758
- const r = c.room.setGithubToken(token)
1759
- return text(r.githubToken ? 'Saved the GitHub token on the relay. It looks at the session\'s branches again now.' : 'Removed the GitHub token.')
1760
- } catch (e) { return fail(e.message) }
1761
- })
1762
-
1763
1443
  // Secrets: names only. A hosted agent runs no commands, so it uses none (the answer says so).
1764
1444
  mcp.registerTool('quilt_secret_names', {
1765
1445
  description: `${SECRET_NAMES_DESCRIPTION} You are a hosted agent: you run no commands, so you can't use secrets yourself; this tells you which exist.`,
@@ -1808,11 +1488,53 @@ export async function handleHostedMcp ({ req, res, pass, relay, workspaces = nul
1808
1488
  }
1809
1489
 
1810
1490
  sessionTools(mcp, ctx)
1491
+ // Seam's tools for an agent in a session that is a quilt (session-seam.js), as itself: its
1492
+ // pass, traded once for an agent token of its own (seam-access.js). Only while one of its
1493
+ // sessions here is a quilt, so every other agent's tool list stays as it was.
1494
+ const rawPass = String(req.headers['x-quilt-pass'] || '')
1495
+ // Hidden from the list, but a call still answers with the Seam way: an agent's client keeps the
1496
+ // list it read before its session moved into Seam, and a hidden tool would answer only "disabled".
1497
+ if (quilts.all) {
1498
+ if (body === undefined && req.method === 'POST') {
1499
+ 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 }
1500
+ }
1501
+ const calls = [].concat(body || []).some((m) => m && m.method === 'tools/call')
1502
+ if (!calls) for (const [name, t] of registered) if (GIT_TOOLS.has(name)) t.disable()
1503
+ }
1504
+ if (process.env.QUILT_SOURCE_URL && pass.kind === 'agent' && rawPass && quilts.any) {
1505
+ const seam = hostedSeam(account, rawPass)
1506
+ for (const name of SEAM_SESSION_TOOLS) {
1507
+ const spec = seamToolSpec(name, { hosted: true })
1508
+ mcp.registerTool(name, { ...spec, inputSchema: spec.inputSchema.extend({ session: SESSION_ARG }) }, async ({ session, ...args } = {}) => {
1509
+ const c = current(session)
1510
+ if (c.error) return fail(c.error)
1511
+ const quilt = quiltOf(c.room.meta)
1512
+ if (!quilt) return fail('This session is not a quilt: its source control is git, as quilt_status says.')
1513
+ let client
1514
+ try { client = (await seam.client(quilt)).client } catch (e) { return fail(e.message) }
1515
+ return runSessionSeamTool(name, args, { client, quilt, hosted: true })
1516
+ })
1517
+ }
1518
+ }
1811
1519
  // The library works whether or not the agent is in a session (it never touches relay.hosted).
1812
1520
  if (workspaces) registerWorkspaceTools(mcp, { ...hostedWorkspaceAccess(workspaces), webhook: false })
1813
1521
  await serve(mcp, req, res, body)
1814
1522
  }
1815
1523
 
1524
+ // Each hosted agent's way into Seam, kept between its requests (each is a new MCP server): the
1525
+ // agent token it traded its pass for lasts hours, while passes last minutes.
1526
+ const hostedSeams = new Map() // account -> { access, pass }
1527
+ function hostedSeam (account, pass) {
1528
+ let e = hostedSeams.get(account)
1529
+ if (!e) {
1530
+ e = { pass }
1531
+ e.access = seamAccess({ api: () => process.env.QUILT_SOURCE_URL, credential: async () => `QuiltPass ${e.pass}` })
1532
+ hostedSeams.set(account, e)
1533
+ }
1534
+ e.pass = pass
1535
+ return e.access
1536
+ }
1537
+
1816
1538
  /**
1817
1539
  * Handles one MCP request (stateless: a fresh server per request) for the older
1818
1540
  * link-based flow: a browser's token tied to a room by /agent/link.