heyquilt 0.0.1 → 0.3.90

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 (204) hide show
  1. package/LICENSE +85 -0
  2. package/README.md +630 -2
  3. package/RELEASES.md +733 -0
  4. package/assets/logo.svg +1 -0
  5. package/bin/quilt.js +816 -0
  6. package/desktop/main.js +315 -0
  7. package/package.json +163 -7
  8. package/src/account.js +563 -0
  9. package/src/admit-policy.js +32 -0
  10. package/src/agent-join.js +370 -0
  11. package/src/agent-task-workflow.js +176 -0
  12. package/src/agents/actions.js +85 -0
  13. package/src/agents/claude-code.js +180 -0
  14. package/src/agents/common.js +21 -0
  15. package/src/agents/cursor-transcripts.js +170 -0
  16. package/src/agents/cursor.js +404 -0
  17. package/src/agents/index.js +69 -0
  18. package/src/ai-vm-signin.js +98 -0
  19. package/src/ai-vm.js +380 -0
  20. package/src/api/access.js +75 -0
  21. package/src/api/activity.js +181 -0
  22. package/src/api/agent-auth.js +130 -0
  23. package/src/api/agent-placement.js +157 -0
  24. package/src/api/audit.js +83 -0
  25. package/src/api/auth.js +17 -0
  26. package/src/api/domains.js +30 -0
  27. package/src/api/file-paths.js +40 -0
  28. package/src/api/file-store.js +99 -0
  29. package/src/api/fly-machines.js +76 -0
  30. package/src/api/github-app.js +172 -0
  31. package/src/api/http.js +34 -0
  32. package/src/api/invite-email.js +53 -0
  33. package/src/api/issues.js +110 -0
  34. package/src/api/join-text.js +78 -0
  35. package/src/api/mailer.js +47 -0
  36. package/src/api/memory-store.js +1083 -0
  37. package/src/api/org-access.js +54 -0
  38. package/src/api/permissions.js +71 -0
  39. package/src/api/relay-cluster.js +251 -0
  40. package/src/api/relay-machines.js +60 -0
  41. package/src/api/routes/access-types.js +54 -0
  42. package/src/api/routes/agent-invites.js +115 -0
  43. package/src/api/routes/agents.js +157 -0
  44. package/src/api/routes/ai-vm.js +369 -0
  45. package/src/api/routes/github.js +380 -0
  46. package/src/api/routes/grants.js +70 -0
  47. package/src/api/routes/invites.js +188 -0
  48. package/src/api/routes/issues.js +47 -0
  49. package/src/api/routes/join.js +167 -0
  50. package/src/api/routes/members.js +110 -0
  51. package/src/api/routes/orgs.js +177 -0
  52. package/src/api/routes/relay-cluster.js +90 -0
  53. package/src/api/routes/relay.js +76 -0
  54. package/src/api/routes/session-invites.js +119 -0
  55. package/src/api/routes/sessions.js +92 -0
  56. package/src/api/routes/teams.js +132 -0
  57. package/src/api/routes/workspace-agents.js +332 -0
  58. package/src/api/routes/workspace-files.js +277 -0
  59. package/src/api/routes/workspace-invites.js +196 -0
  60. package/src/api/routes/workspaces.js +225 -0
  61. package/src/api/server.js +623 -0
  62. package/src/api/slugs.js +20 -0
  63. package/src/api/supabase-store.js +765 -0
  64. package/src/api/team-access.js +30 -0
  65. package/src/api/tokens.js +31 -0
  66. package/src/api/workspace-access.js +62 -0
  67. package/src/api/workspace-reach.js +60 -0
  68. package/src/autowork-tools.js +166 -0
  69. package/src/autowork.js +387 -0
  70. package/src/blobstore.js +99 -0
  71. package/src/boxes.js +37 -0
  72. package/src/branchdocs.js +235 -0
  73. package/src/branches.js +194 -0
  74. package/src/catchup.js +111 -0
  75. package/src/chat-archive.js +85 -0
  76. package/src/chat-links.js +923 -0
  77. package/src/clash.js +140 -0
  78. package/src/cloud-ai.js +50 -0
  79. package/src/commit-request.js +59 -0
  80. package/src/commit.js +213 -0
  81. package/src/connection.js +569 -0
  82. package/src/control.js +245 -0
  83. package/src/conversation.js +125 -0
  84. package/src/doctor.js +156 -0
  85. package/src/duties.js +303 -0
  86. package/src/editors.js +228 -0
  87. package/src/fsutil.js +87 -0
  88. package/src/git-hook.js +48 -0
  89. package/src/git.js +147 -0
  90. package/src/github-commit.js +320 -0
  91. package/src/gitignore.js +49 -0
  92. package/src/gitstate.js +900 -0
  93. package/src/history.js +307 -0
  94. package/src/hooks.js +371 -0
  95. package/src/hosted-agents.js +24 -0
  96. package/src/hosted-client.js +112 -0
  97. package/src/identity.js +99 -0
  98. package/src/inbox.js +237 -0
  99. package/src/integrations.js +221 -0
  100. package/src/key-cli.js +115 -0
  101. package/src/largefiles.js +91 -0
  102. package/src/legacy.js +25 -0
  103. package/src/mcp-proxy.js +40 -0
  104. package/src/mcp-room.js +32 -0
  105. package/src/mcp.js +1261 -0
  106. package/src/merge3.js +158 -0
  107. package/src/merges.js +148 -0
  108. package/src/pass-source.js +176 -0
  109. package/src/passes.js +64 -0
  110. package/src/pathrules.js +127 -0
  111. package/src/persona.js +101 -0
  112. package/src/presence.js +360 -0
  113. package/src/private-file.js +41 -0
  114. package/src/procs.js +70 -0
  115. package/src/project-rules.js +178 -0
  116. package/src/protocol.js +92 -0
  117. package/src/quiet-warnings.js +9 -0
  118. package/src/relay-ai-vm.js +82 -0
  119. package/src/relay-ask.js +141 -0
  120. package/src/relay-ci.js +111 -0
  121. package/src/relay-clash.js +597 -0
  122. package/src/relay-cluster.js +454 -0
  123. package/src/relay-commit.js +887 -0
  124. package/src/relay-filekey.js +46 -0
  125. package/src/relay-git.js +274 -0
  126. package/src/relay-githttp.js +137 -0
  127. package/src/relay-github.js +109 -0
  128. package/src/relay-lease.js +137 -0
  129. package/src/relay-lfs.js +37 -0
  130. package/src/relay-mcp.js +1679 -0
  131. package/src/relay-metrics.js +75 -0
  132. package/src/relay-outbound.js +1144 -0
  133. package/src/relay-sync.js +719 -0
  134. package/src/relay-turn-sync.js +307 -0
  135. package/src/relay-upstream.js +934 -0
  136. package/src/relay-webhooks.js +111 -0
  137. package/src/releases.js +131 -0
  138. package/src/report.js +129 -0
  139. package/src/runner.js +306 -0
  140. package/src/same-edit.js +18 -0
  141. package/src/server.js +4187 -0
  142. package/src/session-access.js +170 -0
  143. package/src/session-name.js +15 -0
  144. package/src/session.js +7361 -0
  145. package/src/settings.js +46 -0
  146. package/src/setup.js +184 -0
  147. package/src/shell-path.js +46 -0
  148. package/src/status.js +199 -0
  149. package/src/summarize.js +109 -0
  150. package/src/sync-words.js +42 -0
  151. package/src/task-comments.js +92 -0
  152. package/src/tasks.js +670 -0
  153. package/src/textdiff.js +20 -0
  154. package/src/turn-sync-plan.js +142 -0
  155. package/src/turn-sync.js +191 -0
  156. package/src/ui/access-form.js +37 -0
  157. package/src/ui/agent-env-key.js +76 -0
  158. package/src/ui/agent-guide.js +127 -0
  159. package/src/ui/agent-kinds.js +175 -0
  160. package/src/ui/agent-place.js +265 -0
  161. package/src/ui/app.css +2309 -0
  162. package/src/ui/app.js +542 -0
  163. package/src/ui/board.js +465 -0
  164. package/src/ui/boxes.js +76 -0
  165. package/src/ui/branches.js +111 -0
  166. package/src/ui/catchup.js +77 -0
  167. package/src/ui/changes.js +159 -0
  168. package/src/ui/chat.js +266 -0
  169. package/src/ui/cloud-ai-model.js +107 -0
  170. package/src/ui/cloud-ai.js +221 -0
  171. package/src/ui/common.js +431 -0
  172. package/src/ui/csv.js +21 -0
  173. package/src/ui/diffview.js +74 -0
  174. package/src/ui/feed-convs.js +39 -0
  175. package/src/ui/feed.js +174 -0
  176. package/src/ui/files.js +579 -0
  177. package/src/ui/fileview.js +123 -0
  178. package/src/ui/git-rule.js +60 -0
  179. package/src/ui/home.js +1323 -0
  180. package/src/ui/index.html +16 -0
  181. package/src/ui/invite.js +97 -0
  182. package/src/ui/invites.js +79 -0
  183. package/src/ui/loom-model.js +516 -0
  184. package/src/ui/loom.css +349 -0
  185. package/src/ui/loom.js +753 -0
  186. package/src/ui/mark.js +104 -0
  187. package/src/ui/merges.js +158 -0
  188. package/src/ui/rail-model.js +78 -0
  189. package/src/ui/rail.js +173 -0
  190. package/src/ui/releases.js +182 -0
  191. package/src/ui/schedule.js +156 -0
  192. package/src/ui/session.js +3394 -0
  193. package/src/ui/signin.js +75 -0
  194. package/src/ui/sounds.js +110 -0
  195. package/src/ui/tool-logo.js +86 -0
  196. package/src/ui/tree.js +276 -0
  197. package/src/ui/workspaces.js +542 -0
  198. package/src/ui-server.js +1272 -0
  199. package/src/update-check.js +67 -0
  200. package/src/upstream.js +155 -0
  201. package/src/webhooks.js +235 -0
  202. package/src/workspace-cli.js +62 -0
  203. package/src/workspace-tools.js +340 -0
  204. package/index.js +0 -2
package/src/mcp.js ADDED
@@ -0,0 +1,1261 @@
1
+ // MCP server (stdio) that lets any MCP-capable agent (Claude Code, Cursor,
2
+ // Windsurf, Codex, ...) take part in a live session: see what collaborators
3
+ // and their AIs are doing, coordinate, and even join or start a session by
4
+ // itself. It forwards to the local `quilt join` process for the project, or
5
+ // runs the session itself when the agent joins on its own.
6
+ import { syncStatusText, SYNC_STATUS_DESCRIPTION } from './sync-words.js'
7
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
8
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
9
+ import { z } from 'zod'
10
+ import { SubscribeRequestSchema, UnsubscribeRequestSchema } from '@modelcontextprotocol/sdk/types.js'
11
+ import fs from 'node:fs'
12
+ import crypto from 'node:crypto'
13
+ import path from 'node:path'
14
+ import os from 'node:os'
15
+ import { findDaemon, call as rawCall } from './control.js'
16
+ import { parentPids } from './hooks.js'
17
+ import { renderMessage, renderStatus } from './status.js'
18
+ import { branchesMarkdown, upstreamLine, describeBranchSync } from './branches.js'
19
+ import { DEFAULT_KEY } from './branchdocs.js'
20
+ import { formatTasks, columnName, assigneeLabel, renderNextTask, COLUMNS } from './tasks.js'
21
+ import { formatTaskDetails, MAX_COMMENT } from './task-comments.js'
22
+ import { runSession, decodeInvite, newConn, readConfig, runningElsewhere, personsFolder, agentCopyFolder, forgetWorkspace } from './runner.js'
23
+ import { INVALID_INVITE } from './ui/invite.js'
24
+ import { toolLabel } from './agents/common.js'
25
+ import { sessionPasses } from './pass-source.js'
26
+ import { pickAgent, agentWhoami, agentAccess, readAgent, savedAgents } from './agent-join.js'
27
+ import { setSessionWorkspace, announceSessionStarted, announceWhenReported, apiUrl, readAccount, resumeAccount } from './account.js'
28
+ import { registerWorkspaceTools, bytesFetcher, isInside } from './workspace-tools.js'
29
+ import { quiltHome } from './legacy.js'
30
+ import { TASK_WORKFLOW, pickupBrief, doneRefusal, verifiedEnough, verifiedLine, qaRefusal, qaNotesEnough, qaNotesLine, MAX_VERIFIED } from './agent-task-workflow.js'
31
+ import { formatHistory } from './history.js'
32
+ import { describeCommitRequests, REQUEST_COMMIT_DESCRIPTION } from './commit.js'
33
+ import { POLICY_WORDS, unpublishedRefusal } from './relay-commit.js'
34
+ import { renderInbox, describeEvent, INBOX_HOW } from './inbox.js'
35
+ import { renderConversation, renderContext, CONVERSATION_DESCRIPTION } from './conversation.js'
36
+ import { renderChatAbout, renderUnanswered, heldRefusal, renderQueueNotice, renderQueued, CHAT_RULES, RE_HELP } from './duties.js'
37
+ import { describeSubscription, WEBHOOK_EVENTS } from './webhooks.js'
38
+ import { UpdateCheck } from './update-check.js'
39
+ import { getSettings } from './settings.js'
40
+ import { mergeAction } from './merges.js'
41
+ import { CLASH_DESCRIPTION, clashSchema } from './relay-clash.js'
42
+
43
+ // The board's columns, from tasks.js, so this and the hosted MCP (relay-mcp.js) never drift apart.
44
+ const COLUMN_IDS_LIST = COLUMNS.map((c) => c.id)
45
+
46
+ // A workspace id, as the accounts API makes them (UUID in src/api/http.js).
47
+ const WORKSPACE_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
48
+
49
+ /**
50
+ * The "quilt-<room>" folder inside `cwd`. Invites only carry plain room names, but a room that
51
+ * would put the folder anywhere else is refused all the same.
52
+ */
53
+ export function roomFolder (cwd, room) {
54
+ const root = path.resolve(cwd)
55
+ const dir = path.resolve(root, `quilt-${room}`)
56
+ if (path.dirname(dir) !== root) throw new Error(INVALID_INVITE)
57
+ return dir
58
+ }
59
+
60
+ export { toolLabel }
61
+
62
+ // How often `quilt mcp` asks the session for new inbox events to push to Claude Code.
63
+ export const INBOX_POLL_MS = 2000
64
+ // The notification Claude Code turns into a turn when it was started with the quilt channel.
65
+ export const CHANNEL_METHOD = 'notifications/claude/channel'
66
+ // The inbox as an MCP resource: any client may subscribe to hear when something new arrives.
67
+ export const INBOX_URI = 'quilt://inbox'
68
+
69
+ const NOT_RUNNING = 'There is no live quilt session for this project. If the user gave you an invite link, join with ' +
70
+ 'quilt_join_session. To start a new session, use quilt_start_session. A person can also run `quilt join` or `quilt ui`.'
71
+
72
+ export const MCP_INSTRUCTIONS =
73
+ 'quilt lets several people (and their AI agents) edit one project live, each in their own tool. ' +
74
+ 'If you are given a quilt invite link, join with quilt_join_session. In a session, files may change underneath ' +
75
+ 'you at any time. Call quilt_status before starting a task; use quilt_partner_feed to see what a partner\'s AI is ' +
76
+ 'doing; announce your task with quilt_set_focus. The session has a shared task board: read it with quilt_tasks ' +
77
+ '(open tasks assigned to you are listed first), add work with quilt_add_task, assign it with quilt_assign_task, ' +
78
+ 'and move a task with quilt_move_task when you start or finish it; quilt_task reads one in full with its comments, and quilt_comment_task leaves a note or handoff on it. ' +
79
+ 'quilt_history tells you who changed which file, when, with the diff: read it for the files you are about to touch. ' +
80
+ 'If git refuses to pull because untracked files would be overwritten, those files came from the session: quilt_status lists them under Pulling (and whether they match); make way and pull with rm <files> && git pull --autostash, and Quilt keeps them for everyone. ' +
81
+ 'If the person you work for lets their AI pick up work by itself (their Quilt settings: on their own computer, or on their own cloud VM they connected), finishing (quilt_set_work done, a task to QA or Done) hands you your next task from the board: start it. ' +
82
+ 'Before you change files, call quilt_before_edit with their paths: it tells you whether each is yours to edit (claiming free ones for you, so partners are refused instead of overwriting you), ' +
83
+ 'and shows what people said about those files in chat, so you know what was asked or planned before you change them. ' +
84
+ 'Do not edit a file it refuses: ask for it in its file queue with quilt_request_file (a title like "Working on <what> for <task>" and up to 300 characters on your plan) and carry on with other work; you are woken when it is handed to you, with the holder\'s context. ' +
85
+ 'Claims also follow your edits: a file you change that nobody holds is claimed for you until you finish. ' +
86
+ 'If an edit of yours was undone because someone else holds the file, your next quilt answer says so: do not retry; ask for it with quilt_request_file and carry on with other work. ' +
87
+ 'In a folder that is a git repository, your edits to a file stay in a box until you commit them: everyone sees the change, but it is only in your folder, so nobody else\'s git can commit it. Committing the file (or putting it back as it was) opens the box by itself; finishing or releasing does not. ' +
88
+ '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) is not in your folder yet: do not build on it; ask for it with quilt_request_file. ' +
89
+ 'When someone waits in the file queue for a file you hold, every quilt answer says so: finish the change you are making, then hand it off with quilt_handoff and your context (what you changed, what is left, gotchas). ' +
90
+ 'Until you do, you cannot finish (quilt_set_work done, a task to Done) or release that file. A claim whose holder does nothing in the session for 20 minutes goes to the next one waiting, or is released. ' +
91
+ 'Every quilt answer starts with anything new for you (direct messages, mentions, tasks handed to you): act on it. ' +
92
+ 'These rules are enforced: while someone who messaged or mentioned you is waiting for an answer, the tools that move work on (claims, tasks, focus, merges, commits, quilt_set_work) refuse until you answer them with quilt_message; ' +
93
+ 'an edit to a file someone else holds is undone. ' +
94
+ 'When you finish a piece of work, call quilt_set_work with "done": it lets go of the files claimed for you. ' +
95
+ 'Claim ahead (quilt_claim) only for a larger change across several files. ' +
96
+ 'Always re-read a file right before you edit it. ' +
97
+ 'If quilt_status lists merges to settle, read quilt_merges before editing those files. ' +
98
+ 'If quilt_status starts with While you were away, the session changed since this folder last synced: read quilt_history for those files before editing them. ' +
99
+ 'Mentions of you (@yourname) in chat, direct messages to you and tasks handed to you wait in quilt_inbox: read it when you start, and act on each one. ' +
100
+ 'Each comes with the conversation before it with its sender; when a message refers to something earlier you do not have, read back with quilt_conversation before you answer, never guess. ' +
101
+ 'Git works here as usual: commit only the files you changed and push yourself. When the relay serves the session\'s git, your folder\'s origin is the relay: git signs in with your Quilt pass, and the relay checks each push (the session owner\'s policy, never a force-push, no ignored files) and puts it on GitHub, replayed onto GitHub\'s head if it moved (your folder follows). Otherwise your folder signs in to GitHub through Quilt (a short-lived token for this repository). Either way pushes stay within the owner\'s policy: by default branches of your own. Taking a task (quilt_move_task to doing) puts your folder on quilt/<you>/<task>; push there and Quilt opens the pull request for the task; move the task to QA when it is done and tested; Done merges the pull request once its checks pass (a person moves it to Done unless the owner lets agents push to any branch). When agents may not commit, ask a person with quilt_request_commit. ' +
102
+ 'When a branch syncs with GitHub (quilt_branches says so), pushes and merges on GitHub come into the session by themselves; nothing goes to GitHub until someone commits and pushes. A file GitHub and the session both changed is held, and its merger proposes the merge with quilt_clash (the reviewer accepts; writing it directly is undone). Releasing a file only lets go of your claim: it publishes nothing by itself. ' +
103
+ 'Work only in the session has not shipped: push before you move a task to QA. Pushes and merges on GitHub reach the session within seconds: never pull by hand. ' +
104
+ 'When you are about to answer someone in chat, quilt_typing shows them you are typing; sending clears it. ' +
105
+ 'Chat: ' + CHAT_RULES.replace(/^Send a chat message\. /, '') + ' A message that needs nothing back is settled with quilt_inbox (no_reply: [its id]), not answered. ' +
106
+ 'To be woken instead of polling, subscribe to the quilt://inbox resource (you are told when something new arrives), or quilt_webhook_subscribe POSTs each one to a URL of yours as it happens. ' +
107
+ 'You are a member of your own in the session, apart from your person and their other AI sessions, named "<their first name> · <label>" after your work (your git branch, or what you first say you are doing); rename yourself with quilt_name_session. Your messages, inbox, claims and duties are your own. People see all of a person\'s AI sessions as one, "<person>\'s AI" ("Daniel\'s AI"): write to another person\'s AI by that name, and what is written to your person\'s AI reaches whichever of their sessions was active last (with none of them live, their cloud AI, if they connected one: Quilt runs their own AI tool for them on their own cloud VM). ' +
108
+ 'Share what you are doing with quilt_share: when you start on a request (request and your plan) and when you finish (what you did and the files you changed). Partners see it in their feed, it puts your work on the task board, and it tells the host not to commit under you. ' +
109
+ 'When Claude Code is started with the quilt channel, they arrive on their own as <channel source="quilt"> events while you work: treat each like a request from that person, answer with quilt_message, and take a task with quilt_move_task. ' +
110
+ TASK_WORKFLOW
111
+
112
+ export async function runMcp () {
113
+ const server = new McpServer(
114
+ { name: 'quilt', version: '0.1.0' },
115
+ // The channel capability lets Claude Code (started with the quilt channel) take inbox events as turns.
116
+ { instructions: MCP_INSTRUCTIONS, capabilities: { resources: { subscribe: true }, experimental: { 'claude/channel': {} } } }
117
+ )
118
+
119
+ // This AI session, as the app tells it apart from other AI sessions working through it (Claude
120
+ // Code in one window, Cursor or Codex in another): it is a member of its own there, under a
121
+ // name of its own (persona.js), with its own messages, inbox, claims and duties.
122
+ const via = crypto.randomBytes(8).toString('hex')
123
+ const hello = { key: null, name: '', named: null, told: false }
124
+ // Every call to the app says which AI session it is from (in the query for a GET), and the first
125
+ // call to an app (or to one that restarted) says hello, so it has a name.
126
+ const call = async (d, method, route, body) => {
127
+ const key = `${d.dir}:${d.pid}`
128
+ if (hello.key !== key) {
129
+ hello.key = key
130
+ try {
131
+ const r = await rawCall(d, 'POST', '/persona', { via, tool: clientTool(), cwd: process.cwd(), ppid: process.ppid, mcpPids: parentPids(process.ppid, 2) })
132
+ Object.assign(hello, { name: r.name, named: r.named, told: hello.told || !!r.own })
133
+ } catch { hello.key = null } // an older app: everything still works, as this member
134
+ }
135
+ if (method === 'GET') return rawCall(d, method, `${route}${route.includes('?') ? '&' : '?'}via=${via}`)
136
+ return rawCall(d, method, route, { ...(body || {}), via })
137
+ }
138
+ // Said once, in front of an answer: who this session is in the session, and how to rename it.
139
+ const introduce = () => {
140
+ if (!hello.name || hello.told) return ''
141
+ hello.told = true
142
+ return `You are **${hello.name}** in this Quilt session: your messages, claims and inbox are your own, and partners write to you by that name. ` +
143
+ (hello.named ? '' : 'Give yourself a name after your work with quilt_name_session (a few words, like "file queue"); until you do, the first thing you say you are doing names you.') + '\n\n'
144
+ }
145
+
146
+ // A session this MCP server runs itself, when the agent joined or started one.
147
+ let joined = null // { run, dir, invite }
148
+ let logs = []
149
+ const clientTool = () => toolLabel(server.server.getClientVersion()?.name)
150
+
151
+ // This install is the agent's image. When a newer Quilt is out, every answer says so.
152
+ const updates = new UpdateCheck().start()
153
+ const stale = () => { const n = updates.notice(); return n ? `\n\n⚠️ ${n}` : '' }
154
+
155
+ // Quilt undoes edits to files a partner holds; the agent that made them hears about it with its next answer.
156
+ const notices = async (d) => {
157
+ try {
158
+ const { notices } = await call(d, 'POST', '/notices', {})
159
+ return notices.length ? `⚠️ Quilt:\n${notices.map((n) => `- ${n}`).join('\n')}\n\n` : ''
160
+ } catch { return '' }
161
+ }
162
+ // What arrived for this agent (direct messages, mentions, tasks handed over) is put in front of
163
+ // its next answer, whatever tool it runs in. What came before this server found the session
164
+ // is left to quilt_inbox.
165
+ const shown = { key: null, seq: 0 }
166
+ const track = async (d) => {
167
+ const key = `${d.dir}:${d.pid}`
168
+ if (shown.key === key) return shown
169
+ const { seq } = await call(d, 'POST', '/inbox', { after: 0 })
170
+ Object.assign(shown, { key, seq: seq || 0 })
171
+ return shown
172
+ }
173
+ const arrivals = async (d) => {
174
+ try {
175
+ const s = await track(d)
176
+ const r = await call(d, 'POST', '/inbox', { after: s.seq })
177
+ s.seq = r.seq
178
+ const text = renderInbox(r.events)
179
+ return text ? `📬 ${text}\n\n` : ''
180
+ } catch { return '' }
181
+ }
182
+ // Tools that move work forward wait until nobody is waiting for an answer from this agent:
183
+ // the rule is the daemon's (Session.duties), so it holds for every tool and every client.
184
+ const gateFor = (tool) => async (d) => {
185
+ const { waiting } = await call(d, 'GET', '/duties')
186
+ return renderUnanswered(waiting, `call ${tool} again`)
187
+ }
188
+ // Files this agent holds that someone waits for in the file queue: said with every answer until it hands them off.
189
+ const queuedNow = async (d) => { try { return (await call(d, 'GET', '/duties')).queued || [] } catch { return [] } }
190
+ const queueGate = (then) => async (d) => renderQueued(await queuedNow(d), then)
191
+ // When this person lets their AI pick up work by itself: the next task, said as it finishes one.
192
+ const nextUp = async (d) => { try { const n = renderNextTask((await call(d, 'GET', '/duties')).next); return n ? `\n\n➡️ ${n}` : '' } catch { return '' } }
193
+ const gates = (...list) => async (d) => { for (const g of list) { const r = g && await g(d); if (r) return r } return '' }
194
+ const withDaemon = async (fn, { inbox = true, gate = null } = {}) => {
195
+ const d = findDaemon(joined ? joined.dir : undefined)
196
+ if (!d) return { content: [{ type: 'text', text: NOT_RUNNING + stale() }], isError: true }
197
+ const before = async () => introduce() + (await notices(d)) + (inbox ? await arrivals(d) : '')
198
+ const waiting = async () => { const n = renderQueueNotice(await queuedNow(d)); return n ? `\n\n📥 ${n}` : '' }
199
+ try {
200
+ const refused = gate ? await gate(d) : ''
201
+ if (refused) return { content: [{ type: 'text', text: (await before()) + refused + stale() }], isError: true }
202
+ const body = await fn(d)
203
+ return { content: [{ type: 'text', text: (await before()) + body + (await waiting()) + stale() }] }
204
+ } catch (err) {
205
+ return { content: [{ type: 'text', text: (await before()) + `Error: ${err.message}` }], isError: true }
206
+ }
207
+ }
208
+
209
+ const taskReader = (st) => ({ name: st.me.name, tool: st.me.tool, asAi: st.me.kind !== 'agent' })
210
+ const describeAssignment = (task, me) => {
211
+ const label = assigneeLabel(task, me)
212
+ const files = task.files?.length ? `\nFiles: ${task.files.join(', ')}` : ''
213
+ return `${label ? `Assigned to ${label}.` : 'Unassigned.'}${files}`
214
+ }
215
+
216
+ server.registerTool('quilt_status', {
217
+ description: 'See who else is in the live session, what they are working on, which files they recently edited or claimed, recent messages, and which open tasks are assigned to you. Call this before starting work.',
218
+ inputSchema: {}
219
+ }, () => withDaemon(async (d) => {
220
+ const st = await call(d, 'GET', '/status')
221
+ return renderStatus(st, { asAi: taskReader(st).asAi, mentionYours: true })
222
+ }))
223
+
224
+ server.registerTool('quilt_tasks', {
225
+ description: 'List the shared task board (To do, In progress, QA, Done), with an id on each task. Open tasks assigned to you are listed first. Call this before starting work.',
226
+ inputSchema: {
227
+ archived: z.boolean().optional().describe('True: list the archived tasks (off the board) instead.')
228
+ }
229
+ }, ({ archived } = {}) => withDaemon(async (d) => {
230
+ const st = await call(d, 'GET', '/status')
231
+ return formatTasks(st.tasks, taskReader(st), { archived: !!archived })
232
+ }))
233
+
234
+ server.registerTool('quilt_add_task', {
235
+ description: 'Add a task to the shared board, in To do. One short line. Optionally assign it to a person or their AI, and name the files it is about.',
236
+ inputSchema: {
237
+ title: z.string().describe('What needs doing, in a few words'),
238
+ assignee: z.string().optional().describe('Who should do it: a person\'s name, or "me". Omit to leave it unassigned.'),
239
+ to_ai: z.boolean().optional().describe('Assign it to that person\'s AI instead of the person. You are this person\'s AI unless you joined as your own agent: set assignee to "me" and to_ai to true to take the task yourself.'),
240
+ files: z.array(z.string()).max(20).optional().describe('Project files this task is about, relative paths such as src/app.js')
241
+ }
242
+ }, ({ title, assignee, to_ai, files }) => withDaemon(async (d) => {
243
+ const body = { title }
244
+ if (assignee != null) body.assignee = assignee
245
+ else if (to_ai) body.assignee = 'me'
246
+ if (to_ai) body.to_ai = true
247
+ if (files !== undefined) body.files = files
248
+ const { task } = await call(d, 'POST', '/tasks', body)
249
+ const me = (await call(d, 'GET', '/info')).name
250
+ return `Added to To do: ${task.title}\n${task.id}\n${describeAssignment(task, me)}`
251
+ }, { gate: gateFor('quilt_add_task') }))
252
+
253
+ server.registerTool('quilt_move_task', {
254
+ 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). ' +
255
+ '"qa" when you finish implementing and testing: requires `qaNotes` (what changed and how you self-validated); without it the move is refused. ' +
256
+ '"done" after QA: requires `verified`, what you ran and what you saw; without it the move is refused.',
257
+ inputSchema: {
258
+ id: z.string().describe('Task id from quilt_tasks'),
259
+ column: z.enum(COLUMN_IDS_LIST).describe(COLUMN_IDS_LIST.join(', ')),
260
+ qaNotes: z.string().max(MAX_VERIFIED).optional().describe('For "qa": describe the changes you made and how you self-validated them, briefly (a few short sentences, no logs).'),
261
+ verified: z.string().max(MAX_VERIFIED).optional().describe('For "done": what you ran and what you saw, concretely and briefly (commands, results, what you exercised in the app; no full logs).')
262
+ }
263
+ }, ({ id, column, qaNotes, verified }) => withDaemon(async (d) => {
264
+ const brief = await call(d, 'POST', '/tasks/brief', { id })
265
+ if (column === 'qa' && !qaNotesEnough(qaNotes)) return qaRefusal({ task: brief.task, checklist: brief.checklist })
266
+ if (column === 'done' && !verifiedEnough(verified)) return doneRefusal({ task: brief.task, checklist: brief.checklist })
267
+ // An agent's finished work goes to git before QA, when it can commit itself (relay-commit.js); Done merges its pull request (the daemon asks the relay).
268
+ if (column === 'qa') {
269
+ const u = await call(d, 'POST', '/unpublished', { task: id, column }).catch(() => null)
270
+ if (u && u.unpublished && u.unpublished.length) return unpublishedRefusal(brief.task, u.unpublished, column, [])
271
+ }
272
+ const patch = { id, column }
273
+ if (column === 'qa') patch.qaNotes = qaNotes
274
+ if (column === 'done') patch.verified = verified
275
+ const { task, merged, branch } = await call(d, 'POST', '/tasks/update', patch)
276
+ if (column === 'doing') return pickupBrief({ ...brief, task: { ...task, comments: brief.task.comments } }) + (branch ? `\nYour folder is on ${branch} now (Quilt made it for this task): commit only the files you change and push there; the pull request opens by itself. Move the task to QA when it is done and tested.` : '')
277
+ if (column === 'qa') return `Moved "${task.title}" to QA. Notes: ${qaNotesLine(task)}` + (task.pr ? ` Pull request #${task.pr.number} waits for a person to move the task to Done, which merges it.` : '') + await nextUp(d)
278
+ if (column === 'done') return `Moved "${task.title}" to Done.${merged ? ` Merged pull request #${merged.number} (${merged.head} → ${merged.base}) as ${String(merged.sha).slice(0, 7)}.` : ''} Verified: ${verifiedLine(task)}` + await nextUp(d)
279
+ return `Moved "${task.title}" to ${columnName(task.column)}.`
280
+ }, { gate: gates(gateFor('quilt_move_task'), column === 'done' && queueGate('move the task again')) }))
281
+
282
+ server.registerTool('quilt_assign_task', {
283
+ description: 'Assign a shared task to a person or to their AI, and optionally set the files it is about. assignee "" clears it. Set assignee to "me" and to_ai to true to take it yourself when you are that person\'s AI.',
284
+ inputSchema: {
285
+ id: z.string().describe('Task id from quilt_tasks'),
286
+ assignee: z.string().describe('A person\'s name, "me", or "" to unassign'),
287
+ to_ai: z.boolean().optional().describe('True: that person\'s AI. Omit or false: the person.'),
288
+ files: z.array(z.string()).max(20).optional().describe('Replace the file list. Omit to leave the files unchanged.')
289
+ }
290
+ }, ({ id, assignee, to_ai, files }) => withDaemon(async (d) => {
291
+ const body = { id, assignee, forAi: !!to_ai }
292
+ if (files !== undefined) body.files = files
293
+ const { task } = await call(d, 'POST', '/tasks/update', body)
294
+ const me = (await call(d, 'GET', '/info')).name
295
+ return describeAssignment(task, me)
296
+ }, { gate: gateFor('quilt_assign_task') }))
297
+
298
+ server.registerTool('quilt_task', {
299
+ description: 'One task in full: its column, assignee, files, QA and Done notes, and its comments (work notes and handoffs people left on it).',
300
+ inputSchema: { id: z.string().describe('Task id from quilt_tasks') }
301
+ }, ({ id }) => withDaemon(async (d) => {
302
+ const { tasks } = await call(d, 'GET', '/tasks')
303
+ const task = tasks.find((t) => t.id === id)
304
+ if (!task) return 'No such task: read the board with quilt_tasks.'
305
+ return formatTaskDetails(task)
306
+ }))
307
+
308
+ server.registerTool('quilt_comment_task', {
309
+ description: 'Add a comment to a task: a work note, a handoff, or why it went to whom. Put reasoning about a task here instead of in the chat. Keep it brief: a few short sentences, no logs, diffs or play-by-play.',
310
+ inputSchema: {
311
+ id: z.string().describe('Task id from quilt_tasks'),
312
+ text: z.string().max(MAX_COMMENT).describe('The comment')
313
+ }
314
+ }, ({ id, text }) => withDaemon(async (d) => {
315
+ const { task } = await call(d, 'POST', '/tasks/comment', { id, text })
316
+ return `Comment added to "${task.title}" (${task.comments.length} on it now).`
317
+ }, { gate: gateFor('quilt_comment_task') }))
318
+
319
+ server.registerTool('quilt_delete_task', {
320
+ description: 'Remove a task from the shared board.',
321
+ inputSchema: { id: z.string().describe('Task id from quilt_tasks') }
322
+ }, ({ id }) => withDaemon(async (d) => {
323
+ await call(d, 'POST', '/tasks/delete', { id })
324
+ return 'Removed.'
325
+ }, { gate: gateFor('quilt_delete_task') }))
326
+
327
+ server.registerTool('quilt_archive_task', {
328
+ description: 'Archive a task: it leaves the board and every task list, but is kept, and can be brought back. archived false brings it back to the column it was in.',
329
+ inputSchema: {
330
+ id: z.string().describe('Task id from quilt_tasks'),
331
+ archived: z.boolean().optional().describe('Omit or true: archive it. False: bring it back.')
332
+ }
333
+ }, ({ id, archived }) => withDaemon(async (d) => {
334
+ const { task } = await call(d, 'POST', '/tasks/update', { id, archived: archived !== false })
335
+ return task.archived ? `Archived "${task.title}".` : `Brought "${task.title}" back to ${columnName(task.column)}.`
336
+ }, { gate: gateFor('quilt_archive_task') }))
337
+
338
+ server.registerTool('quilt_set_focus', {
339
+ description: 'Tell collaborators what you are working on right now (e.g. "adding dark mode to the settings page"). Shown to them live.',
340
+ inputSchema: { focus: z.string().describe('Short description of the current task') }
341
+ }, ({ focus }) => withDaemon(async (d) => {
342
+ await call(d, 'POST', '/focus', { text: focus })
343
+ return `Focus set: ${focus}`
344
+ }, { gate: gateFor('quilt_set_focus') }))
345
+
346
+ server.registerTool('quilt_typing', {
347
+ description: 'Show people in the chat that you are typing a reply ("… is typing" with a small animation), e.g. right after you read a message you will answer. ' +
348
+ 'It lasts about 30 seconds, or until you send with quilt_message (which clears it); call again for a longer reply, or with on false to take it back.',
349
+ inputSchema: {
350
+ on: z.boolean().optional().describe('False: stop showing that you are typing. Default true.'),
351
+ to: z.string().optional().describe('Typing a direct message to this person: only they see it')
352
+ }
353
+ }, ({ on, to }) => withDaemon(async (d) => {
354
+ const r = await call(d, 'POST', '/typing', { on: on !== false, to: to || null })
355
+ return r.typing ? `Showing that you are typing${to ? ` to ${to}` : ''}.` : 'Not showing that you are typing.'
356
+ }))
357
+
358
+ server.registerTool('quilt_name_session', {
359
+ description: 'Name yourself in the Quilt session after what you work on. You are a member of your own there, apart from your person and their other AI sessions: ' +
360
+ 'partners see you as "<their first name> · <your name>", write to you by it, and see which files you hold. A few words, like "file queue" or "billing bug". Messages to your old name still reach you.',
361
+ inputSchema: { name: z.string().min(1).max(40).describe('A few words about what you work on') }
362
+ }, ({ name }) => withDaemon(async (d) => {
363
+ const r = await call(d, 'POST', '/persona/name', { name })
364
+ Object.assign(hello, { name: r.name, named: 'self', told: true })
365
+ return `You are now ${r.name} in this session.`
366
+ }))
367
+
368
+ server.registerTool('quilt_claim', {
369
+ description: 'Claim files so only you can change them while you work: quilt undoes anyone else\'s edits there. Accepts a file path, a folder (it need not exist yet), or a glob like "src/auth/**". Fails if it overlaps someone else\'s claim.',
370
+ inputSchema: {
371
+ pattern: z.string().describe('File path, folder, or glob'),
372
+ reason: z.string().optional().describe('What you are doing there')
373
+ }
374
+ }, ({ pattern, reason }) => withDaemon(async (d) => {
375
+ await call(d, 'POST', '/claim', { pattern, note: reason || '' })
376
+ return `Claimed ${pattern}. Only you can change it until you release it.`
377
+ }, { gate: gateFor('quilt_claim') }))
378
+
379
+ server.registerTool('quilt_release', {
380
+ description: 'Release a claim you made (or "*" for all of yours) once you are done.',
381
+ inputSchema: { pattern: z.string().describe('The claimed pattern, or "*"') }
382
+ }, ({ pattern }) => withDaemon(async (d) => {
383
+ const queued = await queuedNow(d)
384
+ if (pattern && pattern !== '*' && queued.some((q) => q.pattern === pattern.trim())) throw new Error(renderQueued(queued.filter((q) => q.pattern === pattern.trim()), 'release it'))
385
+ const r = await call(d, 'POST', '/release', { pattern })
386
+ return `Released ${r.released} claim(s).${r.held ? ` Kept ${r.held.join(', ')}: someone is waiting for ${r.held.length === 1 ? 'it' : 'them'}; hand off with quilt_handoff.` : ''}`
387
+ }))
388
+
389
+ server.registerTool('quilt_unpack', {
390
+ description: 'Share boxed work before you commit it. In a folder that is a git repository, your edits to a file stay in a box: everyone sees the change, but it is only in your folder, so nobody else\'s git can commit it, until you commit it. ' +
391
+ 'Unpack when someone needs your change now: it reaches everyone\'s folder, and the file stays yours. Decide this yourself; for a large, risky or critical change, or when the project\'s rules say so, ask your person first.',
392
+ inputSchema: { paths: z.array(z.string().min(1).max(500)).min(1).max(100).describe('Your boxed files, relative to the project folder') }
393
+ }, ({ paths }) => withDaemon(async (d) => {
394
+ const rels = paths.map((p) => path.relative(d.dir, path.resolve(d.dir, p)).split(path.sep).join('/'))
395
+ const r = await call(d, 'POST', '/unpack', { paths: rels })
396
+ if (!r.unpacked.length) throw new Error(`${r.notBoxed.join(', ')} ${r.notBoxed.length === 1 ? 'is' : 'are'} not boxed by you.`)
397
+ return `Unpacked ${r.unpacked.join(', ')}: in everyone's folder now. Your next edit to one boxes it again.${r.notBoxed.length ? ` Not boxed by you: ${r.notBoxed.join(', ')}.` : ''}`
398
+ }))
399
+
400
+ server.registerTool('quilt_request_file', {
401
+ description: 'Ask for a file someone else holds (claimed), instead of editing it: your request joins its file queue, its holder is told, and when they finish they hand it to you with their context. ' +
402
+ 'You are woken (a direct message: quilt_inbox, the quilt://inbox resource, your webhook) when it is yours. Asking again for the same file updates your request.',
403
+ inputSchema: {
404
+ path: z.string().min(1).max(500).describe('The file you need, relative to the project folder'),
405
+ title: z.string().min(1).max(120).describe('What you will do, e.g. "Working on retry logic for task t-12"'),
406
+ description: z.string().max(300).optional().describe('Your plan in summary: what you want to change and why (300 characters at most)'),
407
+ task: z.string().max(80).optional().describe('The task id this is for, if any')
408
+ }
409
+ }, ({ path: p, title, description, task }) => withDaemon(async (d) => {
410
+ const rel = path.relative(d.dir, path.resolve(d.dir, p)).split(path.sep).join('/')
411
+ const r = await call(d, 'POST', '/request-file', { path: rel, title, description: description || '', task })
412
+ return `Asked for ${rel}: you are number ${r.position} in the queue for ${r.pattern} (held by ${r.holder}). ${r.holder} is told; you will be handed it with their context. Carry on with other work.`
413
+ }))
414
+
415
+ server.registerTool('quilt_handoff', {
416
+ description: 'Hand a file you hold to someone waiting for it in its file queue (the first, unless you name them), with your context: what you changed, what is left, anything they should know. ' +
417
+ 'The claim becomes theirs and they get your context in a direct message. Required before you finish or release a file someone is waiting for.',
418
+ inputSchema: {
419
+ path: z.string().min(1).max(500).describe('The file (or the claimed pattern) to hand off'),
420
+ context: z.string().min(1).max(2000).describe('What you changed, what is left, gotchas'),
421
+ to: z.string().max(80).optional().describe('Who to hand it to (a name or request id); omit for the first in the queue')
422
+ }
423
+ }, ({ path: p, context, to }) => withDaemon(async (d) => {
424
+ const rel = path.relative(d.dir, path.resolve(d.dir, p)).split(path.sep).join('/')
425
+ const r = await call(d, 'POST', '/handoff', { path: rel, context, to: to || '' })
426
+ return `Handed ${r.pattern} to ${r.to} with your context.${r.waiting ? ` ${r.waiting} more waiting: ${r.to} hands it on next.` : ''}`
427
+ }))
428
+
429
+ server.registerTool('quilt_withdraw_request', {
430
+ description: 'Take back your request for a file (from quilt_request_file) when you no longer need it.',
431
+ inputSchema: { path: z.string().min(1).max(500).describe('The file you asked for') }
432
+ }, ({ path: p }) => withDaemon(async (d) => {
433
+ const rel = path.relative(d.dir, path.resolve(d.dir, p)).split(path.sep).join('/')
434
+ const { claims } = await call(d, 'GET', '/tree')
435
+ const me = (await call(d, 'GET', '/info')).name
436
+ const mine = claims.flatMap((c) => c.queue || []).filter((r) => r.by === me && r.path === rel)
437
+ let n = 0
438
+ for (const r of mine) n += (await call(d, 'POST', '/withdraw-request', { request: r.id })).withdrawn
439
+ return n ? `Withdrew your request for ${rel}.` : `You had not asked for ${rel}.`
440
+ }))
441
+
442
+ const did = (m, deleted = false) => mergeAction(m, deleted)
443
+ const mergeLine = (m, me) => {
444
+ const who = m.by === me ? 'you' : m.by
445
+ const other = m.others[0] ? (m.others[0] === me ? 'you' : m.others[0]) : 'the session'
446
+ const what = m.kind === 'ai' ? `merged by AI, waiting for a look`
447
+ : m.kind === 'claimed' ? `${who} ${did(m)} but ${m.claimedBy} has it claimed`
448
+ : m.oursDeleted ? `${who} ${did(m, true)} and ${other} changed it in the session`
449
+ : m.theirsHash === null ? `${who} ${did(m)} but it was deleted in the session`
450
+ : `${who} ${did(m)} and ${other} changed it in the session`
451
+ return `- \`${m.path}\` (id ${m.id}, ${m.state}): ${what}${m.reason ? ` — ${m.reason}` : ''}`
452
+ }
453
+
454
+ server.registerTool('quilt_merges', {
455
+ description: 'Files changed in two places (offline or by a pull, and in the session). Quilt never combines two different changes on its own. Each has an id. The file currently holds the session\'s version; the other version is under .quilt/merges/<id>/ours (with base and theirs beside it). To settle one yourself: write the merged file, then call quilt_resolve_merge with how "agent".',
456
+ inputSchema: {}
457
+ }, () => withDaemon(async (d) => {
458
+ const { merges } = await call(d, 'GET', '/merges')
459
+ const me = (await call(d, 'GET', '/info')).name
460
+ const open = merges.filter((m) => m.state !== 'done')
461
+ if (!open.length) return 'Nothing to merge.'
462
+ return `Merges to settle:\n${open.map((m) => mergeLine(m, me)).join('\n')}`
463
+ }))
464
+
465
+ server.registerTool('quilt_resolve_merge', {
466
+ description: 'Settle a merge from quilt_merges. how: "agent" after you wrote the merged file yourself; "mine" to keep the offline version; "theirs" to keep the session version; "review" to accept an AI merge as it is.',
467
+ inputSchema: {
468
+ id: z.string().describe('The merge id'),
469
+ how: z.enum(['agent', 'mine', 'theirs', 'review']).describe('How it was settled')
470
+ }
471
+ }, ({ id, how }) => withDaemon(async (d) => {
472
+ const r = await call(d, 'POST', '/merges/resolve', { id, how })
473
+ return `Settled the merge of ${r.path} (${how}).`
474
+ }, { gate: gateFor('quilt_resolve_merge') }))
475
+
476
+ server.registerTool('quilt_clash', {
477
+ description: CLASH_DESCRIPTION,
478
+ inputSchema: clashSchema(z)
479
+ }, (args) => withDaemon(async (d) => {
480
+ const r = await call(d, 'POST', '/clash', args)
481
+ if (r && r.ok === false) throw new Error(r.text)
482
+ return r && r.text ? r.text : 'Done.'
483
+ }))
484
+
485
+ server.registerTool('quilt_message', {
486
+ description: CHAT_RULES + ' Set "to" to message one person directly.',
487
+ inputSchema: {
488
+ text: z.string(),
489
+ to: z.string().optional().describe('Name of one collaborator for a direct message'),
490
+ everyone: z.boolean().optional().describe('Only for a real announcement to the whole session: lets a message that @mentions nobody go out'),
491
+ re: z.string().max(40).optional().describe(RE_HELP),
492
+ also: z.boolean().optional().describe('Only when another of your AI sessions already wrote to them, or their message was already answered, and yours is about something different they need')
493
+ }
494
+ }, ({ text, to, everyone, also, re }) => withDaemon(async (d) => {
495
+ const r = await call(d, 'POST', '/say', { text, to, via, everyone: !!everyone, also: !!also, re: re || '' })
496
+ if (to && r.recipientAway) return `Sent. (${to} is away: it works in turns and sees this at the start of its next one.)`
497
+ return to && !r.recipientOnline ? `Sent. (${to} is offline and will see it when they reconnect.)` : 'Sent.'
498
+ }))
499
+
500
+ // Inbox cursors: one for the tool, one for the channel push, so an event pushed to Claude
501
+ // Code still shows in quilt_inbox, and one that Claude Code ignored (no channel) is not lost.
502
+ const cursor = () => ({ key: null, seq: 0 })
503
+ const toolCursor = cursor()
504
+ const pushCursor = cursor()
505
+ const at = (c, d) => {
506
+ const key = `${d.dir}:${d.pid}`
507
+ if (c.key !== key) { c.key = key; c.seq = 0; c.fresh = true } else c.fresh = false
508
+ return c
509
+ }
510
+
511
+ server.registerTool('quilt_check_update', {
512
+ description: 'Whether the Quilt you run (your image) is current. Give the version you run; without one, this Quilt install is checked. An out-of-date image is told to update the app.',
513
+ inputSchema: { image: z.string().max(40).optional().describe('The Quilt version you run, such as 0.3.4') }
514
+ }, ({ image }) => ({ content: [{ type: 'text', text: updates.describe(image) }] }))
515
+
516
+ server.registerTool('quilt_inbox', {
517
+ description: 'What is waiting for you: mentions of you in chat (@yourname), direct messages to you, and tasks handed to you since you last looked. Act on each one: answer with quilt_message, take a task with quilt_move_task.',
518
+ inputSchema: {
519
+ all: z.boolean().optional().describe('Include what you already looked at (the last 100 events)'),
520
+ no_reply: z.array(z.string().max(40)).max(50).optional().describe('Ids of messages that need nothing back from you (thanks, a greeting, an FYI, a status report): settled without a reply, for all your sessions')
521
+ }
522
+ }, ({ all, no_reply }) => withDaemon(async (d) => {
523
+ const c = at(toolCursor, d)
524
+ const settled = no_reply && no_reply.length ? (await call(d, 'POST', '/inbox/settle', { ids: no_reply })).settled : []
525
+ const r = await call(d, 'POST', '/inbox', { after: all ? 0 : c.seq, all: !!all })
526
+ c.seq = r.seq
527
+ const s = await track(d)
528
+ if (no_reply && no_reply.length) {
529
+ const note = `Settled as needing no reply: ${settled.length ? settled.join(', ') : 'none (unknown ids)'}.`
530
+ return [note, renderInbox(r.events, { me: r.me })].filter(Boolean).join('\n\n')
531
+ }
532
+ s.seq = Math.max(s.seq, r.seq) // shown here, so not again in front of the next answer
533
+ return renderInbox(r.events, { me: r.me }) || (all ? 'Nothing has been waiting for you.' : 'Nothing new for you.')
534
+ }, { inbox: false }))
535
+
536
+ server.registerTool('quilt_webhook_subscribe', {
537
+ description: 'Be told as it happens, by an HTTP POST to a URL of yours, when you are mentioned in chat (@yourname), sent a direct message or handed a task: ' +
538
+ 'no need to poll quilt_inbox. One subscription per session folder; calling again replaces it. Each POST is JSON, signed with the secret ' +
539
+ '(x-quilt-signature: sha256=HMAC-SHA256(secret, "<x-quilt-timestamp>.<body>")); answer 2xx. Give a secret of your own or get one back (shown once).',
540
+ inputSchema: {
541
+ url: z.string().min(1).max(2000).describe('The URL to POST to: https, or http on this computer (a webhook trigger of your routine, for example)'),
542
+ secret: z.string().max(200).optional().describe('16 to 200 characters for signing; omit to have Quilt make one'),
543
+ events: z.array(z.enum(WEBHOOK_EVENTS)).max(WEBHOOK_EVENTS.length).optional().describe(`Which events to send (default: all): ${WEBHOOK_EVENTS.join(', ')}`),
544
+ bearer: z.string().max(500).optional().describe('A key your receiver wants on every POST, sent as "Authorization: Bearer <key>" (a Grok Bot routine\'s sender key, for example)')
545
+ }
546
+ }, ({ url, secret, events, bearer }) => withDaemon(async (d) => {
547
+ const { webhook } = await call(d, 'POST', '/webhook', { url, secret, events, bearer })
548
+ return describeSubscription(webhook, { showSecret: webhook.made })
549
+ }))
550
+
551
+ server.registerTool('quilt_webhook_unsubscribe', {
552
+ description: 'Stop the webhook: Quilt no longer POSTs mentions, direct messages and tasks to you. quilt_inbox still has them.',
553
+ inputSchema: {}
554
+ }, () => withDaemon(async (d) => {
555
+ const { had } = await call(d, 'POST', '/webhook/clear', {})
556
+ return had ? 'Webhook removed. Mentions, direct messages and tasks still wait in quilt_inbox.' : 'You had no webhook.'
557
+ }))
558
+
559
+ // Claude Code started with the quilt channel gets each new inbox event as a turn. Other
560
+ // tools are not sent anything (they read quilt_inbox). What was already waiting when this
561
+ // server first finds the session is left to quilt_inbox, so a fresh Claude is not flooded.
562
+ // Any client that subscribes to the inbox resource (plain MCP) is told the moment something
563
+ // arrives, and reads it; Claude Code started with the quilt channel gets each event as a turn
564
+ // instead. What was already waiting when this server first finds the session is left to quilt_inbox.
565
+ const subscribed = new Set()
566
+ const pushInbox = async () => {
567
+ const channel = clientTool() === 'Claude Code'
568
+ if (!channel && !subscribed.size) return
569
+ const d = findDaemon(joined ? joined.dir : undefined)
570
+ if (!d) return
571
+ const c = at(pushCursor, d)
572
+ const r = await call(d, 'POST', '/inbox', { after: c.seq, poll: true })
573
+ c.seq = r.seq
574
+ if (c.fresh || !r.events.length) return
575
+ for (const uri of subscribed) await server.server.sendResourceUpdated({ uri })
576
+ if (!channel) return
577
+ for (const e of r.events) {
578
+ await server.server.notification({
579
+ method: CHANNEL_METHOD,
580
+ params: { content: [describeEvent(e), renderContext(e, { me: r.me }), INBOX_HOW].filter(Boolean).join('\n'), meta: { kind: e.kind, from: String(e.by || ''), id: String(e.id || '') } }
581
+ })
582
+ }
583
+ }
584
+ server.registerResource('inbox', INBOX_URI, {
585
+ title: 'Quilt inbox',
586
+ description: 'Mentions of you, direct messages to you and tasks handed to you (the last 100). Subscribe to be told when something new arrives.',
587
+ mimeType: 'text/markdown'
588
+ }, async (uri) => {
589
+ const d = findDaemon(joined ? joined.dir : undefined)
590
+ const r = d ? await call(d, 'POST', '/inbox', { after: 0, poll: true }).catch(() => ({ events: [] })) : { events: [] }
591
+ return { contents: [{ uri: uri.href, mimeType: 'text/markdown', text: d ? (renderInbox(r.events, { me: r.me }) || 'Nothing has been waiting for you.') : NOT_RUNNING }] }
592
+ })
593
+ server.server.setRequestHandler(SubscribeRequestSchema, (req) => {
594
+ if (req.params.uri === INBOX_URI) {
595
+ subscribed.add(INBOX_URI)
596
+ // Take stock now, so only what arrives from here on is announced.
597
+ const d = findDaemon(joined ? joined.dir : undefined)
598
+ if (d) call(d, 'POST', '/inbox', { after: 0, poll: true }).then((r) => { const c = at(pushCursor, d); if (c.fresh || c.seq < r.seq) { c.seq = r.seq; c.fresh = false } }).catch(() => {})
599
+ }
600
+ return {}
601
+ })
602
+ server.server.setRequestHandler(UnsubscribeRequestSchema, (req) => { subscribed.delete(req.params.uri); return {} })
603
+ const pushTimer = setInterval(() => pushInbox().catch(() => {}), INBOX_POLL_MS)
604
+ pushTimer.unref()
605
+
606
+ server.registerTool('quilt_read_messages', {
607
+ description: 'Read chat messages from collaborators, including direct messages and shared files (with the local path each file was saved to). By default returns only unread messages.',
608
+ inputSchema: {
609
+ all: z.boolean().optional().describe('Return recent messages, not just unread ones'),
610
+ limit: z.number().int().min(1).max(200).optional()
611
+ }
612
+ }, ({ all, limit }) => withDaemon(async (d) => {
613
+ const { messages } = await call(d, 'POST', '/messages', { unreadOnly: !all, limit: limit || 30 })
614
+ if (!messages.length) return all ? 'No messages yet.' : 'No unread messages.'
615
+ const me = (await call(d, 'GET', '/status')).me.name
616
+ return messages.map((m) => `- ${renderMessage(m, me)}`).join('\n')
617
+ }))
618
+
619
+ server.registerTool('quilt_conversation', {
620
+ description: CONVERSATION_DESCRIPTION,
621
+ inputSchema: {
622
+ with: z.string().max(200).optional().describe('Only the conversation with this person or agent: direct messages either way, and messages that @mention one of you'),
623
+ q: z.string().max(200).optional().describe('Only messages containing this text (any case)'),
624
+ before: z.string().max(40).optional().describe('Only messages before this message id (to read further back)'),
625
+ limit: z.number().int().min(1).max(200).optional().describe('How many of the newest matching messages (default 30)')
626
+ }
627
+ }, (args) => withDaemon(async (d) => {
628
+ const r = await call(d, 'POST', '/conversation', { with: args.with, q: args.q, before: args.before, limit: args.limit })
629
+ return renderConversation(r, { me: r.me, with: args.with, q: args.q })
630
+ }))
631
+
632
+ server.registerTool('quilt_send_file', {
633
+ description: 'Send a file from this project to collaborators through chat, without adding it to the shared project (useful for screenshots, logs, exports, or drafts). The file must be inside the project folder.',
634
+ inputSchema: {
635
+ path: z.string().describe('Path relative to the project root'),
636
+ to: z.string().optional().describe('Send only to this collaborator'),
637
+ message: z.string().optional().describe('Optional note to go with the file')
638
+ }
639
+ }, ({ path: p, to, message }) => withDaemon(async (d) => {
640
+ const abs = insideProject(d.dir, p, { reading: true })
641
+ const r = await call(d, 'POST', '/send', { path: abs, to, text: message || '' })
642
+ return `Sent ${r.file.name}${to ? ` to ${to}` : ''}.`
643
+ }, { gate: gateFor('quilt_send_file') }))
644
+
645
+ server.registerTool('quilt_get_file', {
646
+ description: 'Download a file someone shared in chat. Received files are normally saved automatically (see quilt_read_messages); use this to fetch one again or save it into the project.',
647
+ inputSchema: {
648
+ id: z.string().describe('The message id shown next to the file'),
649
+ dest: z.string().optional().describe('Destination path or folder inside the project (default: .quilt/inbox/)')
650
+ }
651
+ }, ({ id, dest }) => withDaemon(async (d) => {
652
+ const r = await call(d, 'POST', '/get', { id, dest: dest ? insideProject(d.dir, dest) : undefined })
653
+ return `Saved to ${path.relative(d.dir, r.path)}`
654
+ }))
655
+
656
+ // ------------------------------------------------ joining as an agent --
657
+
658
+ const startAs = async ({ conn, folder, agent, inviteServer, starting = false, workspace = '' }) => {
659
+ if (joined) throw new Error(`Already in session ${path.basename(joined.dir)} (${joined.dir}). Call quilt_leave_session first.`)
660
+ const cwd = process.env.QUILT_DIR || process.cwd()
661
+ let dir = folder ? path.resolve(cwd, folder) : null
662
+ if (!dir) {
663
+ // Join into the current folder only if it's empty or already this room's folder. A
664
+ // person's folder for another room gets no copy inside it (their session would sync it).
665
+ const saved = readConfig(cwd)
666
+ const empty = !fs.existsSync(cwd) || fs.readdirSync(cwd).filter((n) => n !== '.quilt' && n !== '.DS_Store').length === 0
667
+ dir = empty || (saved && saved.room === conn.room) ? cwd : saved ? null : roomFolder(cwd, conn.room)
668
+ }
669
+ // A person is already syncing this folder: work through their session.
670
+ if (dir && runningElsewhere(dir)) {
671
+ const saved = readConfig(dir)
672
+ if (saved && saved.room === conn.room) {
673
+ joined = { dir, attached: true }
674
+ return { dir, attached: true }
675
+ }
676
+ throw new Error(`${dir} is already synced by another quilt session. Choose another folder.`)
677
+ }
678
+ // A person's folder that isn't being synced right now (they left, or closed the app) is
679
+ // still theirs: taking it over would lock them out of their own session until the agent
680
+ // leaves. The agent is its own member, in its own copy of the room.
681
+ // Each agent joins as itself: its name, key and passes come from its saved keys.
682
+ const agentKey = pickAgent({ agent })
683
+ const auth = sessionPasses({ agent: agentKey })
684
+ let aside = null
685
+ if (!dir || personsFolder(dir, auth.name)) {
686
+ if (starting) throw new Error(`${dir} already belongs to a session a person started on this computer. Ask them for its invite link and join that, or start from another folder.`)
687
+ aside = dir || cwd
688
+ dir = agentCopyFolder(conn.room, auth.name)
689
+ }
690
+ // Prefer the agent's registered provider when it names the model maker (xAI), or
691
+ // when the MCP client did not identify a tool. Host IDEs (Cursor) otherwise stay.
692
+ let tool = clientTool()
693
+ try {
694
+ const me = await agentWhoami({ name: auth.name })
695
+ const provider = me?.agent?.provider
696
+ if (provider) {
697
+ const labeled = toolLabel(provider)
698
+ if (labeled === 'xAI') tool = 'xAI'
699
+ else if ((!tool || tool === 'AI agent') && labeled) tool = labeled
700
+ }
701
+ } catch {}
702
+ logs = []
703
+ const run = await runSession({
704
+ dir,
705
+ conn,
706
+ name: auth.name,
707
+ tool,
708
+ kind: 'agent',
709
+ passes: auth.passes,
710
+ identity: auth.identity,
711
+ joined: !inviteServer && !conn.viewSecret,
712
+ inviteServer,
713
+ // This agent's own chat lives where it was started, which may be above the synced folder.
714
+ readerOptions: { chatDir: process.cwd() },
715
+ onLog: (line) => { logs.push(line); if (logs.length > 50) logs.shift() },
716
+ onFatal: async (err) => { logs.push(`stopped: ${err.message}`); await leave() },
717
+ workspace
718
+ })
719
+ joined = { run, dir, invite: run.invite }
720
+ // Puts the new room in its workspace with the agent's own key. A failure (offline, the
721
+ // workspace was removed, the API doesn't have workspaces on) is logged and reported, never
722
+ // fatal: the session has already started and works without it, outside any workspace.
723
+ let workspaceError = ''
724
+ if (workspace) {
725
+ try {
726
+ const saved = await agentAccess({ name: agentKey })
727
+ await setSessionWorkspace({ token: saved.accessKey, id: workspace, room: conn.room, api: saved.api })
728
+ // Then hand its link to the agents that join it by themselves, in the background:
729
+ // starting never waits for this, and a failure is only logged.
730
+ announceWhenReported(agentAnnouncer({ name: agentKey, workspace, room: conn.room, link: run.invite }), {
731
+ alive: () => joined?.run === run,
732
+ log: (line) => run.session.log(line)
733
+ }).catch(() => {})
734
+ } catch (err) {
735
+ workspaceError = `Could not add it to workspace ${workspace}: ${err.message}`
736
+ run.session.log(`could not add this session to workspace ${workspace}: ${err.message}`)
737
+ forgetWorkspace(dir)
738
+ }
739
+ }
740
+ return { dir, invite: run.invite, aside, workspace: workspaceError ? '' : workspace, workspaceError }
741
+ }
742
+
743
+ const leave = async () => {
744
+ if (!joined) return false
745
+ const j = joined
746
+ joined = null
747
+ if (j.run) await j.run.stop()
748
+ return true
749
+ }
750
+
751
+ const describeSession = async (dir, extra = '') => {
752
+ const d = findDaemon(dir)
753
+ const st = d ? await call(d, 'GET', '/status') : null
754
+ const info = d ? await call(d, 'GET', '/info') : null
755
+ const lines = [extra]
756
+ if (info) lines.push(`Project folder: ${info.dir}`, `You appear as: ${info.name}${info.kind === 'agent' ? ' (AI agent)' : ''}`)
757
+ // Only the id is known locally in phase 1 (its name comes from the accounts API later).
758
+ const cfg = readConfig(dir)
759
+ if (cfg && cfg.workspace) lines.push(`Workspace: ${cfg.workspace}`)
760
+ if (st) lines.push(`Shared files: ${st.fileCount}`, `People online: ${st.peers.map((p) => p.name).join(', ') || 'nobody else yet'}`)
761
+ const acc = info && info.access
762
+ if (acc && acc.state === 'pending') lines.push('⏳ Waiting for the session owner to let you in. Nothing syncs until they approve you; check again with quilt_session_info.')
763
+ else if (acc && acc.controlled) lines.push(`Your access: ${acc.owner ? 'owner' : acc.role === 'viewer' ? 'view only (your file changes are undone)' : acc.scopes && acc.scopes.length ? `may change files only in ${acc.scopes.join(', ')}` : 'may change any file'}`)
764
+ if (info && info.invite) lines.push(`Invite link to edit (for others to join): ${info.invite}`)
765
+ if (info && info.viewInvite) lines.push(`Invite link to view only: ${info.viewInvite}`)
766
+ if (joined && joined.run) lines.push('The session runs inside this MCP server and ends when it stops, or with quilt_leave_session.')
767
+ return lines.filter(Boolean).join('\n')
768
+ }
769
+
770
+ server.registerTool('quilt_join_session', {
771
+ description: 'Join a live quilt session from an invite link, as an AI agent. The shared project is synced into a folder (the current folder if it is empty or already this session\'s, otherwise a new "quilt-<room>" subfolder) and kept in sync live. Other people see you in the session.',
772
+ inputSchema: {
773
+ invite: z.string().describe('The invite link, like https://join.heyquilt.com/<room>#<secret> (or the full "quilt join <link>" command)'),
774
+ folder: z.string().optional().describe('Where to put the project, relative to the current folder'),
775
+ agent: z.string().optional().describe('Which Quilt agent to join as (saved with `quilt agent join`). Optional when this computer has only one.')
776
+ }
777
+ }, async ({ invite, folder, agent }) => {
778
+ try {
779
+ const conn = decodeInvite(invite)
780
+ const r = await startAs({ conn, folder, agent })
781
+ const text = await describeSession(r.dir, r.attached
782
+ ? `This folder is already in the session (someone runs quilt here), so you're working through their session.`
783
+ : `Joined room ${conn.room}. Files are synced into ${r.dir}; edit them there.${r.aside ? ` (${r.aside} is a person's own copy of this session on this computer and stays theirs: don't sync or edit it from here.)` : ''}`)
784
+ return { content: [{ type: 'text', text }] }
785
+ } catch (err) {
786
+ return { content: [{ type: 'text', text: `Could not join: ${err.message}` }], isError: true }
787
+ }
788
+ })
789
+
790
+ server.registerTool('quilt_start_session', {
791
+ description: 'Start a new live quilt session for a folder, as an AI agent, and get an invite link for others.',
792
+ inputSchema: {
793
+ folder: z.string().optional().describe('Folder to share, relative to the current folder (default: current folder)'),
794
+ agent: z.string().optional().describe('Which Quilt agent to join as (saved with `quilt agent join`). Optional when this computer has only one.'),
795
+ workspace: z.string().optional().describe('The id of the workspace this session belongs to, if the person gave you one.')
796
+ }
797
+ }, async ({ folder, agent, workspace }) => {
798
+ if (workspace && !WORKSPACE_ID.test(workspace)) {
799
+ return { content: [{ type: 'text', text: `Could not start: "${workspace}" is not a workspace id. A workspace id looks like 6f1d2c3b-4a5e-4f60-8a9b-0c1d2e3f4a5b; ask the person for it, or leave workspace out.` }], isError: true }
800
+ }
801
+ try {
802
+ const r = await startAs({ conn: newConn(), folder: folder || '.', agent, starting: true, workspace: workspace || '' })
803
+ const started = `Started a session for ${r.dir}. Share the invite link below with collaborators.`
804
+ return { content: [{ type: 'text', text: await describeSession(r.dir, r.workspaceError ? `${started}\n${r.workspaceError}. It is outside any workspace.` : started) }] }
805
+ } catch (err) {
806
+ return { content: [{ type: 'text', text: `Could not start: ${err.message}` }], isError: true }
807
+ }
808
+ })
809
+
810
+ server.registerTool('quilt_leave_session', {
811
+ description: 'Leave the session this agent joined or started. Files stay on disk.',
812
+ inputSchema: {}
813
+ }, async () => {
814
+ const left = await leave()
815
+ return { content: [{ type: 'text', text: left ? 'Left the session. The files stay where they are.' : 'You have not joined a session from here.' }] }
816
+ })
817
+
818
+ // ------------------------------------------------------ commit timing --
819
+
820
+ const describeCommits = (c) => {
821
+ const lines = []
822
+ lines.push(c.ready ? '✅ Everyone else\'s AI is idle: a good moment to commit.' : `⏳ Still working: ${c.busy.map((b) => b.why).join('; ')}`)
823
+ lines.push(describeCommitRequests(c, { canCommit: !!c.canCommit }))
824
+ if (c.open.length) lines.push('A request committed outside Quilt (with git by hand) is closed with quilt_commit_request_done.')
825
+ return lines.join('\n')
826
+ }
827
+
828
+ server.registerTool('quilt_set_work', {
829
+ description: 'Tell everyone whether you (this agent) are working or done, so people know when it is safe to commit. Set "working" when you start a task and "done" when you finish: ' +
830
+ '"done" lets go of the files claimed for you while you edited. Like every tool that moves work on, it is refused while someone is waiting for an answer from you.',
831
+ inputSchema: {
832
+ state: z.enum(['working', 'done']),
833
+ note: z.string().optional().describe('What you are working on, in a few words')
834
+ }
835
+ }, ({ state, note }) => withDaemon(async (d) => {
836
+ if (state === 'working') {
837
+ await call(d, 'POST', '/work', { state, note })
838
+ return 'Marked as working. Set "done" when you finish so others know it is safe to commit.'
839
+ }
840
+ const { released } = await call(d, 'POST', '/finish', {})
841
+ await call(d, 'POST', '/work', { state, note })
842
+ return `Marked as done.${released ? ` Let go of ${released} file${released === 1 ? '' : 's'} claimed for you while you edited.` : ''}` + await nextUp(d)
843
+ }, { gate: gates(gateFor('quilt_set_work'), state === 'done' && queueGate('set done again')) }))
844
+
845
+ server.registerTool('quilt_share', {
846
+ description: 'Share what you are doing with your collaborators; it appears live in their Quilt feed and on the task board, in any tool. Call it when you start on a request ' +
847
+ '(`request`: what your user asked, in a sentence; `summary`: your plan) and again when you finish (`summary`: what you did; `files`: files you changed). ' +
848
+ 'Starting marks you as working, so the host doesn\'t commit under you. Keep it short and never include secrets, keys or file contents.',
849
+ inputSchema: {
850
+ request: z.string().max(2000).optional().describe('What your user asked for, in a sentence (only when starting a new request)'),
851
+ summary: z.string().max(4000).describe('Your plan, progress or result, in one to three sentences'),
852
+ files: z.array(z.string().max(300)).max(30).optional().describe('Project files you changed, relative paths')
853
+ }
854
+ }, ({ request, summary, files }) => withDaemon(async (d) => {
855
+ const r = await call(d, 'POST', '/share-work', { tool: clientTool(), request, summary, files })
856
+ if (r.automatic) return 'Quilt already shares your chat with the session as you work, so nothing more to do.'
857
+ return r.shared ? 'Shared with the session.' : 'Nothing to share: give a summary.'
858
+ }))
859
+
860
+ server.registerTool('quilt_chat_link', {
861
+ description: 'Session owner only: make a link for an AI that only has a chat window (ChatGPT, claude.ai, Grok and the like). ' +
862
+ 'Your user pastes it into that chat; the AI then reads and sends messages, reads and adds tasks, reads files, and adds pictures, documents and notes ' +
863
+ 'by opening links, with nothing to install. It joins as its own member (removing it ends the link). ' +
864
+ 'It works for ten minutes unless extended with quilt_extend_chat_link; once it runs out, a new link is needed.',
865
+ inputSchema: {
866
+ name: z.string().max(40).optional().describe('How it appears in the session, e.g. "ChatGPT"'),
867
+ minutes: z.number().int().min(1).max(43200).optional().describe('How long the link works (default 10 minutes, at most 30 days)')
868
+ }
869
+ }, ({ name, minutes }) => withDaemon(async (d) => {
870
+ const r = await call(d, 'POST', '/chat-link', { name, minutes })
871
+ return `Chat link for ${r.name} (works until ${new Date(r.expiresAt).toISOString().slice(0, 16).replace('T', ' ')} UTC):\n${r.url}\n\n` +
872
+ 'Give it to your user to paste into the chat AI, with a line like "Open this link and follow it to join our Quilt session." Anyone with the link can act as that member, so share it only there.'
873
+ }))
874
+
875
+ server.registerTool('quilt_extend_chat_link', {
876
+ description: 'Session owner only: set how long a chat link still works, from now (shorter or longer). Only a link that still works can be extended; one that ran out needs a new link (quilt_chat_link).',
877
+ inputSchema: {
878
+ who: z.string().max(80).describe('The chat AI\'s name in the session, e.g. "ChatGPT"'),
879
+ minutes: z.number().int().min(1).max(43200).describe('Minutes from now, e.g. 60 for an hour')
880
+ }
881
+ }, ({ who, minutes }) => withDaemon(async (d) => {
882
+ const r = await call(d, 'POST', '/chat-link/extend', { who, minutes })
883
+ return `${r.name}'s chat link now works until ${new Date(r.expiresAt).toISOString().slice(0, 16).replace('T', ' ')} UTC.`
884
+ }))
885
+
886
+ server.registerTool('quilt_before_edit', {
887
+ description: 'Call before you change files (with your own edit tools), with the paths you are about to change. For each file: whether it is yours to edit ' +
888
+ '(a file nobody holds is claimed for you until you finish; one someone else holds is refused: do not edit it, message them instead), and what people said ' +
889
+ 'about those files in chat lately (what they asked for, warned about or planned). Works in every tool; it is how Quilt keeps agents from overwriting each other.',
890
+ inputSchema: {
891
+ paths: z.array(z.string().min(1).max(500)).min(1).max(50).describe('Files you are about to change: relative to the project folder, or absolute inside it')
892
+ }
893
+ }, ({ paths }) => withDaemon(async (d) => {
894
+ const outside = []
895
+ const rels = []
896
+ for (const p of paths) {
897
+ const rel = path.relative(d.dir, path.resolve(d.dir, p)).split(path.sep).join('/')
898
+ if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) outside.push(p)
899
+ else rels.push(rel)
900
+ }
901
+ const r = rels.length ? await call(d, 'POST', '/before-edit', { paths: rels }) : { files: [], chat: [] }
902
+ const lines = []
903
+ for (const f of r.files) {
904
+ if (!f.shared) lines.push(`- ${f.path}: not shared by Quilt (ignored or private): edit it as you like.`)
905
+ else if (f.ok) lines.push(`- ${f.path}: ✅ yours to edit${f.claimed ? ' (claimed for you until you finish)' : ''}.`)
906
+ else lines.push(`- ${f.path}: ⛔ ${heldRefusal(f.path, f.claim, f.error)}`)
907
+ }
908
+ for (const p of outside) lines.push(`- ${p}: not inside the project folder (${d.dir}).`)
909
+ const said = renderChatAbout(r.chat)
910
+ return lines.join('\n') + (said ? `\n\n${said}` : '') + '\n\nRe-read each file right before you edit it.'
911
+ }))
912
+
913
+ server.registerTool('quilt_request_commit', {
914
+ description: REQUEST_COMMIT_DESCRIPTION,
915
+ inputSchema: {
916
+ message: z.string().max(500).optional().describe('The commit message: what the change does (a task\'s title by default)'),
917
+ 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)'),
918
+ 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')
919
+ }
920
+ }, ({ message, files, task }) => withDaemon(async (d) => {
921
+ const r = await call(d, 'POST', '/commit-request', { message, files, task })
922
+ return `Asked for a commit [${r.id}] of ${r.files.length} file${r.files.length === 1 ? '' : 's'}: ${r.files.slice(0, 12).join(', ')}${r.files.length > 12 ? ', …' : ''}.\n${r.committer}`
923
+ }, { gate: gateFor('quilt_request_commit') }))
924
+
925
+ server.registerTool('quilt_agent_commits', {
926
+ 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.',
927
+ inputSchema: { mode: z.enum(['off', 'branches', 'any']) }
928
+ }, ({ mode }) => withDaemon(async (d) => {
929
+ const r = await call(d, 'POST', '/agent-commits', { mode })
930
+ return `Now ${POLICY_WORDS[r.agentCommits]}.`
931
+ }))
932
+
933
+ server.registerTool('quilt_github_sync', {
934
+ description: 'Session owner only: whether this session syncs with GitHub through the relay (on by default once a GitHub connection covers the repository): pushes and merges on GitHub come into the session within seconds, and commits made in Quilt (quilt_git_commit) go through the relay. Nothing reaches GitHub without a commit. Off: the session keeps its files to itself, and people commit with git as before.',
935
+ inputSchema: { on: z.boolean() }
936
+ }, ({ on }) => withDaemon(async (d) => {
937
+ const r = await call(d, 'POST', '/github-sync', { on })
938
+ return r.githubSync ? 'This session syncs with GitHub.' : 'Sync with GitHub is off for this session.'
939
+ }))
940
+
941
+ server.registerTool('quilt_adopt_work', {
942
+ description: 'For people (and their AI): take the work on an agent\'s branch (quilt/<agent>/…, everything it changed since it started) into the branch you are on, as your edits, pushed to GitHub at once as one commit. This is how agents\' work reaches GitHub when the owner keeps agents off it ("off"), and a way to take a branch\'s work without a pull request.',
943
+ inputSchema: { from: z.string().min(1).max(200).describe('The agent\'s branch, e.g. quilt/grok/login-page') }
944
+ }, ({ from }) => withDaemon(async (d) => {
945
+ const r = await call(d, 'POST', '/adopt', { from })
946
+ return r.state === 'pushed' ? `Adopted ${from}: pushed as ${String(r.sha).slice(0, 7)} (${(r.changed || []).length} files).` : r.state === 'nothing' ? `Nothing to adopt: ${r.why || 'no changes'}.` : `Adopted into the session; not pushed yet (${r.state}${r.why ? `: ${r.why}` : ''}).`
947
+ }))
948
+
949
+ server.registerTool('quilt_branches', {
950
+ 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. Read it before you branch, merge or push.',
951
+ inputSchema: {}
952
+ }, () => withDaemon(async (d) => {
953
+ const { branches, git } = await call(d, 'GET', '/branches')
954
+ const here = git && git.branch ? `This folder is on \`${git.branch}\`${git.upstream ? ` (${upstreamLine(git.upstream)})` : ''}.\n\n` : ''
955
+ return here + branchesMarkdown(branches || [], { limit: 40 })
956
+ }))
957
+
958
+ server.registerTool('quilt_sync_status', {
959
+ description: SYNC_STATUS_DESCRIPTION,
960
+ inputSchema: {}
961
+ }, () => withDaemon(async (d) => { const r = await call(d, 'GET', '/sync-status'); return syncStatusText(r.branches || [], { mine: r.mine || '' }) }))
962
+
963
+ server.registerTool('quilt_sync_branch', {
964
+ description: 'Bring commits made outside the session into it now: fetches this folder\'s upstream (say origin/main after a PR merged, or a push from a worktree) and moves the branch forward, with the session\'s uncommitted work merged in. Quilt also does this by itself about once a minute; call it right after you push or merge elsewhere. It says when the fetch fails (git has no credentials here, say). A folder that is not a git clone, or can\'t fetch, asks the relay to bring the commits in from GitHub instead. It never merges git history: a branch that has diverged, or files that clash with the session\'s work, become one task on the board for one AI to resolve (the others are told to leave those files to them).',
965
+ inputSchema: {}
966
+ }, () => withDaemon(async (d) => describeBranchSync(await call(d, 'POST', '/branches/sync'))))
967
+
968
+ server.registerTool('quilt_github_token', {
969
+ 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 for hosted agents, and, when it can write, to commit the work of agents that have no folder (hosted agents\' quilt_git_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 hosted agents commit and open pull requests, within what quilt_agent_commits allows. It goes to the relay and stays there: never shown again. Agents with a folder don\'t need it: their git signs in through the session\'s GitHub connection.',
970
+ inputSchema: { token: z.string().max(300).describe('The token (github_pat_… or ghp_…), or "" to remove it') }
971
+ }, ({ token }) => withDaemon(async (d) => {
972
+ const r = await call(d, 'POST', '/github-token', { token })
973
+ return r.githubToken ? 'Saved the GitHub token on the relay. It looks at the session\'s branches again now.' : 'Removed the GitHub token.'
974
+ }))
975
+
976
+ server.registerTool('quilt_commit_status', {
977
+ description: 'Is it a good moment to commit? Lists open commit requests and whose AI is still working (not counting yours).',
978
+ inputSchema: {}
979
+ }, () => withDaemon(async (d) => describeCommits(await call(d, 'GET', '/commits'))))
980
+
981
+ server.registerTool('quilt_wait_until_idle', {
982
+ description: 'Wait until every other AI in the session is idle (or the timeout passes), then report. Use it before committing, or when you need everyone else to finish first.',
983
+ inputSchema: { timeout_seconds: z.number().int().min(5).max(1800).optional().describe('How long to wait at most (default 300)') }
984
+ }, ({ timeout_seconds: timeout = 300 }) => withDaemon(async (d) => {
985
+ const until = Date.now() + timeout * 1000
986
+ let c = await call(d, 'GET', '/commits')
987
+ while (!c.ready && Date.now() < until) {
988
+ await new Promise((r) => setTimeout(r, 3000))
989
+ c = await call(d, 'GET', '/commits')
990
+ }
991
+ return `${c.ready ? '' : `Stopped waiting after ${timeout}s.\n`}${describeCommits(c)}`
992
+ }, { gate: gateFor('quilt_wait_until_idle') }))
993
+
994
+ server.registerTool('quilt_commit_request_done', {
995
+ description: 'Close commit requests whose work was committed with git (or isn\'t needed any more). Without an id, every open request is closed.',
996
+ inputSchema: { id: z.string().optional().describe('One request id; omit for all open ones') }
997
+ }, ({ id }) => withDaemon(async (d) => {
998
+ const { done } = await call(d, 'POST', '/commit-request/done', { id })
999
+ return done ? `Marked ${done} commit request${done === 1 ? '' : 's'} done.` : 'No open commit requests.'
1000
+ }, { gate: gateFor('quilt_commit_request_done') }))
1001
+
1002
+ server.registerTool('quilt_project_rules', {
1003
+ description: 'The project\'s rules for AI and its design system, the same whatever tool you run in: AGENTS.md (root and folders, else CLAUDE.md), Cursor rules that always apply, and DESIGN.md (or the file AGENTS.md names). Read it before your first change in a session.',
1004
+ inputSchema: {}
1005
+ }, () => withDaemon(async (d) => (await call(d, 'GET', '/rules')).text))
1006
+
1007
+ server.registerTool('quilt_session_info', {
1008
+ description: 'Where the shared project lives on disk, how you appear to others, who is online, and the invite link.',
1009
+ inputSchema: {}
1010
+ }, () => withDaemon(async (d) => describeSession(d.dir)))
1011
+
1012
+ // ------------------------------------------------- the workspace, for agents --
1013
+
1014
+ server.registerTool('quilt_history', {
1015
+ description: 'The chronology of the project: who changed which file, when, what changed (diff) and for which task. ' +
1016
+ 'Filter by path or glob, person, task or time. Use it to understand recent changes before building on them, or to find what broke something.',
1017
+ inputSchema: {
1018
+ path: z.string().max(500).optional().describe('A file, a folder ending in "/", or a glob like src/ui/**'),
1019
+ by: z.string().max(80).optional().describe('Only changes by this person or agent'),
1020
+ since: z.string().max(40).optional().describe('"2h", "3d", "today", "yesterday" or a date'),
1021
+ task: z.string().max(40).optional().describe('Only changes made for this task id'),
1022
+ with_diff: z.boolean().optional().describe('Include each change\'s diff (capped per change)'),
1023
+ limit: z.number().int().min(1).max(200).optional().describe('How many of the newest matching changes (default 30)')
1024
+ }
1025
+ }, ({ path: p, by, since, task, with_diff, limit }) => withDaemon(async (d) => {
1026
+ const { entries } = await call(d, 'POST', '/history', { path: p, by, since, task, limit: limit || 30 })
1027
+ return formatHistory(entries, { withDiff: !!with_diff })
1028
+ }))
1029
+
1030
+ server.registerTool('quilt_partner_feed', {
1031
+ description: 'Read what a collaborator\'s AI is doing: their prompts, the AI\'s replies, and one-line actions like "Edited src/app.ts". Without "who", lists collaborators and their AI status. Use it to avoid duplicating or conflicting with their work.',
1032
+ inputSchema: {
1033
+ who: z.string().optional().describe('Collaborator name'),
1034
+ limit: z.number().int().min(1).max(300).optional().describe('How many recent entries (default 40)')
1035
+ }
1036
+ }, ({ who, limit }) => withDaemon(async (d) => {
1037
+ const st = await call(d, 'GET', '/status')
1038
+ if (!who) {
1039
+ if (!st.peers.length) return 'Nobody else is in the session right now.'
1040
+ return st.peers.map((p) => {
1041
+ const a = p.agent || {}
1042
+ const reported = p.work && p.work.state === 'working' ? `working${p.work.note ? `: ${p.work.note}` : ''}` : null
1043
+ const ai = a.sharing === false ? 'sharing paused' : a.status === 'working' && a.tool ? `${a.tool} working` : reported || (a.status === 'unavailable' ? 'feed unavailable' : a.tool ? `${a.tool} ${a.status}` : 'no AI activity yet')
1044
+ return `- ${p.name}${p.kind === 'agent' ? ' (AI agent)' : ''}: ${ai}${p.focus ? `; focus: ${p.focus}` : ''}`
1045
+ }).join('\n') + '\n\nCall again with "who" to read one feed.'
1046
+ }
1047
+ const { entries } = await call(d, 'POST', '/feed', { who, limit: limit || 40 })
1048
+ if (!entries.length) return `No AI activity from ${who} yet.`
1049
+ const cut = (t) => (t.length > 1500 ? t.slice(0, 1500) + '…' : t)
1050
+ let conv = null
1051
+ const out = []
1052
+ for (const e of entries) {
1053
+ if (e.conv && conv && e.conv !== conv) out.push('--- new conversation ---')
1054
+ if (e.conv) conv = e.conv
1055
+ if (e.kind === 'prompt') out.push(`${who} asked: ${cut(e.text)}`)
1056
+ else if (e.kind === 'reply') out.push(`${e.tool || 'AI'} replied: ${cut(e.text)}`)
1057
+ else if (e.kind === 'action') out.push(` · ${e.text}`)
1058
+ else out.push(`(${who} ${e.kind} sharing)`)
1059
+ }
1060
+ const p = st.peers.find((x) => x.name === who)
1061
+ if (p && p.agent && p.agent.status === 'working' && p.agent.sharing !== false) out.push(`(${who}'s AI is working right now)`)
1062
+ return out.join('\n')
1063
+ }))
1064
+
1065
+ server.registerTool('quilt_list_files', {
1066
+ description: 'List the shared project\'s files with who edited each one recently and any claims, so you can see where others are working.',
1067
+ inputSchema: { prefix: z.string().optional().describe('Only paths under this folder, e.g. "src/auth"') }
1068
+ }, ({ prefix }) => withDaemon(async (d) => {
1069
+ const { files, claims } = await call(d, 'GET', '/tree')
1070
+ const me = (await call(d, 'GET', '/info')).name
1071
+ const pre = prefix ? prefix.replace(/^\.\//, '').replace(/\/+$/, '') : ''
1072
+ const shown = files.filter((f) => !pre || f.path === pre || f.path.startsWith(pre + '/'))
1073
+ const now = Date.now()
1074
+ const lines = shown.slice(0, 400).map((f) => {
1075
+ const notes = []
1076
+ if (f.edited && now - f.edited.ts < 10 * 60 * 1000) notes.push(`edited by ${f.edited.by === me ? 'you' : f.edited.by} ${Math.round((now - f.edited.ts) / 1000)}s ago`)
1077
+ if (f.claim) notes.push(`claimed by ${f.claim.by === me ? 'you' : f.claim.by}${f.claim.note ? `: ${f.claim.note}` : ''}${f.claim.queue && f.claim.queue.length ? `, ${f.claim.queue.length} waiting` : ''}`)
1078
+ if (f.binary) notes.push('binary')
1079
+ return `${f.path}${notes.length ? ` [${notes.join('; ')}]` : ''}`
1080
+ })
1081
+ if (shown.length > 400) lines.push(`… and ${shown.length - 400} more (use prefix to narrow)`)
1082
+ const claimLines = claims.length ? '\n\nClaims: ' + claims.map((c) => `${c.pattern} (${c.by === me ? 'you' : c.by}${c.note ? `: ${c.note}` : ''})`).join(', ') : ''
1083
+ return (lines.join('\n') || 'No shared files.') + claimLines
1084
+ }))
1085
+
1086
+ server.server.onclose = () => { clearInterval(pushTimer); updates.stop(); leave().catch(() => {}) }
1087
+ for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => { leave().finally(() => process.exit(0)) })
1088
+
1089
+ server.server.oninitialized = () => {
1090
+ const client = server.server.getClientVersion()
1091
+ const d = findDaemon()
1092
+ if (d && client && client.name) call(d, 'POST', '/agent', { client: client.name }).catch(() => {}) // says hello too
1093
+ }
1094
+
1095
+ await server.connect(new StdioServerTransport())
1096
+ // The workspace library tools arrive a moment later (the client is told the tool list
1097
+ // changed), so startup never waits on the API.
1098
+ // fromPath reads only from the project: the session's folder, or where the agent runs.
1099
+ const projectDirs = () => [joined?.dir, findDaemon(joined ? joined.dir : undefined)?.dir, process.env.QUILT_DIR || process.cwd()].filter(Boolean)
1100
+ addWorkspaceTools(server, { projectDirs }).catch(() => {})
1101
+ }
1102
+
1103
+ /**
1104
+ * One try at handing a session's link to the agents that join it by themselves, as the agent
1105
+ * `name` that started it. Its access key is read afresh on every try (agentAccess refreshes
1106
+ * it when it has nearly run out), so a retry minutes later never sends an expired one.
1107
+ */
1108
+ export function agentAnnouncer ({ name, workspace, room, link, access = agentAccess, announce = announceSessionStarted }) {
1109
+ return async () => {
1110
+ const saved = await access({ name })
1111
+ return announce({ token: saved.accessKey, id: workspace, room, link, api: saved.api })
1112
+ }
1113
+ }
1114
+
1115
+ // How long the startup check of the API's features may take before the tools are left out.
1116
+ export const FEATURES_TIMEOUT_MS = 2000
1117
+ // Up to the library's largest file, to or from storage.
1118
+ const TRANSFER_TIMEOUT_MS = 10 * 60 * 1000
1119
+ const MAX_LOCAL_FILE = 500 * 1024 * 1024
1120
+
1121
+ /**
1122
+ * Who the library tools act as, decided afresh on every call (the MCP may join a session as an
1123
+ * agent later): the agent whose folder this is (the first of `dirs` holding a session, joined
1124
+ * as one of this computer's saved agents); in a person's folder, the person signed in on this
1125
+ * computer (their AI works as them); else this computer's only agent; else the signed-in
1126
+ * person. null when there is nobody to act as.
1127
+ */
1128
+ export function workspaceActor ({ dirs = [process.cwd()] } = {}) {
1129
+ const agents = savedAgents()
1130
+ let personsFolder = false
1131
+ for (const d of dirs) {
1132
+ const c = readConfig(d)
1133
+ if (!c) continue
1134
+ if (c.kind === 'agent' && c.name && agents.includes(c.name)) return { agent: c.name }
1135
+ personsFolder = c.kind !== 'agent'
1136
+ break
1137
+ }
1138
+ const signedIn = !!readAccount()
1139
+ if (personsFolder && signedIn) return { person: true }
1140
+ if (agents.length === 1) return { agent: agents[0] }
1141
+ return signedIn ? { person: true } : null
1142
+ }
1143
+
1144
+ const NOBODY = 'Sign in to Quilt on this computer (the Quilt app, or quilt login), or join as an agent, to use the workspace library.'
1145
+
1146
+ /**
1147
+ * How the library reaches the API from this computer, for the local MCP and the CLI: as
1148
+ * workspaceActor decides on each call. { actor, api(), call, fetchBytes, put, readLocal,
1149
+ * saveDir } for registerWorkspaceTools; api() throws when there is nobody to act as.
1150
+ */
1151
+ export function localWorkspaceAccess ({ fetch: fetchImpl = globalThis.fetch, projectDirs = () => [process.cwd()] } = {}) {
1152
+ const actor = () => workspaceActor({ dirs: projectDirs() })
1153
+ const api = () => {
1154
+ const a = actor()
1155
+ if (!a) throw new Error(NOBODY)
1156
+ return String((a.agent && readAgent({ name: a.agent }).api) || apiUrl()).replace(/\/+$/, '')
1157
+ }
1158
+ const send = async (base, token, method, route, body) => {
1159
+ try {
1160
+ return await fetchImpl(String(base).replace(/\/+$/, '') + route, {
1161
+ method,
1162
+ headers: { authorization: `Bearer ${token}`, ...(body ? { 'content-type': 'application/json' } : {}) },
1163
+ body: body ? JSON.stringify(body) : undefined,
1164
+ signal: AbortSignal.timeout(60_000)
1165
+ })
1166
+ } catch (err) {
1167
+ throw new Error(`Couldn't reach Quilt (${err.cause?.code || err.message}).`)
1168
+ }
1169
+ }
1170
+ const call = async (method, route, body) => {
1171
+ const a = actor()
1172
+ if (!a) throw new Error(NOBODY)
1173
+ let res
1174
+ if (a.agent) {
1175
+ // A fresh access key every call: it is refreshed (and saved) when it has nearly run out.
1176
+ const saved = await agentAccess({ name: a.agent, fetch: fetchImpl })
1177
+ res = await send(saved.api || apiUrl(), saved.accessKey, method, route, body)
1178
+ } else {
1179
+ const account = readAccount()
1180
+ if (!account) throw new Error(NOBODY)
1181
+ res = await send(apiUrl(), account.token, method, route, body)
1182
+ // The sign-in ran out, not the link to this computer: sign back in with its key, once.
1183
+ if (res.status === 401) {
1184
+ const back = await resumeAccount({ fetch: fetchImpl }).catch(() => null)
1185
+ if (back) res = await send(apiUrl(), back.token, method, route, body)
1186
+ }
1187
+ }
1188
+ const data = await res.json().catch(() => null)
1189
+ if (!res.ok) throw Object.assign(new Error(data?.error || `Quilt answered ${res.status}.`), { status: res.status })
1190
+ if (!data || typeof data !== 'object') throw new Error('Quilt sent back something unexpected.')
1191
+ return data
1192
+ }
1193
+ const fetchBytes = bytesFetcher(fetchImpl, { timeoutMs: TRANSFER_TIMEOUT_MS })
1194
+ const put = async (url, bytes, headers) => (await fetchImpl(url, { method: 'PUT', headers, body: bytes, signal: AbortSignal.timeout(TRANSFER_TIMEOUT_MS) })).status
1195
+ return { actor, api, call, fetchBytes, put, saveDir: path.join(quiltHome(), 'workspaces'), readLocal: (p) => readLocalFile(p, projectDirs()) }
1196
+ }
1197
+
1198
+ /** Whether the API at `api` says workspaces are on (false when it is slow, unreachable or odd). */
1199
+ export async function workspacesOn (api, fetchImpl = globalThis.fetch) {
1200
+ try {
1201
+ const res = await fetchImpl(`${api}/v1/features`, { signal: AbortSignal.timeout(FEATURES_TIMEOUT_MS) })
1202
+ return res.ok && (await res.json())?.workspaces === true
1203
+ } catch { return false }
1204
+ }
1205
+
1206
+ /**
1207
+ * Adds the workspace library tools when there is someone to act as (workspaceActor: this
1208
+ * folder's agent, or the person signed in on this computer) and their API says workspaces are
1209
+ * on. Anything else (nobody, the flag off, the API slow, unreachable or odd) adds nothing and
1210
+ * never throws. Returns whether the tools were added.
1211
+ */
1212
+ export async function addWorkspaceTools (server, { fetch: fetchImpl = globalThis.fetch, projectDirs = () => [process.cwd()] } = {}) {
1213
+ const access = localWorkspaceAccess({ fetch: fetchImpl, projectDirs })
1214
+ let api
1215
+ try { api = access.api() } catch { return false }
1216
+ if (!await workspacesOn(api, fetchImpl)) return false
1217
+ try {
1218
+ registerWorkspaceTools(server, { ...access, guide: true })
1219
+ } catch { return false }
1220
+ return true
1221
+ }
1222
+
1223
+ const realOr = async (p) => { try { return await fs.promises.realpath(p) } catch { return path.resolve(p) } }
1224
+
1225
+ /**
1226
+ * A file for quilt_workspace_write_file's fromPath: only from the project (`dirs`, the first
1227
+ * resolving a relative path) or the temp folder, as quilt_send_file only sends project files,
1228
+ * so a prompt-injected agent can't put ~/.ssh in a library other people read. Never the
1229
+ * agent's own keys or anything else in ~/.quilt, nor an .env file. Symlinks are followed
1230
+ * before the checks.
1231
+ */
1232
+ export async function readLocalFile (p, dirs = [process.cwd()]) {
1233
+ const abs = await fs.promises.realpath(path.resolve(dirs[0] || process.cwd(), String(p)))
1234
+ const roots = await Promise.all([...dirs, os.tmpdir()].map(realOr))
1235
+ if (!roots.some((root) => isInside(root, abs))) throw new Error(`${p} is outside this project. Copy it into the project folder first, then send it from there.`)
1236
+ const home = await realOr(quiltHome())
1237
+ if (abs === home || isInside(home, abs)) throw new Error('Quilt\'s own files (keys and settings) are not sent to a library.')
1238
+ const base = path.basename(abs)
1239
+ if (/^\.env(\..*)?$/.test(base) && base !== '.env.example') throw new Error('refusing to send environment/secret files')
1240
+ const st = await fs.promises.stat(abs)
1241
+ if (!st.isFile()) throw new Error(`${p} is not a file`)
1242
+ if (st.size > MAX_LOCAL_FILE) throw new Error('That file is too large (at most 500 MB).')
1243
+ return fs.promises.readFile(abs)
1244
+ }
1245
+
1246
+ // Agents may only send or save files inside the project folder, and may not
1247
+ // send secrets, so a prompt-injected agent can't leak ~/.ssh or .env.
1248
+ function insideProject (root, p, { reading = false } = {}) {
1249
+ const abs = path.resolve(root, p)
1250
+ const check = (base, target) => {
1251
+ const rel = path.relative(base, target)
1252
+ if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) throw new Error('path must be inside the project folder')
1253
+ }
1254
+ check(root, abs)
1255
+ if (reading) {
1256
+ check(fs.realpathSync(root), fs.realpathSync(abs)) // no symlinks pointing outside
1257
+ const base = path.basename(abs)
1258
+ if (/^\.env(\..*)?$/.test(base) && base !== '.env.example') throw new Error('refusing to send environment/secret files')
1259
+ }
1260
+ return abs
1261
+ }