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/duties.js ADDED
@@ -0,0 +1,303 @@
1
+ // What an agent owes the people it works with, and what it should know before it acts,
2
+ // whatever tool it runs in. Pure checks shared by the local MCP, the hosted MCP and the
3
+ // Claude Code hooks, so every agent gets the same rules:
4
+ //
5
+ // - before editing a file: who holds it (claims are what keep agents off each other's
6
+ // files), and what was said about it in chat lately, as context;
7
+ // - before moving work on (claims, tasks, commits, "done"): every direct message and
8
+ // mention is answered (a message back to that person, or to everyone, after theirs);
9
+ // - before finishing or letting go of a file: anyone waiting for it in its file queue is
10
+ // handed it, with the holder's context (quilt_handoff). A file someone else holds is
11
+ // asked for in its queue (quilt_request_file), not taken.
12
+ //
13
+ // Chat never blocks a file: Quilt can't tell "don't touch it" from "is it done?". A file
14
+ // is held by a claim; a message about it is something the agent reads.
15
+ //
16
+ // Messages are chat entries ({ id, by, to, text, ts }) the reader can see.
17
+ import { mentionsMe, mentioned, ALL_AGENTS } from './inbox.js'
18
+ import { RELAY_BY } from './branches.js'
19
+ import { RELAY_OLD_NAMES } from './ui/chat.js'
20
+
21
+
22
+ // How far back chat about a file is still worth reading before editing it.
23
+ export const REQUEST_WINDOW_MS = 24 * 60 * 60 * 1000
24
+
25
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
26
+
27
+ /**
28
+ * Does `text` name `rel`? The whole path counts, and so does a file name with an
29
+ * extension (app.js), as its own word: "src/app.js", "`app.js`" and "app.js?" do,
30
+ * "myapp.js" and "app.json" don't.
31
+ */
32
+ export function namesPath (text, rel) {
33
+ const t = String(text || '')
34
+ const p = String(rel || '').replace(/^\.\//, '')
35
+ if (!t || !p) return false
36
+ const base = p.split('/').pop()
37
+ const tokens = [p]
38
+ if (base !== p && /\.\w/.test(base)) tokens.push(base)
39
+ return tokens.some((tok) => new RegExp(`(^|[^\\w.-])${escapeRe(tok)}(?![\\w-]|\\.\\w)`, 'u').test(t))
40
+ }
41
+
42
+ const ANY_MENTION = /(^|[^\w@])@\w/u
43
+
44
+ /**
45
+ * Did `me` write back to `who` after `ts`? A direct message to them, a message that @mentions
46
+ * them, or one to everyone that mentions nobody. A message @mentioning only others is not an answer.
47
+ */
48
+ export function answered (messages, me, who, ts) {
49
+ const mine = Array.isArray(me) ? me : [me] // an AI session answers under any name it has had
50
+ return (messages || []).some((m) => m && mine.includes(m.by) && (m.ts || 0) > (ts || 0) &&
51
+ (m.to ? m.to === who : mentioned(m.text, [who]).length > 0 || !ANY_MENTION.test(String(m.text || ''))))
52
+ }
53
+
54
+ // ------------------------------------------------------------------ chat --
55
+ // Every message says who it is for, so only they are woken: @Name in the text (several are
56
+ // fine), or a direct message. Several AI sessions can work as one member at once (a person
57
+ // with Claude Code in one window and Cursor in another); they see the same messages, so only
58
+ // one of them answers each person: the others are refused a repeat.
59
+
60
+ /** How to use chat, said by quilt_message (local and hosted) and in every agent's instructions. */
61
+ export const CHAT_RULES = 'Send a chat message. Start it with @Name of each person or agent it is for (several are fine; @Agents for every agent): ' +
62
+ 'only they are told, so nobody else is interrupted. A message that names nobody is refused unless it is a real announcement (everyone: true). ' +
63
+ 'Write only when you have something they need: an answer, a question, a handoff, a warning. Never send greetings, welcomes, thanks or "noted" replies. ' +
64
+ '@Quilt answers questions about the session itself (who is connected, which branches it keeps, how they stand against git) as a direct message to you. ' +
65
+ 'Other AI sessions may be working as the same member as you (other windows, other tools) and see the same messages: only one answers each person, and Quilt refuses a repeat. ' +
66
+ 'A direct message you answer gets its id on your reply (re), and each message is answered once: Quilt refuses a second answer to the same one.'
67
+
68
+ /** What quilt_message's `re` is for, local and hosted. */
69
+ export const RE_HELP = 'Id of the message you are answering (the id in your inbox). It goes back to its sender. Without it, a direct message answers the latest one they sent you. A message already answered is refused'
70
+
71
+ /** Who a message is for: `to`, or the names among `names` (and @Agents, and @Quilt) it mentions. */
72
+ export function addressees (text, to, names) {
73
+ return to ? [String(to)] : mentioned(text, [...new Set([...(names || []), ALL_AGENTS, RELAY_BY, ...RELAY_OLD_NAMES])])
74
+ }
75
+
76
+ /** Why a message may not go as it is: it names nobody (no `to`, no @Name), so it is for no one in particular. '' when fine. */
77
+ export function unaddressed (text, { to = null, everyone = false, names = [] } = {}) {
78
+ if (to || everyone || addressees(text, to, names).length) return ''
79
+ return 'Not sent: this message names nobody. Start it with @Name of each person or agent it is for ' +
80
+ `(${names.length ? names.slice(0, 6).map((n) => '@' + n).join(', ') : '@Name'}; @${ALL_AGENTS} for every agent): only they are told, so nobody else is interrupted. ` +
81
+ 'Or set "to" for a direct message. If it really is an announcement for everyone, send it again with everyone: true.'
82
+ }
83
+
84
+ // How long a message from one of a member's AI sessions stands in for the others.
85
+ export const REPEAT_WINDOW_MS = 30 * 60 * 1000
86
+
87
+ /**
88
+ * A message one of this member's other AI sessions already sent to one of `targets`, since that
89
+ * person last wrote (and within the window), or null. `sent`: [{ via, targets, text, ts }] the
90
+ * member's AI sessions sent, oldest first; `via` is the session about to send.
91
+ */
92
+ export function sentByAnother (sent, { via, targets = [], messages = [], now = Date.now(), windowMs = REPEAT_WINDOW_MS } = {}) {
93
+ if (!via) return null
94
+ for (const t of targets) {
95
+ const lastFrom = (messages || []).reduce((n, m) => m && m.by === t && (m.ts || 0) > n ? m.ts : n, 0)
96
+ for (let i = (sent || []).length - 1; i >= 0; i--) {
97
+ const s = sent[i]
98
+ if (!s || s.via === via || s.ts <= lastFrom || s.ts < now - windowMs) continue
99
+ if ((s.targets || []).includes(t)) return { to: t, text: s.text, ts: s.ts }
100
+ }
101
+ }
102
+ return null
103
+ }
104
+
105
+ /** Why a repeat is refused: what the other session already said. */
106
+ export function renderRepeat (hit, now = Date.now()) {
107
+ return `Not sent: another AI session working as you already wrote to ${hit.to} ${ago(hit.ts, now)}: "${quote(hit.text)}". ` +
108
+ `${hit.to} has that, and has not written since, so do not repeat it, thank them or greet them again. ` +
109
+ 'If yours is about something different that they need from you, send it again with also: true.'
110
+ }
111
+
112
+ // ------------------------------------------------------------- reply ids --
113
+ // A direct message that answers one carries its id (`re`), so an AI can see a message was
114
+ // already answered (by it, or by another of its person's AI sessions) and not answer twice.
115
+
116
+ /**
117
+ * The reply id for a message about to go out, and whether it is a second answer to the same
118
+ * message. `names`: every name the sender answers to (its own, its person's, their other AI
119
+ * sessions'). `re`: the id it says it answers ('' to work it out: the latest direct message
120
+ * from `to` to one of `names`). Returns { to, re, repeat } where `repeat` ({ re, to, text, ts }:
121
+ * the earlier answer) is set when that message was already answered and its sender has not
122
+ * written since, or { error } for an id that can't be answered.
123
+ */
124
+ export function replyFor (messages, { names = [], to = null, re = '', now = Date.now(), windowMs = REPEAT_WINDOW_MS } = {}) {
125
+ const list = (messages || []).filter((m) => m && typeof m.id === 'string')
126
+ const ours = (m) => names.includes(m.by)
127
+ let orig = null
128
+ if (re) {
129
+ orig = list.find((m) => m.id === re)
130
+ if (!orig) return { error: `No message with id ${re}: check the id in your inbox.` }
131
+ if (ours(orig)) return { error: `Message ${re} is your own: give the id of the message you are answering.` }
132
+ if (!to && orig.to) to = orig.by // answering a direct message answers its sender
133
+ } else if (to) {
134
+ for (let i = list.length - 1; i >= 0 && !orig; i--) if (list[i].by === to && names.includes(list[i].to)) orig = list[i]
135
+ }
136
+ if (!orig) return { to, re: '', repeat: null }
137
+ const earlier = list.filter((m) => ours(m) && m.re === orig.id).pop()
138
+ if (earlier) {
139
+ const lastFrom = list.reduce((n, m) => m.by === orig.by && (m.ts || 0) > n ? m.ts : n, 0)
140
+ // Explicitly answered twice, or written to again right after answering with nothing new from them.
141
+ if (re || ((earlier.ts || 0) >= lastFrom && (earlier.ts || 0) > now - windowMs)) {
142
+ return { to, re: orig.id, repeat: { re: orig.id, to: orig.by, text: earlier.text, ts: earlier.ts } }
143
+ }
144
+ return { to, re: '', repeat: null } // a new message to them, not an answer to that one
145
+ }
146
+ return { to, re: orig.id, repeat: null }
147
+ }
148
+
149
+ /** Why a second answer to one message is refused: the answer already sent. */
150
+ export function renderReplied (hit, now = Date.now()) {
151
+ return `Not sent: ${hit.to}'s message ${hit.re} was already answered ${ago(hit.ts, now)}: "${quote(hit.text)}". ` +
152
+ `${hit.to} has not written since, so do not answer it again, thank them or confirm. ` +
153
+ 'If yours is something new that they need from you, send it again with also: true.'
154
+ }
155
+
156
+ /**
157
+ * Recent chat from others that names one of `paths`: what people said about the files this
158
+ * agent is about to change (asked for, warned off, planned). Each is
159
+ * { path, id, by, to, text, ts, answered }, oldest first; `answered` is whether `me` wrote
160
+ * back to that person (or everyone) since.
161
+ */
162
+ export function chatAbout (paths, { messages = [], me, now = Date.now(), windowMs = REQUEST_WINDOW_MS } = {}) {
163
+ const out = []
164
+ for (const m of messages) {
165
+ if (!m || !m.by || m.by === me || typeof m.text !== 'string') continue
166
+ if (m.to && m.to !== me) continue
167
+ if ((m.ts || 0) < now - windowMs) continue
168
+ const hit = (paths || []).find((p) => namesPath(m.text, p))
169
+ if (!hit) continue
170
+ out.push({ path: hit, id: m.id, by: m.by, to: m.to || null, text: m.text, ts: m.ts, answered: answered(messages, me, m.by, m.ts) })
171
+ }
172
+ return out
173
+ }
174
+
175
+ // Words a message made only of pleasantries is built from: a goodnight, a greeting, thanks, an
176
+ // "ok". Such a message asks nothing, so nobody has to answer it before they go on working.
177
+ const PLEASANTRY = new Set(('goodnight night nite gn bye goodbye cya later morning gm evening afternoon hi hello hey heya yo ' +
178
+ 'thanks thank thx ty cheers appreciated appreciate ok okay k kk alright allright cool nice great awesome perfect noted sweet ' +
179
+ 'bed bedtime sleep welcome np lol haha').split(' '))
180
+ const FILLER = new Set(('good have a an the all everyone everybody guys folks team yall you u ya see for now today tonight time day ' +
181
+ 'very much so and it got sounds off signing heading out im i am going to soon tomorrow again one well there oh wow agents agent ' +
182
+ 'is its that this me we are too also just really done my your').split(' '))
183
+
184
+ /**
185
+ * Whether a message plainly needs nothing back: after its @mentions, only a few words of
186
+ * goodnight, greeting, thanks or "ok", with no question ("alright, bed time. Goodnight @Agents").
187
+ * Anything else, or anything in doubt, still waits for an answer.
188
+ */
189
+ export function needsNoReply (text) {
190
+ const t = String(text || '')
191
+ if (t.includes('?')) return false
192
+ const words = t.replace(/(^|\s)@\S+/gu, ' ').toLowerCase().replace(/['’]/g, '').split(/[^\p{L}\p{N}]+/u).filter(Boolean)
193
+ if (!words.length || words.length > 14) return false
194
+ return words.every((w) => PLEASANTRY.has(w) || FILLER.has(w)) && words.some((w) => PLEASANTRY.has(w))
195
+ }
196
+
197
+ /**
198
+ * Straight from the chat: direct messages to `me` and mentions of `me` (recent, from others)
199
+ * with no later message from `me` to that person or everyone, as inbox-like events.
200
+ * `agent`: `me` joined as an agent, so an @Agents message waits on it too.
201
+ */
202
+ export function waitingOn (messages, me, { now = Date.now(), windowMs = REQUEST_WINDOW_MS, agent = false, settled = null } = {}) {
203
+ const out = []
204
+ const names = Array.isArray(me) ? me : [me] // an AI session's name, and the ones it had before
205
+ for (const m of messages || []) {
206
+ if (!m || !m.by || names.includes(m.by) || typeof m.text !== 'string' || (m.ts || 0) < now - windowMs) continue
207
+ if (settled && settled.has(m.id)) continue // needs no reply (quilt_inbox no_reply)
208
+ if (fileQueueMessage(m)) continue // a file queue request or a handoff: handled by handing off, not by a reply
209
+ if (m.kind === 'commit') continue // a commit request told to the owner: handled by committing (quilt_commit_request_done)
210
+ if (needsNoReply(m.text)) continue // a goodnight, a greeting, thanks: asks nothing
211
+ const kind = names.includes(m.to) ? 'dm' : !m.to && names.some((n) => mentionsMe(m.text, n, { agent })) ? 'mention' : null
212
+ if (kind && !answered(messages, names, m.by, m.ts)) out.push({ id: m.id, kind, by: m.by, text: m.text, ts: m.ts })
213
+ }
214
+ return out
215
+ }
216
+
217
+ /**
218
+ * A message that needs no reply: one the relay wrote for the file queue (someone asking for a file,
219
+ * or a file handed over), or a note that work someone asked to commit was committed (commit.js).
220
+ */
221
+ export const fileQueueMessage = (m) => !!m && (m.kind === 'queue' || m.kind === 'handoff' || m.kind === 'commit' || m.kind === 'nudge')
222
+
223
+ /** Direct messages and mentions among inbox `events` that `me` has not answered yet. */
224
+ export function unanswered (events, { messages = [], me } = {}) {
225
+ return (events || []).filter((e) => e && (e.kind === 'dm' || e.kind === 'mention') && !e.queue && !e.commit && !e.nudge && e.by !== me && !needsNoReply(e.text) && !answered(messages, me, e.by, e.ts))
226
+ }
227
+
228
+ /**
229
+ * Files `me` holds that someone is waiting for: [{ pattern, queue: [{ id, path, by, title,
230
+ * description, task }] }], from the relay's claim list.
231
+ */
232
+ export function queuedFor (claims, me) {
233
+ return (claims || []).filter((c) => c && c.by === me && Array.isArray(c.queue) && c.queue.length).map((c) => ({ pattern: c.pattern, queue: c.queue }))
234
+ }
235
+
236
+ const requestLine = (r) => `${r.by}${r.path ? ` (for ${r.path})` : ''}: "${quote(r.title)}"${r.description ? ` — ${quote(r.description)}` : ''}${r.task ? ` (task ${r.task})` : ''}`
237
+
238
+ /** Who is waiting for the files `me` holds, as lines an agent reads with every answer; '' when nobody is. */
239
+ export function renderQueueNotice (held) {
240
+ if (!held || !held.length) return ''
241
+ const lines = held.map((h) => `- ${h.pattern}: ${h.queue.map(requestLine).join('; then ')}`)
242
+ return 'Waiting in the file queue for files you hold:\n' + lines.join('\n') + '\n' +
243
+ 'Finish the change you are making to each, then hand it off with quilt_handoff (path, and context: what you changed, what is left, anything they should know). ' +
244
+ 'You cannot finish or release these files until you do.'
245
+ }
246
+
247
+ /** Why an agent may not finish (or let go of files) yet: files it holds that someone is waiting for. '' when none. */
248
+ export function renderQueued (held, then = 'finish again') {
249
+ if (!held || !held.length) return ''
250
+ return 'Not yet: ' + renderQueueNotice(held).replace(/^Waiting/, 'people are waiting') + ` Then ${then}.`
251
+ }
252
+
253
+ const quote = (t) => {
254
+ const s = String(t || '').replace(/\s+/g, ' ').trim()
255
+ return s.length > 300 ? s.slice(0, 300) + '…' : s
256
+ }
257
+
258
+ const ago = (ts, now) => {
259
+ const m = Math.max(0, Math.round((now - (ts || now)) / 60000))
260
+ return m < 1 ? 'just now' : m < 60 ? `${m}m ago` : `${Math.round(m / 60)}h ago`
261
+ }
262
+
263
+ /** What was said about the files an agent is about to change, newest last, or '' when nothing was. */
264
+ export function renderChatAbout (said, { now = Date.now(), max = 8 } = {}) {
265
+ if (!said || !said.length) return ''
266
+ const shown = said.slice(-max)
267
+ const lines = shown.map((r) => `- ${r.by}${r.to ? ' (to you)' : ''}, ${ago(r.ts, now)}, about ${r.path}: "${quote(r.text)}"${r.answered ? '' : ' (you have not replied)'}`)
268
+ const more = said.length > shown.length ? `\n(and ${said.length - shown.length} earlier: quilt_read_messages)` : ''
269
+ return 'What people said in chat about these files:\n' + lines.join('\n') + more + '\n' +
270
+ 'Take it into account: if they asked you to leave a file alone or to change it a certain way, do that, and reply to anyone you have not replied to with quilt_message.'
271
+ }
272
+
273
+ /**
274
+ * Why an agent may not go on yet: the messages it still owes an answer, or '' when it owes none.
275
+ * `then` says what to do once they're answered ("finish again", "call quilt_claim again").
276
+ */
277
+ export function renderUnanswered (events, then = 'finish again') {
278
+ if (!events || !events.length) return ''
279
+ const lines = events.map((e) => `- ${e.by} ${e.kind === 'dm' ? 'sent you a direct message' : 'mentioned you'}${e.id ? ` (id ${e.id})` : ''}: "${quote(e.text)}"`)
280
+ return 'Not yet: these people are still waiting for an answer from you:\n' + lines.join('\n') + '\n' +
281
+ `Answer each that asks something of you with quilt_message (to: their name), even if only to say when you will get to it. ` +
282
+ `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]). Then ${then}. ` +
283
+ 'Reading, messaging and checking files work meanwhile.'
284
+ }
285
+
286
+ /** One refusal for an edit to a file someone else holds: who, and what to do instead of retrying. */
287
+ export function heldRefusal (rel, claim, error) {
288
+ const holder = claim ? claim.by : 'someone else'
289
+ const why = claim ? (claim.note ? ` (${claim.note})` : '') : error ? ` (${error})` : ''
290
+ // A box (docs/superpowers/specs/2026-10-16-git-boxes-design.md): their work isn't in this folder yet.
291
+ if (claim && claim.box) return `${rel} is boxed by ${holder}${why}: their unfinished work, which isn't in your folder until they commit or unpack it. Quilt refuses edits to it and would undo them. ${askForIt(rel, claim)}`
292
+ const covered = claim && claim.pattern && claim.pattern !== rel ? `, as part of their claim on ${claim.pattern}` : ''
293
+ return `${rel} is claimed by ${holder}${why}${covered}, so Quilt refuses edits to it and would undo them. ${askForIt(rel, claim)}`
294
+ }
295
+
296
+ /** What to do about a file someone else holds: ask for it in its file queue, and carry on. */
297
+ export function askForIt (rel, claim) {
298
+ const holder = claim ? claim.by : 'its holder'
299
+ const waiting = claim && Array.isArray(claim.queue) && claim.queue.length ? ` ${claim.queue.length} already waiting for it.` : ''
300
+ return `Do not retry or work around it.${waiting} ` +
301
+ `Ask for it in its file queue with quilt_request_file (path "${rel}", a title like "Working on <what> for <task>", and a description of up to 300 characters: what you plan to change and why). ` +
302
+ `${holder} is told, and hands it to you with their context when they are done; you are woken when it is yours. Carry on with other work meanwhile.`
303
+ }
package/src/editors.js ADDED
@@ -0,0 +1,228 @@
1
+ // Opens a session's folder in the AI coding app someone already has
2
+ // installed (Claude, Cursor, Codex...), so their AI works right where the
3
+ // files sync and its chats reach the session's feed.
4
+ import fs from 'node:fs'
5
+ import os from 'node:os'
6
+ import path from 'node:path'
7
+ import crypto from 'node:crypto'
8
+ import { execFile, execFileSync, spawn } from 'node:child_process'
9
+
10
+ // `tool` matches the AI tool names people pick in their profile. On a Mac,
11
+ // `mac` is the app bundle; `url` builds a link the app opens instead of the
12
+ // folder being handed to it; `cli` is the app's own command-line launcher
13
+ // inside the bundle, run with `args` before the folder. On Windows, `win` is
14
+ // the exe under %LOCALAPPDATA%. `winCli` is that install's command-line
15
+ // launcher, relative to the exe; opening through it (see openCommand) brings
16
+ // the window forward when the app is already running. `macMore` is another
17
+ // bundle the app may ship as, counted only when its bundle id matches.
18
+ export const EDITORS = [
19
+ // The Claude app's own "new session in this folder" link loses the folder
20
+ // once you type, so a session is made in the folder first (see openInClaude)
21
+ // and the app opens that; the link is only the fallback. With a prompt (a
22
+ // merge to settle) the fallback link fills it into the new chat's input as `q`.
23
+ // On Windows the Store build is found by its package folder or the claude://
24
+ // link it registers. The versioned WindowsApps path changes on every update.
25
+ { id: 'claude', name: 'Claude Code', tool: 'Claude Code', mac: 'Claude', win: 'AnthropicClaude/claude.exe', winMore: ['Programs/Claude/Claude.exe'], winPackage: 'Claude_pzs8sxrjxfjjc', protocol: 'claude', url: (dir, prompt) => `claude://code/new?folder=${encodeURIComponent(dir)}${prompt ? `&q=${encodeURIComponent(prompt)}` : ''}` },
26
+ // Cursor otherwise opens its Agents window, whose chats stay on whatever
27
+ // project was used last; a classic window ties the agent to this folder.
28
+ { id: 'cursor', name: 'Cursor', tool: 'Cursor', mac: 'Cursor', cli: 'Contents/Resources/app/bin/cursor', args: ['--classic', '--new-window'], win: 'Programs/cursor/Cursor.exe', winCli: 'resources/app/bin/cursor.cmd' },
29
+ // The Codex app now ships as ChatGPT.app; only the build with Codex's bundle id counts,
30
+ // not the older chat-only ChatGPT app. It opens a folder handed to it as a project.
31
+ { id: 'codex', name: 'Codex', tool: 'Codex', mac: 'Codex', macMore: [{ app: 'ChatGPT', bundleId: 'com.openai.codex' }] },
32
+ { id: 'windsurf', name: 'Windsurf', tool: 'Windsurf', mac: 'Windsurf', win: 'Programs/Windsurf/Windsurf.exe' },
33
+ { id: 'vscode', name: 'VS Code', tool: 'GitHub Copilot', mac: 'Visual Studio Code', win: 'Programs/Microsoft VS Code/Code.exe' },
34
+ { id: 'zed', name: 'Zed', tool: 'Zed', mac: 'Zed' }
35
+ ]
36
+
37
+ // Paths in `platform`'s style, so a Mac layout reads the same when tests run on Windows.
38
+ const pathsOf = (platform) => platform === 'win32' ? path.win32 : path.posix
39
+
40
+ /** Where an editor is installed on this computer, or null. */
41
+ function locate (ed, opts = {}) {
42
+ const { platform = process.platform, home = os.homedir(), exists = fs.existsSync, localAppData = process.env.LOCALAPPDATA, bundleIdOf = macBundleId } = opts
43
+ const path = pathsOf(platform)
44
+ if (platform === 'darwin') {
45
+ const dirs = ['/Applications', path.join(home, 'Applications')]
46
+ for (const dir of dirs) {
47
+ const app = path.join(dir, `${ed.mac}.app`)
48
+ if (ed.mac && exists(app)) return app
49
+ }
50
+ for (const more of ed.macMore || []) {
51
+ for (const dir of dirs) {
52
+ const app = path.join(dir, `${more.app}.app`)
53
+ if (exists(app) && bundleIdOf(app) === more.bundleId) return app
54
+ }
55
+ }
56
+ return null
57
+ }
58
+ if (platform === 'win32') {
59
+ const local = localAppData || path.join(home, 'AppData', 'Local')
60
+ const candidates = [
61
+ ...(ed.win ? [ed.win] : []),
62
+ ...(ed.winMore || []),
63
+ ...(ed.winPackage ? [`Packages/${ed.winPackage}`] : [])
64
+ ]
65
+ for (const rel of candidates) {
66
+ const found = path.join(local, ...rel.split('/'))
67
+ if (exists(found)) return found
68
+ }
69
+ if (ed.protocol && hasProtocol(ed.protocol, opts)) return `${ed.protocol}://`
70
+ return null
71
+ }
72
+ return null
73
+ }
74
+
75
+ /** A Mac app's bundle id, from its Info.plist, or null. */
76
+ function macBundleId (app) {
77
+ try {
78
+ const plist = fs.readFileSync(path.join(app, 'Contents', 'Info.plist'), 'utf8')
79
+ return plist.match(/<key>CFBundleIdentifier<\/key>\s*<string>([^<]+)<\/string>/)?.[1] || null
80
+ } catch { return null }
81
+ }
82
+
83
+ /** Whether `name://` is a registered link on this computer. `protocols` is for tests. */
84
+ function hasProtocol (name, { protocols, query = execFileSync } = {}) {
85
+ if (protocols) return protocols(name)
86
+ if (process.platform !== 'win32') return false
87
+ try {
88
+ query('reg', ['query', `HKCU\\Software\\Classes\\${name}`, '/v', 'URL Protocol'], { windowsHide: true, stdio: 'ignore', timeout: 3000 })
89
+ return true
90
+ } catch { return false }
91
+ }
92
+
93
+ /** The editors installed here, in list order: [{ id, name, tool }]. */
94
+ export function installedEditors (opts) {
95
+ return EDITORS.filter((ed) => locate(ed, opts)).map(({ id, name, tool }) => ({ id, name, tool }))
96
+ }
97
+
98
+ /** The command that opens `dir` in editor `id`: [file, args, runOpts?]. */
99
+ export function openCommand (id, dir, opts = {}) {
100
+ const ed = EDITORS.find((e) => e.id === id)
101
+ if (!ed) throw new Error('Unknown app.')
102
+ const where = locate(ed, opts)
103
+ if (!where) throw new Error(`${ed.name} isn't installed on this computer.`)
104
+ const platform = opts.platform || process.platform
105
+ const path = pathsOf(platform)
106
+ const args = ed.args || []
107
+ if (platform === 'darwin') {
108
+ if (ed.url) return ['open', [ed.url(dir, opts.prompt)]]
109
+ if (ed.cli) return [path.join(where, ...ed.cli.split('/')), [...args, dir]]
110
+ return ['open', ['-a', where, dir]]
111
+ }
112
+ // An encoded link has no spaces or quotes, so it reaches cmd unquoted: its & must be escaped.
113
+ if (ed.url) return ['cmd', ['/c', 'start', '""', ed.url(dir, opts.prompt).replaceAll('&', '^&')]]
114
+ // Launching Cursor.exe while it is already open hands the folder to the
115
+ // running instance and leaves that window behind whatever is in front.
116
+ // `start` of its CLI activates the window on the folder instead.
117
+ if (ed.winCli) {
118
+ const cli = path.join(path.dirname(where), ...ed.winCli.split('/'))
119
+ const exists = opts.exists || fs.existsSync
120
+ if (exists(cli)) return ['cmd.exe', ['/c', 'start', '', cli, ...args, dir], { activate: true }]
121
+ }
122
+ return [where, [...args, dir]]
123
+ }
124
+
125
+ const run = (file, args, opts = {}) => {
126
+ // `start` returns as soon as the app is activated; waiting would hold the
127
+ // button until the launcher's console exits.
128
+ if (opts.activate) {
129
+ return new Promise((resolve, reject) => {
130
+ const child = spawn(file, args, { detached: true, stdio: 'ignore', windowsHide: false })
131
+ child.once('error', reject)
132
+ child.once('spawn', () => { child.unref(); resolve() })
133
+ })
134
+ }
135
+ return new Promise((resolve, reject) => {
136
+ const child = execFile(file, args, { windowsHide: true, timeout: 60000, ...opts }, (err, stdout, stderr) => {
137
+ if (!err) return resolve()
138
+ // What the tool said beats "Command failed: <the whole prompt>".
139
+ const said = `${stderr || ''}\n${stdout || ''}`.split('\n').map((l) => l.trim()).find(Boolean)
140
+ if (err.killed) err.message = 'timed out'
141
+ else if (said) err.message = said.slice(0, 300)
142
+ reject(err)
143
+ })
144
+ child.stdin?.end() // the Claude CLI waits for stdin to close before running a prompt
145
+ })
146
+ }
147
+
148
+ /** The Claude Code command-line tool: installed on its own, or the copy inside the Claude app. */
149
+ export function claudeCli ({ platform = process.platform, home = os.homedir(), exists = fs.existsSync, readdir = fs.readdirSync } = {}) {
150
+ const exe = platform === 'win32' ? 'claude.exe' : 'claude'
151
+ // PATH is this computer's, whatever platform is asked about.
152
+ const delimiter = pathsOf(process.platform).delimiter
153
+ const path = pathsOf(platform)
154
+ const dirs = [path.join(home, '.local', 'bin'), path.join(home, '.claude', 'local'), '/opt/homebrew/bin', '/usr/local/bin',
155
+ ...(process.env.PATH || '').split(delimiter).filter(Boolean)]
156
+ for (const d of dirs) if (exists(path.join(d, exe))) return path.join(d, exe)
157
+ if (platform === 'darwin') {
158
+ const bundled = path.join(home, 'Library', 'Application Support', 'Claude', 'claude-code')
159
+ let versions = []
160
+ try { versions = readdir(bundled) } catch {}
161
+ versions.sort((a, b) => b.localeCompare(a, undefined, { numeric: true }))
162
+ for (const v of versions) {
163
+ const cli = path.join(bundled, v, 'claude.app', 'Contents', 'MacOS', 'claude')
164
+ if (exists(cli)) return cli
165
+ }
166
+ }
167
+ return null
168
+ }
169
+
170
+ /** How a new Claude Code session named after the folder is made, without a model call. */
171
+ export function claudeSessionCommand (cli, dir, id) {
172
+ return [cli, ['-p', `/rename ${path.basename(dir)} (quilt)`, '--session-id', id], { cwd: dir }]
173
+ }
174
+
175
+ /** Puts text on the clipboard (pbcopy on a Mac, clip on Windows, xclip elsewhere). False when it couldn't. */
176
+ export async function copyToClipboard (text) {
177
+ const cmd = process.platform === 'darwin' ? ['pbcopy', []] : process.platform === 'win32' ? ['clip', []] : ['xclip', ['-selection', 'clipboard']]
178
+ return new Promise((resolve) => {
179
+ const child = spawn(cmd[0], cmd[1], { stdio: ['pipe', 'ignore', 'ignore'] })
180
+ child.on('error', () => resolve(false))
181
+ child.on('close', (code) => resolve(code === 0))
182
+ child.stdin.end(text)
183
+ })
184
+ }
185
+
186
+ const resumeLink = (id, platform = process.platform) => (platform === 'win32' ?['cmd', ['/c', 'start', '""', `claude://resume?session=${id}`]] : ['open', [`claude://resume?session=${id}`]])
187
+
188
+ async function openInClaude (dir, opts) {
189
+ const runFn = opts.run || run
190
+ const copyFn = opts.copy || copyToClipboard
191
+ const [file, args] = openCommand('claude', dir, opts) // checks it's installed; the folder link is the fallback
192
+ const cli = claudeCli(opts)
193
+ // The folder link (with a prompt, filled into the new chat and on the clipboard too) — everything else falls back to this.
194
+ // That chat can lose its folder once it is sent, so it is only the fallback.
195
+ const fallback = async () => {
196
+ const copied = opts.prompt ? await copyFn(opts.prompt) : false
197
+ await runFn(file, args)
198
+ return copied
199
+ }
200
+ // A chat made in the folder keeps it. The app can't fill a prompt into one it opens this way,
201
+ // so a merge's prompt goes on the clipboard: Quilt never runs an AI itself.
202
+ if (cli) {
203
+ const id = crypto.randomUUID()
204
+ try {
205
+ await runFn(...claudeSessionCommand(cli, dir, id))
206
+ const copied = opts.prompt ? await copyFn(opts.prompt) : false
207
+ await runFn(...resumeLink(id, opts.platform))
208
+ return { copied }
209
+ } catch {} // fall back to the folder link
210
+ }
211
+ const copied = await fallback()
212
+ return opts.prompt ? { copied, filled: true } : { copied }
213
+ }
214
+
215
+ export async function openIn (id, dir, opts = {}) {
216
+ dir = pathsOf(opts.platform || process.platform).resolve(dir) // in `platform`'s style, as locate() reads it
217
+ try {
218
+ if (id === 'claude') return await openInClaude(dir, opts)
219
+ const runFn = opts.run || run
220
+ const copyFn = opts.copy || copyToClipboard
221
+ const command = openCommand(id, dir, opts) // throws for an unknown or missing app: the clipboard is left alone
222
+ const copied = opts.prompt ? await copyFn(opts.prompt) : false
223
+ await runFn(...command)
224
+ return { copied }
225
+ } catch (err) {
226
+ throw new Error(`Could not open it: ${err.message}`)
227
+ }
228
+ }
package/src/fsutil.js ADDED
@@ -0,0 +1,87 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import crypto from 'node:crypto'
4
+ import { makeIgnore, isIgnored, scopeIgnore, IGNORE_FILES } from './pathrules.js'
5
+
6
+ export { IGNORE_FILES } from './pathrules.js'
7
+ export { MAX_TEXT_BYTES, MAX_BINARY_BYTES, LARGE_FILE_BYTES, MAX_STORED_BINARY_BYTES, isIgnored, isSafeRelPath, globMatcher, patternsOverlap } from './pathrules.js'
8
+
9
+ export function toPosix (p) {
10
+ return p.split(path.sep).join('/')
11
+ }
12
+
13
+ /**
14
+ * Builds the ignore matcher from built-ins and every .gitignore / .quiltignore
15
+ * in the project, each applying to its own folder. Folders already ignored
16
+ * aren't searched, so a .gitignore inside node_modules has no say.
17
+ */
18
+ export function loadIgnore (root) {
19
+ const ig = makeIgnore()
20
+ const visit = (dirRel) => {
21
+ const dir = path.join(root, dirRel)
22
+ for (const file of IGNORE_FILES) {
23
+ try { ig.add(scopeIgnore(fs.readFileSync(path.join(dir, file), 'utf8'), dirRel)) } catch {}
24
+ }
25
+ let entries
26
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return }
27
+ for (const e of entries) {
28
+ if (!e.isDirectory()) continue
29
+ const rel = dirRel ? `${dirRel}/${e.name}` : e.name
30
+ if (!isIgnored(ig, rel)) visit(rel)
31
+ }
32
+ }
33
+ visit('')
34
+ return ig
35
+ }
36
+
37
+ /**
38
+ * Resolves a relative path to an absolute one inside root, making sure no
39
+ * symlinked parent directory points outside of it (or nowhere: a dangling
40
+ * link could later lead anywhere). Throws when it does; the path is then
41
+ * neither read nor written.
42
+ */
43
+ export function resolveInside (root, rel) {
44
+ const parts = rel.split('/')
45
+ const abs = path.join(root, ...parts)
46
+ const realRoot = fs.realpathSync(root)
47
+ const refuse = () => { throw new Error(`refusing to touch a path outside the project: ${rel}`) }
48
+ let dir = root
49
+ for (const part of parts.slice(0, -1)) {
50
+ dir = path.join(dir, part)
51
+ let st
52
+ try { st = fs.lstatSync(dir) } catch { break } // nothing deeper exists yet
53
+ if (!st.isSymbolicLink()) continue
54
+ let real
55
+ try { real = fs.realpathSync(dir) } catch { refuse() }
56
+ if (real !== realRoot && !real.startsWith(realRoot + path.sep)) refuse()
57
+ }
58
+ return abs
59
+ }
60
+
61
+ export function looksBinary (buf) {
62
+ const n = Math.min(buf.length, 8000)
63
+ for (let i = 0; i < n; i++) if (buf[i] === 0) return true
64
+ // Invalid UTF-8 is treated as binary so it round-trips byte for byte.
65
+ return !Buffer.from(buf.toString('utf8'), 'utf8').equals(buf)
66
+ }
67
+
68
+ export function sha1 (buf) {
69
+ return crypto.createHash('sha1').update(buf).digest('hex')
70
+ }
71
+
72
+ /** Recursively lists syncable files as posix relative paths. */
73
+ export function walk (root, ig) {
74
+ const out = []
75
+ const visit = (dirRel) => {
76
+ let entries
77
+ try { entries = fs.readdirSync(path.join(root, dirRel), { withFileTypes: true }) } catch { return }
78
+ for (const e of entries) {
79
+ const rel = dirRel ? `${dirRel}/${e.name}` : e.name
80
+ if (isIgnored(ig, rel)) continue
81
+ if (e.isDirectory()) visit(rel)
82
+ else if (e.isFile()) out.push(rel)
83
+ }
84
+ }
85
+ visit('')
86
+ return out
87
+ }