heyquilt 0.0.1 → 0.3.106

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 (221) hide show
  1. package/LICENSE +85 -0
  2. package/README.md +633 -2
  3. package/RELEASES.md +862 -0
  4. package/assets/logo.svg +1 -0
  5. package/bin/quilt.js +849 -0
  6. package/desktop/main.js +315 -0
  7. package/package.json +163 -7
  8. package/src/account.js +591 -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 +187 -0
  14. package/src/agents/common.js +21 -0
  15. package/src/agents/cursor-transcripts.js +177 -0
  16. package/src/agents/cursor.js +404 -0
  17. package/src/agents/index.js +69 -0
  18. package/src/ai-vm-signin.js +110 -0
  19. package/src/ai-vm.js +522 -0
  20. package/src/api/access.js +75 -0
  21. package/src/api/activity.js +181 -0
  22. package/src/api/agent-auth.js +136 -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 +133 -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 +1219 -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 +545 -0
  40. package/src/api/relay-machines.js +92 -0
  41. package/src/api/releases.js +87 -0
  42. package/src/api/routes/access-types.js +54 -0
  43. package/src/api/routes/agent-invites.js +115 -0
  44. package/src/api/routes/agents.js +252 -0
  45. package/src/api/routes/ai-vm.js +495 -0
  46. package/src/api/routes/github.js +380 -0
  47. package/src/api/routes/grants.js +70 -0
  48. package/src/api/routes/invites.js +188 -0
  49. package/src/api/routes/issues.js +47 -0
  50. package/src/api/routes/join.js +167 -0
  51. package/src/api/routes/members.js +110 -0
  52. package/src/api/routes/orgs.js +179 -0
  53. package/src/api/routes/relay-cluster.js +90 -0
  54. package/src/api/routes/relay.js +76 -0
  55. package/src/api/routes/secrets.js +448 -0
  56. package/src/api/routes/session-invites.js +119 -0
  57. package/src/api/routes/sessions.js +92 -0
  58. package/src/api/routes/teams.js +132 -0
  59. package/src/api/routes/workspace-agents.js +332 -0
  60. package/src/api/routes/workspace-files.js +277 -0
  61. package/src/api/routes/workspace-invites.js +196 -0
  62. package/src/api/routes/workspaces.js +232 -0
  63. package/src/api/sandbox-keys.js +57 -0
  64. package/src/api/secrets-access.js +93 -0
  65. package/src/api/server.js +654 -0
  66. package/src/api/slugs.js +20 -0
  67. package/src/api/supabase-store.js +875 -0
  68. package/src/api/team-access.js +30 -0
  69. package/src/api/tokens.js +31 -0
  70. package/src/api/workspace-access.js +62 -0
  71. package/src/api/workspace-reach.js +60 -0
  72. package/src/autowork-tools.js +166 -0
  73. package/src/autowork.js +375 -0
  74. package/src/blobstore.js +99 -0
  75. package/src/boxes.js +37 -0
  76. package/src/branchdocs.js +235 -0
  77. package/src/branches.js +194 -0
  78. package/src/catchup.js +111 -0
  79. package/src/chat-archive.js +85 -0
  80. package/src/chat-links.js +923 -0
  81. package/src/clash.js +128 -0
  82. package/src/cloud-ai.js +50 -0
  83. package/src/commit-request.js +59 -0
  84. package/src/commit.js +213 -0
  85. package/src/connection.js +569 -0
  86. package/src/control.js +248 -0
  87. package/src/conversation.js +125 -0
  88. package/src/doctor.js +156 -0
  89. package/src/duties.js +303 -0
  90. package/src/editors.js +228 -0
  91. package/src/fsutil.js +97 -0
  92. package/src/git-hook.js +48 -0
  93. package/src/git.js +147 -0
  94. package/src/github-commit.js +320 -0
  95. package/src/gitignore.js +49 -0
  96. package/src/gitstate.js +924 -0
  97. package/src/history.js +307 -0
  98. package/src/hooks.js +371 -0
  99. package/src/hosted-agents.js +24 -0
  100. package/src/hosted-client.js +112 -0
  101. package/src/identity.js +99 -0
  102. package/src/inbox.js +237 -0
  103. package/src/integrations.js +221 -0
  104. package/src/key-cli.js +115 -0
  105. package/src/largefiles.js +91 -0
  106. package/src/legacy.js +25 -0
  107. package/src/mcp-proxy.js +45 -0
  108. package/src/mcp-room.js +32 -0
  109. package/src/mcp.js +1258 -0
  110. package/src/merge3.js +240 -0
  111. package/src/merges.js +148 -0
  112. package/src/pass-source.js +183 -0
  113. package/src/passes.js +64 -0
  114. package/src/pathrules.js +134 -0
  115. package/src/persona.js +101 -0
  116. package/src/presence.js +360 -0
  117. package/src/private-file.js +41 -0
  118. package/src/procs.js +70 -0
  119. package/src/project-rules.js +178 -0
  120. package/src/protocol.js +92 -0
  121. package/src/quiet-warnings.js +9 -0
  122. package/src/relay-ai-vm.js +82 -0
  123. package/src/relay-ask.js +141 -0
  124. package/src/relay-ci.js +111 -0
  125. package/src/relay-clash.js +214 -0
  126. package/src/relay-cluster.js +454 -0
  127. package/src/relay-commit.js +887 -0
  128. package/src/relay-filekey.js +46 -0
  129. package/src/relay-git.js +274 -0
  130. package/src/relay-githttp.js +166 -0
  131. package/src/relay-github.js +109 -0
  132. package/src/relay-lease.js +137 -0
  133. package/src/relay-lfs.js +37 -0
  134. package/src/relay-mcp.js +1691 -0
  135. package/src/relay-metrics.js +140 -0
  136. package/src/relay-outbound.js +1273 -0
  137. package/src/relay-sync.js +716 -0
  138. package/src/relay-turn-sync.js +506 -0
  139. package/src/relay-upstream.js +804 -0
  140. package/src/relay-webhooks.js +111 -0
  141. package/src/releases.js +138 -0
  142. package/src/report.js +129 -0
  143. package/src/runner.js +307 -0
  144. package/src/same-edit.js +18 -0
  145. package/src/secret-import.js +55 -0
  146. package/src/secret-names.js +26 -0
  147. package/src/secret-run.js +80 -0
  148. package/src/secrets-client.js +56 -0
  149. package/src/server.js +4214 -0
  150. package/src/session-access.js +170 -0
  151. package/src/session-name.js +15 -0
  152. package/src/session.js +7446 -0
  153. package/src/settings.js +46 -0
  154. package/src/setup.js +184 -0
  155. package/src/shell-path.js +46 -0
  156. package/src/status.js +197 -0
  157. package/src/summarize.js +109 -0
  158. package/src/sync-words.js +42 -0
  159. package/src/task-comments.js +92 -0
  160. package/src/tasks.js +670 -0
  161. package/src/textdiff.js +20 -0
  162. package/src/turn-sync-plan.js +145 -0
  163. package/src/turn-sync.js +212 -0
  164. package/src/ui/access-form.js +37 -0
  165. package/src/ui/agent-env-key.js +76 -0
  166. package/src/ui/agent-guide.js +127 -0
  167. package/src/ui/agent-kinds.js +175 -0
  168. package/src/ui/agent-place.js +265 -0
  169. package/src/ui/app.css +2494 -0
  170. package/src/ui/app.js +546 -0
  171. package/src/ui/board.js +465 -0
  172. package/src/ui/boxes.js +76 -0
  173. package/src/ui/branches.js +111 -0
  174. package/src/ui/catchup.js +77 -0
  175. package/src/ui/changes.js +159 -0
  176. package/src/ui/chat.js +266 -0
  177. package/src/ui/cloud-ai-model.js +108 -0
  178. package/src/ui/cloud-ai.js +221 -0
  179. package/src/ui/common.js +433 -0
  180. package/src/ui/csv.js +21 -0
  181. package/src/ui/diffview.js +74 -0
  182. package/src/ui/feed-convs.js +39 -0
  183. package/src/ui/feed.js +174 -0
  184. package/src/ui/files.js +579 -0
  185. package/src/ui/fileview.js +150 -0
  186. package/src/ui/git-rule.js +60 -0
  187. package/src/ui/home.js +1322 -0
  188. package/src/ui/index.html +16 -0
  189. package/src/ui/invite.js +97 -0
  190. package/src/ui/invites.js +79 -0
  191. package/src/ui/loom-model.js +516 -0
  192. package/src/ui/loom.css +365 -0
  193. package/src/ui/loom.js +786 -0
  194. package/src/ui/mark.js +104 -0
  195. package/src/ui/rail-model.js +75 -0
  196. package/src/ui/rail.js +173 -0
  197. package/src/ui/releases.js +182 -0
  198. package/src/ui/sandbox-keys-model.js +53 -0
  199. package/src/ui/sandbox-keys.js +132 -0
  200. package/src/ui/schedule.js +156 -0
  201. package/src/ui/secret-rules.js +255 -0
  202. package/src/ui/secrets-view.js +162 -0
  203. package/src/ui/secrets.js +283 -0
  204. package/src/ui/session.js +3292 -0
  205. package/src/ui/signin.js +75 -0
  206. package/src/ui/sounds.js +110 -0
  207. package/src/ui/tool-logo.js +86 -0
  208. package/src/ui/tree.js +281 -0
  209. package/src/ui/workspaces.js +547 -0
  210. package/src/ui-server.js +1381 -0
  211. package/src/update-check.js +67 -0
  212. package/src/upstream.js +192 -0
  213. package/src/vault/crypto.js +96 -0
  214. package/src/vault/kms.js +61 -0
  215. package/src/vault/main.js +33 -0
  216. package/src/vault/server.js +279 -0
  217. package/src/vault/sigv4.js +38 -0
  218. package/src/webhooks.js +235 -0
  219. package/src/workspace-cli.js +62 -0
  220. package/src/workspace-tools.js +340 -0
  221. package/index.js +0 -2
package/src/inbox.js ADDED
@@ -0,0 +1,237 @@
1
+ // What is waiting for a member of a session: mentions of them in chat, direct
2
+ // messages to them, and tasks handed to them. Agents wake on these: Claude Code
3
+ // gets them pushed as channel events by `quilt mcp`, every tool can read them
4
+ // with quilt_inbox, and the Claude Code hooks show them while Claude works.
5
+ //
6
+ // `scanInbox` is pure (state in, state out) so the local daemon, the hosted
7
+ // relay MCP and tests all find the same events the same way.
8
+ import { assignedToReader } from './tasks.js'
9
+
10
+ export const INBOX_CAP = 100
11
+
12
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
13
+
14
+ /**
15
+ * Which of `names` are mentioned as @Name in `text`. Case-insensitive; the @
16
+ * must start a word (so an email address is not a mention) and the name must
17
+ * end one (so @Dan is not a mention of Dan inside @Daniel). @Daniel's AI is not a mention of Daniel.
18
+ */
19
+ export function mentioned (text, names) {
20
+ const t = String(text || '')
21
+ const out = []
22
+ for (const name of names || []) {
23
+ const n = String(name || '').trim()
24
+ if (!n) continue
25
+ // "@Daniel's AI" is for Daniel's AI, not for Daniel.
26
+ const re = new RegExp(`(^|[^\\w@])@${escapeRe(n)}(?![\\w-])(?!'s AI(?![\\w-]))`, 'iu')
27
+ if (re.test(t)) out.push(n)
28
+ }
29
+ return out
30
+ }
31
+
32
+ /** Typing @Agents in chat mentions every agent in the session at once. */
33
+ export const ALL_AGENTS = 'Agents'
34
+
35
+ /** Whether `text` @mentions anyone at all (an email address is not a mention). */
36
+ export const mentionsAnyone = (text) => /(^|[^\w@])@[\p{L}\p{N}]/u.test(String(text || ''))
37
+
38
+ // A room message that names nobody, this soon after its sender @mentioned an agent, carries
39
+ // on with that agent ("can you retry that?"): it wakes the agent like the mention did.
40
+ export const FOLLOW_UP_MS = 10 * 60 * 1000
41
+
42
+ /**
43
+ * Whether `text` mentions `me`: @me, or @Agents when `agent` (the reader joined as an agent).
44
+ * A member actually named "Agents" is still mentioned by name, like anyone else.
45
+ */
46
+ export function mentionsMe (text, me, { agent = false } = {}) {
47
+ return mentioned(text, agent ? [me, ALL_AGENTS] : [me]).length > 0
48
+ }
49
+
50
+ /**
51
+ * Events for `reader` that `state` has not seen. Pure: returns { events, state }.
52
+ * - messages: chat the reader can see, oldest first ({ id, by, to, text, ts }).
53
+ * - tasks: the board ({ id, title, column, by, assignee, forAi, tool, files }).
54
+ * - reader: { name, asAi, agent, aliases, of }: agent means the reader joined as an
55
+ * agent, so @Agents mentions it too. asAi means the reader is that person's AI, so
56
+ * tasks for "their AI" are the reader's and tasks for the person are not. One of
57
+ * several AI sessions working through a person's app (persona.js) has its own name,
58
+ * the names it had before (`aliases`), and `of`, its person: tasks for "their AI" are its.
59
+ * `own`: names of the reader's own AI sessions. What they say never wakes the reader: a
60
+ * session writing to its own person is talking to the person, not to their other sessions.
61
+ * The first scan (no state) only takes stock: nothing that is already there
62
+ * wakes anyone. A task fires once when it becomes the reader's while open;
63
+ * handing it away and back fires again.
64
+ * A direct message carries `re`, the reader's message it answers, when it has one. For an agent
65
+ * reader, a room message naming nobody within FOLLOW_UP_MS of its sender's last @mention of the
66
+ * reader is a mention too (`follows`: that message's id).
67
+ */
68
+ export function scanInbox ({ messages = [], tasks = [], reader, now = Date.now() }, state = null) {
69
+ const me = reader && reader.name
70
+ const names = me ? [me, ...((reader && reader.aliases) || [])] : []
71
+ const own = new Set((reader && reader.own) || [])
72
+ const seed = !state
73
+ const seenMsgs = new Set(state?.messages || [])
74
+ const assigned = new Set(state?.assigned || [])
75
+ const events = []
76
+ const msgIds = []
77
+ const lastAsk = new Map() // sender -> their latest room message that @mentions someone: when, and was it for me
78
+ for (const m of messages) {
79
+ if (!m || typeof m.id !== 'string' || !m.id) continue
80
+ msgIds.push(m.id)
81
+ const text = typeof m.text === 'string' ? m.text : ''
82
+ let follows = ''
83
+ if (!m.to && !m.kind && reader && reader.agent && me) {
84
+ if (mentionsAnyone(text)) lastAsk.set(m.by, { id: m.id, ts: m.ts || 0, mine: names.some((n) => mentionsMe(text, n, { agent: true })) })
85
+ else {
86
+ const ask = lastAsk.get(m.by)
87
+ if (ask && ask.mine && (m.ts || 0) - ask.ts <= FOLLOW_UP_MS) follows = ask.id
88
+ }
89
+ }
90
+ if (seed || seenMsgs.has(m.id) || !me || names.includes(m.by) || own.has(m.by)) continue
91
+ // File queue messages (a request for a file the reader holds, or a file handed to them) wake
92
+ // them like a direct message, but ask for a handoff rather than a reply (duties.js).
93
+ const queue = m.kind === 'queue' || m.kind === 'handoff' ? { queue: m.kind, file: typeof m.path === 'string' ? m.path : '' } : {}
94
+ // A note that work the reader asked to commit was committed (commit.js): it wakes them, and asks for no reply.
95
+ if (m.kind === 'commit') queue.commit = typeof m.commit === 'string' ? m.commit : true
96
+ // A reminder from Quilt (uncommitted work): it wakes the reader, and asks for no reply.
97
+ if (m.kind === 'nudge') queue.nudge = true
98
+ const re = typeof m.re === 'string' && m.re ? { re: m.re } : {}
99
+ if (names.includes(m.to)) events.push({ id: m.id, kind: 'dm', by: m.by, text, ts: m.ts, ...re, ...queue })
100
+ else if (!m.to && names.some((n) => mentionsMe(text, n, { agent: !!reader.agent }))) events.push({ id: m.id, kind: 'mention', by: m.by, text, ts: m.ts })
101
+ else if (follows) events.push({ id: m.id, kind: 'mention', by: m.by, text, ts: m.ts, follows })
102
+ }
103
+ const mine = []
104
+ for (const t of tasks) {
105
+ // An AI session's tasks: given to it by name (as a member), or to its person's AI.
106
+ const its = assignedToReader(t, reader) || names.slice(1).some((n) => assignedToReader(t, { ...reader, name: n })) ||
107
+ (reader && reader.of && (t.assignee === me || assignedToReader(t, { name: reader.of, asAi: true })))
108
+ if (!t || typeof t.id !== 'string' || !its) continue
109
+ mine.push(t.id)
110
+ if (seed || assigned.has(t.id) || t.column === 'done' || t.archived) continue
111
+ events.push({
112
+ id: t.id,
113
+ kind: 'task',
114
+ by: t.by,
115
+ text: t.title,
116
+ ts: now,
117
+ task: { id: t.id, title: t.title, column: t.column, assignee: t.assignee, forAi: !!t.forAi, tool: t.tool || '', files: t.files || [] }
118
+ })
119
+ }
120
+ // A task that is done, archived, or no longer the reader's, can wake them again later.
121
+ const open = new Set(tasks.filter((t) => t && t.column !== 'done' && !t.archived).map((t) => t.id))
122
+ return { events, state: { messages: msgIds.slice(-500), assigned: mine.filter((id) => open.has(id)) } }
123
+ }
124
+
125
+ // How much of each message a wake's context shows (conversation.js picks the messages).
126
+ export const CONTEXT_MESSAGE_CHARS = 600
127
+
128
+ const lowerNames = (names) => new Set((names || []).filter(Boolean).map((n) => String(n).toLowerCase()))
129
+ const clip = (text, max) => {
130
+ const t = String(text || '').trim()
131
+ return t.length > max ? t.slice(0, max - 1).trimEnd() + '…' : t
132
+ }
133
+
134
+ const ago = (ts, now) => {
135
+ const m = Math.max(0, Math.round((now - (ts || now)) / 60000))
136
+ if (m < 1) return 'just now'
137
+ if (m < 60) return `${m}m ago`
138
+ const h = Math.round(m / 60)
139
+ return h < 48 ? `${h}h ago` : `${Math.round(h / 24)}d ago`
140
+ }
141
+
142
+ /** One message as a line: "[id] 3h ago Daniel → you: text". `me`: the reader's names, shown as "you". */
143
+ export function messageLine (m, { me = [], now = Date.now(), max = 2000 } = {}) {
144
+ const mine = lowerNames(me)
145
+ const who = (n) => mine.has(String(n || '').toLowerCase()) ? 'you' : n
146
+ const to = m.to ? ` → ${who(m.to)}` : ''
147
+ return `- [${m.id}] ${ago(m.ts, now)} ${who(m.by)}${to}: ${clip(m.text, max).replace(/\n+/g, ' ⏎ ')}`
148
+ }
149
+
150
+ /** The context an event carries, as lines under it; '' when there is none. */
151
+ export function renderContext (e, { me = [], now = Date.now() } = {}) {
152
+ if (!e || !Array.isArray(e.context) || !e.context.length) return ''
153
+ const head = `Earlier between you and ${e.by} (oldest first${e.earlier ? `; ${e.earlier} older: quilt_conversation with "${e.by}"` : ''}):`
154
+ return [head, ...e.context.map((m) => ' ' + messageLine(m, { me, now, max: CONTEXT_MESSAGE_CHARS }))].join('\n')
155
+ }
156
+
157
+ // A message's id, which quilt_inbox no_reply takes to settle it.
158
+ const idOf = (e) => e.id ? ` (id ${e.id})` : ''
159
+
160
+ /** One event as a line an agent can act on. */
161
+ export function describeEvent (e) {
162
+ const who = e.by || 'someone'
163
+ if (e.queue === 'queue') return `${who} asked for ${e.file} in its file queue: ${e.text} Finish what you are doing in it, then hand it off with quilt_handoff and your context.`
164
+ if (e.queue === 'handoff') return `${who} handed you ${e.file}, which you asked for: it is yours to edit now. ${e.text}`
165
+ if (e.nudge) return `Quilt reminds you (no reply needed): ${e.text}`
166
+ if (typeof e.commit === 'string') return `${who} asked you for a commit (request ${e.commit}; it waits here until the request is done, no reply needed): ${e.text}`
167
+ if (e.commit) return `${who} committed work you asked for (no reply needed): ${e.text}`
168
+ if (e.kind === 'dm') return `${who} sent you a direct message${e.id ? ` (id ${e.id}${e.re ? `, answering your message ${e.re}` : ''})` : ''}: ${e.text}`
169
+ if (e.kind === 'mention') return `${who} ${e.follows ? 'wrote again in chat after mentioning you' : 'mentioned you in chat'}${idOf(e)}: ${e.text}`
170
+ if (e.kind === 'task') {
171
+ const t = e.task || {}
172
+ const files = t.files && t.files.length ? ` Files: ${t.files.join(', ')}.` : ''
173
+ return `${who} handed you a task: "${e.text}" (id ${e.id}).${files} Pick it up with quilt_move_task (doing) when you start.`
174
+ }
175
+ return `${who}: ${e.text || ''}`
176
+ }
177
+
178
+ // What "that" means: the rule every wake carries, so no agent asks a person to repeat themselves.
179
+ export const REFERS_HOW = '"That", "it", "again" and "retry" in a message mean the latest thing in the conversation shown under it: act on that. ' +
180
+ 'Never ask what they meant, and never say you do not know what they refer to before reading back with quilt_conversation.'
181
+
182
+ export const INBOX_HOW = 'Answer a message that asks something of you with quilt_message (re: its id, or set "to", or start with @their name), once. ' +
183
+ 'One that needs nothing back (thanks, a greeting, an FYI, a status report) gets no reply: settle it with quilt_inbox (no_reply: [its id]). Take a task with quilt_move_task. ' + REFERS_HOW
184
+
185
+ /**
186
+ * Several events as the text a tool returns, or '' when there are none. Each sender's
187
+ * conversation before their first event here is shown once, under it (`me`: the reader's names).
188
+ */
189
+ export function renderInbox (events, opts = {}) {
190
+ if (!events || !events.length) return ''
191
+ return `Waiting for you:\n${eventLines(events, opts).join('\n')}\n${INBOX_HOW}`
192
+ }
193
+
194
+ /** Each event as a line an agent can act on, with its sender's conversation under their first one. */
195
+ export function eventLines (events, { me = [], now = Date.now() } = {}) {
196
+ const ids = new Set(events.map((e) => e.id))
197
+ const shown = new Set()
198
+ const lines = []
199
+ for (const e of events) {
200
+ lines.push(`- ${describeEvent(e)}`)
201
+ if (shown.has(e.by) || !Array.isArray(e.context)) continue
202
+ shown.add(e.by)
203
+ const ctx = renderContext({ ...e, context: e.context.filter((m) => !ids.has(m.id)) }, { me, now })
204
+ if (ctx) lines.push(ctx.replace(/^/gm, ' '))
205
+ }
206
+ return lines
207
+ }
208
+
209
+ /** Keeps the events a member has not been shown yet, each with a sequence number. */
210
+ export class Inbox {
211
+ constructor () {
212
+ this.state = null
213
+ this.events = []
214
+ this.seq = 0
215
+ }
216
+
217
+ /** Scans for new events. `quiet` takes stock without waking anyone (our own changes). */
218
+ scan (input, { quiet = false } = {}) {
219
+ const r = scanInbox(input, this.state)
220
+ this.state = r.state
221
+ if (quiet) return []
222
+ for (const e of r.events) this.events.push({ ...e, seq: ++this.seq })
223
+ if (this.events.length > INBOX_CAP) this.events.splice(0, this.events.length - INBOX_CAP)
224
+ return r.events
225
+ }
226
+
227
+ /** Forgets that task `id` was already the reader's, so the next scan wakes them for it (a task they took quietly). */
228
+ forget (id) {
229
+ if (this.state && Array.isArray(this.state.assigned)) this.state = { ...this.state, assigned: this.state.assigned.filter((x) => x !== id) }
230
+ }
231
+
232
+ /** Events after sequence number `after`, and the latest number to pass next time. */
233
+ since (after = 0) {
234
+ const from = Number(after) || 0
235
+ return { events: this.events.filter((e) => e.seq > from), seq: this.seq }
236
+ }
237
+ }
@@ -0,0 +1,221 @@
1
+ // Wires Quilt into every AI tool on this computer, with nothing for the person to do.
2
+ // Tools that run hooks from their user settings (Gemini CLI) get Quilt's hooks there too, so
3
+ // its rules are applied by the tool itself (see hooks.js); Claude Code and Cursor get theirs
4
+ // from each session folder's .claude/settings.local.json (setup.js installHooks).
5
+ //
6
+ // Each tool that reads MCP servers from a user-level config gets Quilt's server there,
7
+ // pointed at this install by absolute path: GUI tools started from the Dock or Start menu
8
+ // don't see the shell's PATH, and project files can't hold one computer's paths (they
9
+ // sync to everyone). A tool is only touched when it looks installed (its config folder
10
+ // exists), only Quilt's own entry is written, and a file Quilt can't read safely (JSON
11
+ // with comments, say) is left alone and reported. Run whenever the app or a session
12
+ // starts: it's idempotent, and it follows the app when it moves or updates.
13
+ import fs from 'node:fs'
14
+ import os from 'node:os'
15
+ import path from 'node:path'
16
+ import { fileURLToPath } from 'node:url'
17
+ import { geminiHookSettings, isQuiltHook } from './hooks.js'
18
+
19
+ const BIN = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'bin', 'quilt.js')
20
+
21
+ /** How to run this Quilt: { command, args, env }. The desktop app's Electron runs it as Node. */
22
+ export function quiltLaunch ({ execPath = process.execPath, electron = !!process.versions.electron, bin = BIN } = {}) {
23
+ return { command: execPath, args: [bin], env: electron ? { ELECTRON_RUN_AS_NODE: '1' } : {} }
24
+ }
25
+
26
+ /** The same, as one shell command line (for Claude Code's hooks), with `extra` arguments. */
27
+ export function quiltShellCommand (extra = [], launch = quiltLaunch(), platform = process.platform) {
28
+ const q = (s) => `"${platform === 'win32' ? String(s).replace(/\\/g, '/') : String(s)}"`
29
+ const env = Object.entries(launch.env).map(([k, v]) => `${k}=${v} `).join('')
30
+ return `${env}${[launch.command, ...launch.args].map(q).join(' ')}${extra.length ? ' ' + extra.join(' ') : ''}`
31
+ }
32
+
33
+ /** The MCP server entry most tools take: { command, args, env }. */
34
+ export function mcpServer (launch = quiltLaunch()) {
35
+ return { command: launch.command, args: [...launch.args, 'mcp'], ...(Object.keys(launch.env).length ? { env: launch.env } : {}) }
36
+ }
37
+
38
+ // ------------------------------------------------------------- files --
39
+
40
+ /** Reads JSON, tolerating none. `null` means "there, but not plain JSON": not ours to rewrite. */
41
+ function readJson (file) {
42
+ let text
43
+ try { text = fs.readFileSync(file, 'utf8') } catch { return { json: {}, text: '' } }
44
+ if (!text.trim()) return { json: {}, text }
45
+ try {
46
+ const json = JSON.parse(text)
47
+ return json && typeof json === 'object' && !Array.isArray(json) ? { json, text } : { json: null, text }
48
+ } catch { return { json: null, text } }
49
+ }
50
+
51
+ function writeAtomic (file, text) {
52
+ fs.mkdirSync(path.dirname(file), { recursive: true })
53
+ const tmp = `${file}.quilt-${process.pid}.tmp`
54
+ fs.writeFileSync(tmp, text)
55
+ fs.renameSync(tmp, file)
56
+ }
57
+
58
+ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b)
59
+
60
+ /** Sets json[key...] = value in a JSON file. Returns 'added' | 'updated' | 'unchanged' | 'skipped'. */
61
+ export function upsertJsonKey (file, keys, value) {
62
+ const { json, text } = readJson(file)
63
+ if (json === null) return 'skipped'
64
+ let at = json
65
+ for (const k of keys.slice(0, -1)) {
66
+ if (!at[k] || typeof at[k] !== 'object' || Array.isArray(at[k])) at[k] = {}
67
+ at = at[k]
68
+ }
69
+ const last = keys[keys.length - 1]
70
+ if (same(at[last], value)) return 'unchanged'
71
+ const was = at[last] !== undefined
72
+ at[last] = value
73
+ // Keep the file's indentation when we can tell it.
74
+ const indent = (text.match(/\n([ \t]+)"/) || [null, ' '])[1]
75
+ writeAtomic(file, JSON.stringify(json, null, indent) + '\n')
76
+ return was ? 'updated' : 'added'
77
+ }
78
+
79
+ /**
80
+ * Codex's config.toml: Quilt's [mcp_servers.quilt] table between markers, replaced in place.
81
+ * A quilt table someone wrote themselves is left alone (TOML refuses a table defined twice).
82
+ */
83
+ export function upsertCodexToml (file, server) {
84
+ let text = ''
85
+ try { text = fs.readFileSync(file, 'utf8') } catch {}
86
+ const START = '# quilt:start (written by Quilt; edits here are replaced)'
87
+ const END = '# quilt:end'
88
+ const str = (s) => JSON.stringify(String(s)) // TOML basic strings take JSON escapes
89
+ const env = server.env && Object.keys(server.env).length ? `env = { ${Object.entries(server.env).map(([k, v]) => `${k} = ${str(v)}`).join(', ')} }\n` : ''
90
+ const block = `${START}\n[mcp_servers.quilt]\ncommand = ${str(server.command)}\nargs = [${server.args.map(str).join(', ')}]\n${env}${END}`
91
+ const re = /# quilt:start[^\n]*\n[\s\S]*?# quilt:end/
92
+ if (re.test(text)) {
93
+ const next = text.replace(re, block)
94
+ if (next === text) return 'unchanged'
95
+ writeAtomic(file, next)
96
+ return 'updated'
97
+ }
98
+ if (/^\s*\[mcp_servers\.(quilt|"quilt")\]/m.test(text)) return 'unchanged'
99
+ writeAtomic(file, (text ? text.replace(/\s*$/, '\n\n') : '') + block + '\n')
100
+ return 'added'
101
+ }
102
+
103
+ /**
104
+ * Zed's settings.json usually has comments. When it isn't plain JSON and has no
105
+ * context_servers yet, Quilt's entry goes in right after the opening brace, leaving every
106
+ * comment where it was.
107
+ */
108
+ function upsertZed (file, entry) {
109
+ const r = upsertJsonKey(file, ['context_servers', 'quilt'], entry)
110
+ if (r !== 'skipped') return r
111
+ const text = fs.readFileSync(file, 'utf8')
112
+ if (/"context_servers"/.test(text)) return 'skipped'
113
+ const at = text.indexOf('{')
114
+ if (at < 0) return 'skipped'
115
+ const body = JSON.stringify({ quilt: entry }, null, 2).replace(/\n/g, '\n ')
116
+ writeAtomic(file, `${text.slice(0, at + 1)}\n "context_servers": ${body},${text.slice(at + 1)}`)
117
+ return 'added'
118
+ }
119
+
120
+ /**
121
+ * Quilt's entries in a JSON settings file's `hooks` (Claude Code's shape, which Gemini CLI shares):
122
+ * earlier Quilt entries replaced, everyone else's left alone. Returns like upsertJsonKey.
123
+ */
124
+ export function upsertHooks (file, ours) {
125
+ const { json } = readJson(file)
126
+ if (json === null) return 'skipped'
127
+ const hooks = json.hooks && typeof json.hooks === 'object' && !Array.isArray(json.hooks) ? { ...json.hooks } : {}
128
+ for (const [event, entries] of Object.entries(ours)) {
129
+ const kept = (Array.isArray(hooks[event]) ? hooks[event] : [])
130
+ .map((e) => (e && Array.isArray(e.hooks) ? { ...e, hooks: e.hooks.filter((h) => !isQuiltHook(h)) } : e))
131
+ .filter((e) => e && (!Array.isArray(e.hooks) || e.hooks.length))
132
+ hooks[event] = [...kept, ...entries]
133
+ }
134
+ return upsertJsonKey(file, ['hooks'], hooks)
135
+ }
136
+
137
+ /** Both of two writes: 'unchanged' only when both were, the first problem otherwise. */
138
+ const both = (a, b) => ['skipped', 'busy', 'failed'].find((r) => r === a || r === b) || (a === 'unchanged' && b === 'unchanged' ? 'unchanged' : a === 'added' ? 'added' : 'updated')
139
+
140
+ // ------------------------------------------------------------- tools --
141
+
142
+ /**
143
+ * Every AI tool Quilt knows how to wire up: where it keeps MCP servers, and in what shape.
144
+ * `home` and `platform` are injectable for tests.
145
+ */
146
+ export function knownTools ({ home = os.homedir(), platform = process.platform, env = process.env } = {}) {
147
+ const appData = env.APPDATA || path.join(home, 'AppData', 'Roaming')
148
+ const appSupport = platform === 'darwin' ? path.join(home, 'Library', 'Application Support') : platform === 'win32' ? appData : (env.XDG_CONFIG_HOME || path.join(home, '.config'))
149
+ const xdg = env.XDG_CONFIG_HOME || path.join(home, '.config')
150
+ const json = (keys, shape = (s) => s) => (file, s) => upsertJsonKey(file, keys, shape(s))
151
+ const vscodeLike = (product) => {
152
+ const user = path.join(appSupport, product, 'User')
153
+ return [
154
+ { name: product === 'Code' ? 'VS Code (GitHub Copilot)' : product, dir: user, file: path.join(user, 'mcp.json'), write: json(['servers', 'quilt'], (s) => ({ type: 'stdio', ...s })) },
155
+ { name: `Cline (${product})`, dir: path.join(user, 'globalStorage', 'saoudrizwan.claude-dev'), file: path.join(user, 'globalStorage', 'saoudrizwan.claude-dev', 'settings', 'cline_mcp_settings.json'), write: json(['mcpServers', 'quilt']) },
156
+ { name: `Roo Code (${product})`, dir: path.join(user, 'globalStorage', 'rooveterinaryinc.roo-cline'), file: path.join(user, 'globalStorage', 'rooveterinaryinc.roo-cline', 'settings', 'mcp_settings.json'), write: json(['mcpServers', 'quilt']) }
157
+ ]
158
+ }
159
+ const codexHome = env.CODEX_HOME || path.join(home, '.codex')
160
+ return [
161
+ { name: 'Claude Code', dir: path.join(home, '.claude'), file: path.join(home, '.claude.json'), write: json(['mcpServers', 'quilt'], (s) => ({ type: 'stdio', ...s })), busy: (f) => fs.existsSync(`${f}.lock`) },
162
+ { name: 'Claude Desktop', dir: path.join(appSupport, 'Claude'), file: path.join(appSupport, 'Claude', 'claude_desktop_config.json'), write: json(['mcpServers', 'quilt']) },
163
+ { name: 'Cursor', dir: path.join(home, '.cursor'), file: path.join(home, '.cursor', 'mcp.json'), write: json(['mcpServers', 'quilt']) },
164
+ { name: 'Windsurf', dir: path.join(home, '.codeium', 'windsurf'), file: path.join(home, '.codeium', 'windsurf', 'mcp_config.json'), write: json(['mcpServers', 'quilt']) },
165
+ { name: 'Codex', dir: codexHome, file: path.join(codexHome, 'config.toml'), write: upsertCodexToml },
166
+ { name: 'Gemini CLI', dir: path.join(home, '.gemini'), file: path.join(home, '.gemini', 'settings.json'), write: (f, s, launch) => { const r = json(['mcpServers', 'quilt'])(f, s); return r === 'skipped' ? r : both(r, upsertHooks(f, geminiHookSettings(quiltShellCommand(['hook', 'gemini'], launch)))) } },
167
+ { name: 'GitHub Copilot CLI', dir: path.join(home, '.copilot'), file: path.join(home, '.copilot', 'mcp-config.json'), write: json(['mcpServers', 'quilt'], (s) => ({ type: 'local', ...s, tools: ['*'] })) },
168
+ { name: 'Zed', dir: platform === 'win32' ? path.join(appData, 'Zed') : path.join(xdg, 'zed'), file: platform === 'win32' ? path.join(appData, 'Zed', 'settings.json') : path.join(xdg, 'zed', 'settings.json'), write: (f, s) => upsertZed(f, { source: 'custom', command: s.command, args: s.args, env: s.env || {} }) },
169
+ { name: 'opencode', dir: path.join(xdg, 'opencode'), file: path.join(xdg, 'opencode', 'opencode.json'), write: json(['mcp', 'quilt'], (s) => ({ type: 'local', command: [s.command, ...s.args], enabled: true, ...(s.env ? { environment: s.env } : {}) })) },
170
+ { name: 'Kiro', dir: path.join(home, '.kiro'), file: path.join(home, '.kiro', 'settings', 'mcp.json'), write: json(['mcpServers', 'quilt']) },
171
+ { name: 'Amp', dir: path.join(xdg, 'amp'), file: path.join(xdg, 'amp', 'settings.json'), write: json(['amp.mcpServers', 'quilt']) },
172
+ { name: 'Junie', dir: path.join(home, '.junie'), file: path.join(home, '.junie', 'mcp', 'mcp.json'), write: json(['mcpServers', 'quilt']) },
173
+ { name: 'Continue', dir: path.join(home, '.continue'), file: path.join(home, '.continue', 'mcpServers', 'quilt.json'), write: json(['mcpServers', 'quilt']) },
174
+ ...vscodeLike('Code'),
175
+ ...vscodeLike('Code - Insiders')
176
+ ]
177
+ }
178
+
179
+ /**
180
+ * Registers Quilt's MCP server with every AI tool installed here. Never throws.
181
+ * Returns [{ name, file, result }] for every tool found ('added' | 'updated' |
182
+ * 'unchanged' | 'skipped' | 'busy' | 'failed', with `error` for failed).
183
+ */
184
+ export function registerEverywhere ({ launch = quiltLaunch(), ...where } = {}) {
185
+ const server = mcpServer(launch)
186
+ const out = []
187
+ for (const t of knownTools(where)) {
188
+ if (!fs.existsSync(t.dir)) continue
189
+ try {
190
+ if (t.busy && t.busy(t.file)) { out.push({ name: t.name, file: t.file, result: 'busy' }); continue }
191
+ out.push({ name: t.name, file: t.file, result: t.write(t.file, server, launch) })
192
+ } catch (err) {
193
+ out.push({ name: t.name, file: t.file, result: 'failed', error: err.message })
194
+ }
195
+ }
196
+ return out
197
+ }
198
+
199
+ /** One line for logs: which tools just got Quilt, and which need a look. Empty when nothing changed. */
200
+ export function describeRegistration (results) {
201
+ const done = results.filter((r) => r.result === 'added' || r.result === 'updated').map((r) => r.name)
202
+ const left = results.filter((r) => r.result === 'skipped' || r.result === 'failed').map((r) => `${r.name} (${r.file})`)
203
+ const parts = []
204
+ if (done.length) parts.push(`🔌 Quilt is now available to ${done.join(', ')}; restart a tool that was open to pick it up`)
205
+ if (left.length) parts.push(`could not safely edit: ${left.join(', ')}`)
206
+ return parts.join('. ')
207
+ }
208
+
209
+ /**
210
+ * What the app, a session and `quilt login` run as they start: register, and log what changed.
211
+ * Skipped under the test runner (it would write to the developer's own tool settings) unless
212
+ * QUILT_REGISTER_TOOLS=1 asks for it.
213
+ */
214
+ export function registerOnStart (log = () => {}, opts = {}) {
215
+ if (process.env.NODE_TEST_CONTEXT && process.env.QUILT_REGISTER_TOOLS !== '1') return []
216
+ let results = []
217
+ try { results = registerEverywhere(opts) } catch { return [] }
218
+ const line = describeRegistration(results)
219
+ if (line) log(line)
220
+ return results
221
+ }
package/src/key-cli.js ADDED
@@ -0,0 +1,115 @@
1
+ // The quilt commands in a sandbox: no `quilt join` runs there, so with a QUILT_AGENT_KEY each
2
+ // command is one call to the hosted tools (hosted-client.js), as the agent, in the key's session.
3
+ // bin/quilt.js comes here when no session runs in the folder and the key is set.
4
+
5
+ /** Commands that work with the key. tasks, task and tool exist only here. */
6
+ export const KEY_COMMANDS = new Set(['status', 'messages', 'inbox', 'say', 'history', 'claim', 'release', 'focus', 'commits', 'commit-request', 'tasks', 'task', 'tool'])
7
+
8
+ export const KEY_USAGE = `With QUILT_AGENT_KEY (an agent in a sandbox, no quilt join running):
9
+ quilt sync Sync this folder with the session once (start and end of every turn)
10
+ quilt status | inbox | tasks | commits What is going on, what waits for you, the board, commit requests
11
+ quilt messages [--with name] [--grep text] [--before id] [-n 30]
12
+ quilt say [@name] <message> [--everyone] [--also]
13
+ quilt task <id> One task in full, with its comments
14
+ quilt history [path] [--by name] [--since 2h] [--task id] [--diff] [-n 30]
15
+ quilt claim <path|glob> [note] | quilt release [path|glob]
16
+ quilt focus <what you are doing> Shared with everyone (quilt_share)
17
+ quilt commit-request <message> [--files a,b] [--task id]
18
+ quilt tool <quilt_tool_name> [json] Any Quilt tool, e.g. quilt tool quilt_move_task '{"id":"t1","column":"doing"}'
19
+ quilt tool Lists the tools
20
+ quilt mcp The same tools as a stdio MCP server`
21
+
22
+ /** --flag value pairs and positionals, for the few flags these commands take. */
23
+ function parse (argv, flags, bools = []) {
24
+ const values = {}
25
+ const rest = []
26
+ for (let i = 0; i < argv.length; i++) {
27
+ const a = argv[i]
28
+ const name = a.replace(/^--?/, '')
29
+ if (a.startsWith('-') && flags.includes(name)) { values[name] = argv[++i]; continue }
30
+ if (a.startsWith('--') && bools.includes(name)) { values[name] = true; continue }
31
+ rest.push(a)
32
+ }
33
+ return { values, rest }
34
+ }
35
+
36
+ const num = (v, d) => (v === undefined ? d : Math.max(1, Math.min(200, Number(v) || d)))
37
+
38
+ /** The tool and arguments a command stands for, or { list: true } for `quilt tool` alone. */
39
+ export function toolFor (cmd, argv, { stdin = '' } = {}) {
40
+ switch (cmd) {
41
+ case 'status': return ['quilt_status', {}]
42
+ case 'inbox': return ['quilt_inbox', {}]
43
+ case 'commits': return ['quilt_commit_status', {}]
44
+ case 'tasks': return ['quilt_tasks', parse(argv, [], ['archived']).values.archived ? { archived: true } : {}]
45
+ case 'task': {
46
+ if (!argv[0]) throw new Error('usage: quilt task <id>')
47
+ return ['quilt_task', { id: argv[0] }]
48
+ }
49
+ case 'messages': {
50
+ const { values } = parse(argv, ['with', 'grep', 'before', 'n'], ['all'])
51
+ if (values.with || values.grep || values.before) return ['quilt_conversation', { ...(values.with ? { with: values.with } : {}), ...(values.grep ? { q: values.grep } : {}), ...(values.before ? { before: values.before } : {}), limit: num(values.n, 30) }]
52
+ return ['quilt_read_messages', { limit: Math.min(100, num(values.n, 50)) }]
53
+ }
54
+ case 'say': {
55
+ const { values, rest } = parse(argv, ['to', 're'], ['everyone', 'also'])
56
+ let to = values.to
57
+ // "@bob text" is a direct message to bob, as with a running session (bin/quilt.js splitRecipient); @Agents is everyone's.
58
+ if (!to && rest.length > 1 && /^@[^@\s]+$/.test(rest[0]) && !/^@agents$/i.test(rest[0])) to = rest.shift().slice(1)
59
+ const text = rest.join(' ').trim()
60
+ if (!text) throw new Error('usage: quilt say [@name] <message> (a message names who it is for with @Name, or add --everyone)')
61
+ return ['quilt_message', { text, ...(to ? { to } : {}), ...(values.everyone ? { everyone: true } : {}), ...(values.also ? { also: true } : {}), ...(values.re ? { re: values.re } : {}) }]
62
+ }
63
+ case 'history': {
64
+ const { values, rest } = parse(argv, ['by', 'since', 'task', 'n'], ['diff'])
65
+ return ['quilt_history', { ...(rest[0] ? { path: rest[0] } : {}), ...(values.by ? { by: values.by } : {}), ...(values.since ? { since: values.since } : {}), ...(values.task ? { task: values.task } : {}), ...(values.diff ? { with_diff: true } : {}), limit: num(values.n, 30) }]
66
+ }
67
+ case 'claim': {
68
+ if (!argv[0]) throw new Error('usage: quilt claim <path|glob> [note]')
69
+ return ['quilt_claim', { pattern: argv[0], ...(argv.length > 1 ? { note: argv.slice(1).join(' ') } : {}) }]
70
+ }
71
+ case 'release': return ['quilt_release', argv[0] ? { pattern: argv[0] } : {}]
72
+ case 'focus': {
73
+ if (!argv.length) throw new Error('usage: quilt focus <what you are doing>')
74
+ return ['quilt_share', { summary: argv.join(' ') }]
75
+ }
76
+ case 'commit-request': {
77
+ const { values, rest } = parse(argv, ['files', 'task'])
78
+ const files = values.files ? values.files.split(',').map((f) => f.trim()).filter(Boolean) : undefined
79
+ return ['quilt_request_commit', { ...(rest.length ? { message: rest.join(' ') } : {}), ...(files ? { files } : {}), ...(values.task ? { task: values.task } : {}) }]
80
+ }
81
+ case 'tool': {
82
+ if (!argv[0]) return { list: true }
83
+ const raw = argv[1] === '-' ? stdin : argv.slice(1).join(' ')
84
+ let args = {}
85
+ if (raw && raw.trim()) {
86
+ try { args = JSON.parse(raw) } catch { throw new Error(`The arguments must be JSON, e.g. quilt tool ${argv[0]} '{"key":"value"}'`) }
87
+ if (!args || typeof args !== 'object' || Array.isArray(args)) throw new Error('The arguments must be a JSON object.')
88
+ }
89
+ return [argv[0], args]
90
+ }
91
+ }
92
+ throw new Error(`quilt ${cmd} isn't a command here.\n\n${KEY_USAGE}`)
93
+ }
94
+
95
+ /** Runs one command with the key's client: prints the answer, resolves to the exit code. */
96
+ export async function runKeyCommand (cmd, argv, { client, out = console.log, err = console.error, stdin = '' } = {}) {
97
+ let what
98
+ try { what = toolFor(cmd, argv, { stdin }) } catch (e) { err(e.message); return 1 }
99
+ try {
100
+ if (what.list) {
101
+ const r = await client.rpc('tools/list', {})
102
+ out((r.tools || []).map((t) => `${t.name}: ${String(t.description || '').split('. ')[0]}`).join('\n'))
103
+ return 0
104
+ }
105
+ const [name, args] = what
106
+ const r = await client.callRaw(name, args)
107
+ const text = client.textOf(r)
108
+ if (r && r.isError) { err(text || `${name} was refused.`); return 1 }
109
+ out(text)
110
+ return 0
111
+ } catch (e) {
112
+ err(e.message)
113
+ return 1
114
+ }
115
+ }