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/merge3.js ADDED
@@ -0,0 +1,158 @@
1
+ // Three-way merge of text, line by line, the way git does it: changes to
2
+ // different lines combine on their own; the same lines changed on both sides
3
+ // are a conflict. Quilt never uses it to combine two different changes on its
4
+ // own (a person settles those): it writes the markers for Edit by hand, and
5
+ // lays out a settled clash with the commits around it (relay-upstream.js).
6
+ import { diff3Merge } from 'node-diff3'
7
+
8
+ export const MARK = { start: '<<<<<<< mine', mid: '=======', end: '>>>>>>> session' }
9
+
10
+ const lines = (s) => s.split('\n')
11
+
12
+ /**
13
+ * Merges ours and theirs against base. `conflicts` is empty when the merge
14
+ * is clean; `text` then holds the merged file. With conflicts, `text` holds
15
+ * the merge with ours taken for each conflicted region (callers decide what
16
+ * to do with it; see withMarkers).
17
+ */
18
+ export function merge3 (base, ours, theirs) {
19
+ const regions = diff3Merge(lines(ours), lines(base), lines(theirs), { excludeFalseConflicts: true })
20
+ const out = []
21
+ const conflicts = []
22
+ for (const r of regions) {
23
+ if (r.ok) { out.push(...r.ok); continue }
24
+ const c = r.conflict
25
+ conflicts.push({ base: c.o, ours: c.a, theirs: c.b })
26
+ out.push(...c.a)
27
+ }
28
+ return { text: out.join('\n'), conflicts }
29
+ }
30
+
31
+ /** The merge written out with git-style markers around each conflict, labelled with people's names. */
32
+ export function withMarkers (base, ours, theirs, names) {
33
+ const regions = diff3Merge(lines(ours), lines(base), lines(theirs), { excludeFalseConflicts: true })
34
+ const out = []
35
+ for (const r of regions) {
36
+ if (r.ok) { out.push(...r.ok); continue }
37
+ out.push(`${MARK.start} (${names.mine})`, ...r.conflict.a, MARK.mid, ...r.conflict.b, `${MARK.end} (${names.theirs})`)
38
+ }
39
+ return out.join('\n')
40
+ }
41
+
42
+ /** True when the text still holds markers that withMarkers put there. */
43
+ export function hasMarkers (text) {
44
+ return typeof text === 'string' && text.split('\n').some((l) => l.startsWith(`${MARK.start} (`) || l.startsWith(`${MARK.end} (`))
45
+ }
46
+
47
+ /**
48
+ * merge3, plus the clashes where neither side lost anything, resolved the way a person would:
49
+ * - both sides only added lines in the same place (base had nothing there): ours, then theirs,
50
+ * with lines both start (or end) with written once, so two new sections under the same
51
+ * heading become one heading with both sections' lines;
52
+ * - both sides changed the same one-line `import { … } from '…'` of the same module: one import
53
+ * with every name either side has (a name either side removed stays removed).
54
+ * Anything else stays a conflict. Used where commits from elsewhere come in (upstream.js).
55
+ */
56
+ export function mergeAdditive (base, ours, theirs) {
57
+ const regions = diff3Merge(lines(ours), lines(base), lines(theirs), { excludeFalseConflicts: true })
58
+ const out = []
59
+ const conflicts = []
60
+ for (const r of regions) {
61
+ if (r.ok) { out.push(...r.ok); continue }
62
+ const c = r.conflict
63
+ const both = c.o.length === 0 ? addedBoth(c.a, c.b) : sameImport(c.o, c.a, c.b)
64
+ if (both) { out.push(...both); continue }
65
+ conflicts.push({ base: c.o, ours: c.a, theirs: c.b })
66
+ out.push(...c.a)
67
+ }
68
+ return { text: out.join('\n'), conflicts }
69
+ }
70
+
71
+ /** Two blocks inserted at one place, as one: shared leading and trailing lines once, the middles in order. */
72
+ function addedBoth (a, b) {
73
+ let pre = 0
74
+ while (pre < a.length && pre < b.length && a[pre] === b[pre]) pre++
75
+ let post = 0
76
+ while (post < a.length - pre && post < b.length - pre && a[a.length - 1 - post] === b[b.length - 1 - post]) post++
77
+ return [...a.slice(0, pre), ...a.slice(pre, a.length - post), ...b.slice(pre, b.length - post), ...a.slice(a.length - post)]
78
+ }
79
+
80
+ const IMPORT = /^(\s*import\s*\{)([^}]*)(\}\s*from\s*(['"][^'"]+['"]).*)$/
81
+
82
+ /** Both sides changed the same one-line import of the same module: the union of its names, or null. */
83
+ function sameImport (o, a, b) {
84
+ if (o.length !== 1 || a.length !== 1 || b.length !== 1) return null
85
+ const [mo, ma, mb] = [IMPORT.exec(o[0]), IMPORT.exec(a[0]), IMPORT.exec(b[0])]
86
+ if (!mo || !ma || !mb || mo[4] !== ma[4] || ma[4] !== mb[4]) return null
87
+ const names = (m) => m[2].split(',').map((s) => s.trim()).filter(Boolean)
88
+ const [no, na, nb] = [names(mo), names(ma), names(mb)]
89
+ const gone = new Set(no.filter((n) => !na.includes(n) || !nb.includes(n)))
90
+ const all = [...na, ...nb.filter((n) => !na.includes(n))].filter((n) => !gone.has(n))
91
+ return [`${ma[1]} ${all.join(', ')} ${ma[3]}`]
92
+ }
93
+
94
+ const normWs = (l) => l.replace(/\s+/g, ' ').trim()
95
+ const sameIgnoringWs = (a, b) => a.length === b.length && a.every((l, i) => normWs(l) === normWs(b[i]))
96
+ const TOKEN = /\s+|\w+|[^\w\s]/g
97
+
98
+ /** Two blocks of lines merged word by word against their base, or null when the same words changed on both sides. */
99
+ function mergeWords (o, a, b) {
100
+ const tok = (ls) => ls.join('\n').match(TOKEN) || []
101
+ const regions = diff3Merge(tok(a), tok(o), tok(b), { excludeFalseConflicts: true })
102
+ if (regions.some((r) => !r.ok)) return null
103
+ return regions.map((r) => r.ok.join('')).join('').split('\n')
104
+ }
105
+
106
+ /**
107
+ * The clash ladder's mechanical and structural steps (plan §7.3, steps 1 and 2): mergeAdditive, then
108
+ * each region still clashing is tried again ignoring whitespace (a side that only reindented loses
109
+ * to the side that changed something), then word by word within the clashing lines (two edits to
110
+ * different words of one line both land); a file marked merge=union takes both sides, ours first.
111
+ * Returns { text, conflicts, how } with `how` the step that settled it: 'lines', 'whitespace',
112
+ * 'words' or 'union'; `conflicts` lists what no step could settle (text then holds ours there).
113
+ */
114
+ export function mergeSmart (base, ours, theirs, { union = false } = {}) {
115
+ const regions = diff3Merge(lines(ours), lines(base), lines(theirs), { excludeFalseConflicts: true })
116
+ const out = []
117
+ const conflicts = []
118
+ let how = 'lines'
119
+ const rank = { lines: 0, whitespace: 1, words: 2, union: 3 }
120
+ const step = (h) => { if (rank[h] > rank[how]) how = h }
121
+ for (const r of regions) {
122
+ if (r.ok) { out.push(...r.ok); continue }
123
+ const c = r.conflict
124
+ if (union) { out.push(...c.a, ...c.b); step('union'); continue }
125
+ const both = c.o.length === 0 ? addedBoth(c.a, c.b) : sameImport(c.o, c.a, c.b)
126
+ if (both) { out.push(...both); continue }
127
+ if (sameIgnoringWs(c.a, c.b)) { out.push(...c.a); step('whitespace'); continue } // the same change, spaced differently
128
+ if (sameIgnoringWs(c.a, c.o)) { out.push(...c.b); step('whitespace'); continue } // ours only reindented
129
+ if (sameIgnoringWs(c.b, c.o)) { out.push(...c.a); step('whitespace'); continue } // theirs only reindented
130
+ const words = mergeWords(c.o, c.a, c.b)
131
+ if (words) { out.push(...words); step('words'); continue }
132
+ conflicts.push({ base: c.o, ours: c.a, theirs: c.b })
133
+ out.push(...c.a)
134
+ }
135
+ return { text: out.join('\n'), conflicts, how }
136
+ }
137
+
138
+ /** The paths a .gitattributes text marks merge=union, as a matcher: (rel) => boolean. */
139
+ export function unionMatcher (texts) {
140
+ const patterns = []
141
+ for (const [dir, text] of texts) {
142
+ for (const line of String(text || '').split('\n')) {
143
+ const t = line.trim()
144
+ if (!t || t.startsWith('#')) continue
145
+ const [pat, ...attrs] = t.split(/\s+/)
146
+ if (!attrs.includes('merge=union')) continue
147
+ patterns.push({ dir, pat })
148
+ }
149
+ }
150
+ if (!patterns.length) return () => false
151
+ return (rel) => patterns.some(({ dir, pat }) => {
152
+ if (dir && !rel.startsWith(`${dir}/`)) return false
153
+ const local = dir ? rel.slice(dir.length + 1) : rel
154
+ const name = local.includes('/') ? local.slice(local.lastIndexOf('/') + 1) : local
155
+ const re = new RegExp('^' + pat.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*\*/g, '.*').replace(/\*/g, '[^/]*').replace(/\?/g, '[^/]') + '$')
156
+ return pat.includes('/') ? re.test(local.replace(/^\//, '')) : re.test(name)
157
+ })
158
+ }
package/src/merges.js ADDED
@@ -0,0 +1,148 @@
1
+ // Merge records: a file whose offline and in-session edits could not be
2
+ // combined on their own. Kept as plain objects in the shared doc so everyone
3
+ // in the session (and every AI) sees the same list and anyone can settle it.
4
+ import crypto from 'node:crypto'
5
+ import { isSafeRelPath } from './pathrules.js'
6
+
7
+ export const MAX_RECORD_TEXT = 200_000
8
+ export const MAX_MERGES = 50
9
+ export const DONE_TTL_MS = 24 * 60 * 60 * 1000
10
+ export const KINDS = ['conflict', 'ai', 'claimed']
11
+ export const STATES = ['open', 'editing', 'done']
12
+ export const HOWS = ['mine', 'theirs', 'hand', 'agent', 'review']
13
+ // How the opener's side came about: pulled commits, an edit during a git command, or (null) offline.
14
+ export const VIAS = ['pull', 'hold']
15
+
16
+ /** What the opener did to the file, for agents and status text ("changed it offline"…). */
17
+ export function mergeAction (m, deleted = !!m.oursDeleted) {
18
+ if (m.via === 'pull') return deleted ? 'pulled commits that delete it' : 'pulled commits that change it'
19
+ if (m.via === 'hold') return deleted ? 'deleted it during a git command' : 'changed it during a git command'
20
+ return deleted ? 'deleted it offline' : 'changed it offline'
21
+ }
22
+
23
+ const HEX_ID = /^[0-9a-f]{16}$/i
24
+ // eslint-disable-next-line no-control-regex
25
+ const CONTROL = /[\u0000-\u001f\u007f]/
26
+ // eslint-disable-next-line no-control-regex
27
+ const CONTROL_RUN = /[\u0000-\u001f\u007f]+/g
28
+ const str = (v, max) => typeof v === 'string' && v.length <= max
29
+ const optStr = (v, max) => v == null || str(v, max)
30
+ // Names, paths and reasons go verbatim into logs and into the prompt a coding
31
+ // tool gets for Send to…: one line each, no control characters.
32
+ const line = (v, max) => str(v, max) && !CONTROL.test(v)
33
+ const optLine = (v, max) => v == null || line(v, max)
34
+
35
+ /**
36
+ * A peer-written name (from an activity entry or a claim), made safe to put
37
+ * in a merge record: control characters and newlines become a space,
38
+ * whitespace is collapsed, and it's trimmed and capped at 80 characters.
39
+ * Falls back to 'someone' when that leaves nothing, so a stray control
40
+ * character in a name never suppresses the merge record itself.
41
+ */
42
+ export function cleanName (name) {
43
+ const flat = String(name == null ? '' : name).replace(CONTROL_RUN, ' ').replace(/\s+/g, ' ').trim().slice(0, 80)
44
+ return flat || 'someone'
45
+ }
46
+
47
+ /** A record we'll show. Anything a modified client pushed that isn't this shape is ignored. */
48
+ export function publicMerge (v) {
49
+ if (!v || typeof v !== 'object') return null
50
+ if (typeof v.id !== 'string' || !HEX_ID.test(v.id)) return null
51
+ // A path any member could be made to write: inside the project, never .git or .quilt.
52
+ if (!line(v.path, 1024) || !isSafeRelPath(v.path)) return null
53
+ if (!line(v.by, 80) || !optStr(v.byId, 128)) return null
54
+ if (!Array.isArray(v.others) || v.others.length > 20 || !v.others.every((n) => line(n, 80))) return null
55
+ if (typeof v.ts !== 'number' || !Number.isFinite(v.ts)) return null
56
+ if (!KINDS.includes(v.kind) || !STATES.includes(v.state)) return null
57
+ if (!optStr(v.ours, MAX_RECORD_TEXT) || !optStr(v.base, MAX_RECORD_TEXT) || !optStr(v.theirsHash, 64)) return null
58
+ if (!optLine(v.claimedBy, 80) || !optLine(v.resolvedBy, 80) || !optLine(v.reason, 500)) return null
59
+ if (v.how != null && !HOWS.includes(v.how)) return null
60
+ if (v.doneTs != null && typeof v.doneTs !== 'number') return null
61
+ // Deleted offline: ours is gone, not merely too big to share (records from before this field have none).
62
+ if (v.oursDeleted != null && typeof v.oursDeleted !== 'boolean') return null
63
+ if (v.oursDeleted && v.ours != null) return null
64
+ if (v.via != null && !VIAS.includes(v.via)) return null
65
+ return {
66
+ id: v.id, path: v.path, by: v.by, byId: v.byId ?? null, others: v.others, ts: v.ts,
67
+ kind: v.kind, state: v.state, ours: v.ours ?? null, base: v.base ?? null, theirsHash: v.theirsHash ?? null,
68
+ binary: !!v.binary, local: !!v.local, oursDeleted: !!v.oursDeleted, claimedBy: v.claimedBy ?? null, resolvedBy: v.resolvedBy ?? null,
69
+ how: v.how ?? null, doneTs: v.doneTs ?? null, reason: v.reason ?? null, via: v.via ?? null
70
+ }
71
+ }
72
+
73
+ const rank = (s) => (s === 'done' ? 1 : 0)
74
+ const order = (a, b) => rank(a.state) - rank(b.state) || a.ts - b.ts || (a.id < b.id ? -1 : 1)
75
+
76
+ function split (map) {
77
+ const valid = []
78
+ const junk = []
79
+ map.forEach((v, key) => {
80
+ const m = publicMerge(v)
81
+ if (m && m.id === key) valid.push(m)
82
+ else junk.push(key)
83
+ })
84
+ valid.sort(order)
85
+ return { valid, junk }
86
+ }
87
+
88
+ export function readMerges (map) {
89
+ return split(map).valid
90
+ }
91
+
92
+ /** Opens a record. Text beyond the cap stays on the opener's disk only (`local`); `oursDeleted`: the opener deleted the file. */
93
+ export function openMerge (doc, map, { path, by, byId = null, others = [], kind, ours = null, oursDeleted = false, base = null, theirsHash = null, binary = false, claimedBy = null, reason = null, via = null }, origin) {
94
+ if (!KINDS.includes(kind)) throw new Error('bad merge kind')
95
+ const fits = (t) => t == null || t.length <= MAX_RECORD_TEXT
96
+ const local = !fits(ours) || !fits(base)
97
+ const rec = {
98
+ id: crypto.randomBytes(8).toString('hex'),
99
+ path, by, byId, others: others.filter((n) => n && n !== by).slice(0, 20), ts: Date.now(),
100
+ kind, state: 'open',
101
+ ours: fits(ours) ? ours : null, base: fits(base) ? base : null, theirsHash, binary: !!binary, local, oursDeleted: !!oursDeleted,
102
+ claimedBy, resolvedBy: null, how: null, doneTs: null, reason, via
103
+ }
104
+ const out = publicMerge(rec)
105
+ if (!out) throw new Error('bad merge record') // never write what readers would drop as junk
106
+ doc.transact(() => {
107
+ for (const key of split(map).junk) map.delete(key)
108
+ map.set(rec.id, rec)
109
+ }, origin)
110
+ return out
111
+ }
112
+
113
+ /** Changes state, how, resolvedBy or reason. Setting state to done stamps doneTs. */
114
+ export function updateMerge (doc, map, id, patch, origin) {
115
+ const cur = publicMerge(map.get(id))
116
+ if (!cur) throw new Error('no such merge')
117
+ const next = { ...cur }
118
+ if (patch.state !== undefined) {
119
+ if (!STATES.includes(patch.state)) throw new Error('bad merge state')
120
+ next.state = patch.state
121
+ if (patch.state === 'done') next.doneTs = Date.now()
122
+ }
123
+ if (patch.how !== undefined) {
124
+ if (patch.how != null && !HOWS.includes(patch.how)) throw new Error('bad merge how')
125
+ next.how = patch.how
126
+ }
127
+ if (patch.resolvedBy !== undefined) next.resolvedBy = patch.resolvedBy
128
+ if (patch.reason !== undefined) next.reason = patch.reason
129
+ const out = publicMerge(next)
130
+ if (!out) throw new Error('bad merge record')
131
+ doc.transact(() => map.set(id, next), origin)
132
+ return out
133
+ }
134
+
135
+ /** Drops done records older than a day, and the oldest done ones beyond MAX_MERGES. Open ones are kept. */
136
+ export function pruneMerges (doc, map, origin, now = Date.now()) {
137
+ const { valid, junk } = split(map)
138
+ const drop = new Set(junk)
139
+ const done = valid.filter((m) => m.state === 'done').sort((a, b) => (a.doneTs || 0) - (b.doneTs || 0))
140
+ for (const m of done) if (m.doneTs && now - m.doneTs > DONE_TTL_MS) drop.add(m.id)
141
+ let count = valid.length - drop.size
142
+ for (const m of done) {
143
+ if (count <= MAX_MERGES) break
144
+ if (!drop.has(m.id)) { drop.add(m.id); count-- }
145
+ }
146
+ if (!drop.size) return
147
+ doc.transact(() => { for (const id of drop) map.delete(id) }, origin)
148
+ }
@@ -0,0 +1,176 @@
1
+ // Session passes from the accounts API. A pass proves who you are to the relay
2
+ // for 10 minutes. Clients keep one until 2 minutes before it runs out, and while
3
+ // connected they fetch a fresh one every 5 minutes (see connection.js). A session
4
+ // asks for passes for its own room: those carry what you may do there.
5
+ import { readPass } from './passes.js'
6
+ import { apiUrl, accountFile, readAccount, resumeAccount, NOT_SIGNED_IN, SIGNED_OUT } from './account.js'
7
+ import { agentAccess, agentResume, readAgent } from './agent-join.js'
8
+
9
+ export const PASS_EARLY_MS = 2 * 60 * 1000
10
+ export const PASS_REFRESH_MS = 5 * 60 * 1000
11
+ // A pass request with no answer gives up after this, so the retry runs: right after a computer
12
+ // wakes, the network may be gone for a while and the system's own timeout can take minutes.
13
+ // Passes are cheap to ask for again, unlike an agent's key refresh.
14
+ export const PASS_TIMEOUT_MS = 10 * 1000
15
+ const AGENT_SIGNED_OUT = "This agent's keys stopped working. Invite it again."
16
+
17
+ /** The API turned the token away: this computer (or agent) is signed out for good. */
18
+ export class SignedOutError extends Error {
19
+ constructor (message) {
20
+ super(message)
21
+ this.signedOut = true
22
+ }
23
+ }
24
+
25
+ export class PassSource {
26
+ /** `fetchPass(room)` resolves to { pass, expiresAt }; `room` is '' for a pass that isn't for one room. */
27
+ constructor ({ fetchPass, now = Date.now, earlyMs = PASS_EARLY_MS, room = '' }) {
28
+ this.fetchPass = fetchPass
29
+ this.now = now
30
+ this.earlyMs = earlyMs
31
+ this.room = room
32
+ this.rooms = new Map() // room -> PassSource, for forRoom
33
+ this.current = null // { pass, expiresAt, payload }
34
+ this.pending = null
35
+ }
36
+
37
+ /** The same account's passes for one room (kept, one per room). They carry its access there. */
38
+ forRoom (room) {
39
+ if (!room || room === this.room) return this
40
+ if (!this.rooms.has(room)) this.rooms.set(room, new PassSource({ fetchPass: this.fetchPass, now: this.now, earlyMs: this.earlyMs, room }))
41
+ return this.rooms.get(room)
42
+ }
43
+
44
+ /** A pass with at least `earlyMs` left, fetching one when needed. */
45
+ get () {
46
+ if (this.current && this.now() < this.current.expiresAt - this.earlyMs) return Promise.resolve(this.current.pass)
47
+ return this.fresh()
48
+ }
49
+
50
+ /** Always asks for a new pass. Calls made while one is on its way share it. */
51
+ fresh () {
52
+ if (!this.pending) {
53
+ this.pending = Promise.resolve()
54
+ .then(() => this.fetchPass(this.room))
55
+ .then(({ pass, expiresAt }) => {
56
+ this.current = { pass, expiresAt, payload: readPass(pass) }
57
+ return pass
58
+ })
59
+ .finally(() => { this.pending = null })
60
+ }
61
+ return this.pending
62
+ }
63
+
64
+ /**
65
+ * A pass issued after this call. One already on its way may have been issued before
66
+ * something changed (the relay asking for a fresh pass after the owner changed our
67
+ * access): wait for it, then ask again.
68
+ */
69
+ newer () {
70
+ return this.pending ? this.pending.catch(() => {}).then(() => this.fresh()) : this.fresh()
71
+ }
72
+
73
+ /** Forgets the cached pass (the relay turned it away), so the next get() fetches one. */
74
+ forget () {
75
+ this.current = null
76
+ }
77
+
78
+ /** Who the passes are for ({ sub, kind, name, key, … }), once one has been fetched. */
79
+ get payload () {
80
+ return this.current ? this.current.payload : null
81
+ }
82
+ }
83
+
84
+ async function requestPass (fetchImpl, api, bearer, signedOutMessage, room = '', timeoutMs = PASS_TIMEOUT_MS) {
85
+ let res
86
+ try {
87
+ const body = room ? { headers: { authorization: `Bearer ${bearer}`, 'content-type': 'application/json' }, body: JSON.stringify({ room }) } : { headers: { authorization: `Bearer ${bearer}` } }
88
+ res = await fetchImpl(`${String(api).replace(/\/+$/, '')}/v1/passes`, { method: 'POST', ...body, signal: AbortSignal.timeout(timeoutMs) })
89
+ } catch (err) {
90
+ throw new Error(`Couldn't reach Quilt (${err.name === 'TimeoutError' ? 'ETIMEDOUT' : err.cause?.code || err.message}).`)
91
+ }
92
+ const body = await res.json().catch(() => null)
93
+ if (res.status === 401) throw new SignedOutError(signedOutMessage)
94
+ if (!res.ok || !body || typeof body.pass !== 'string') throw Object.assign(new Error(body?.error || `Quilt answered ${res.status}.`), { status: res.status })
95
+ return body
96
+ }
97
+
98
+ /**
99
+ * Passes for this computer's account, from its qd_ token. A token turned away isn't the
100
+ * end: another app on this computer may have signed in again since (its token is in
101
+ * account.json), or the computer signs back in with its key. Only a computer that's no
102
+ * longer linked gets SignedOutError.
103
+ */
104
+ export function personPasses ({ token, api = apiUrl(), fetch: fetchImpl = globalThis.fetch, now, file = accountFile(), resume = resumeAccount, timeoutMs } = {}) {
105
+ let current = token
106
+ // Whose sign-in this is: a sign-in for someone else since (a different account) doesn't carry on these passes.
107
+ const first = readAccount(file)
108
+ const who = first && first.token === token ? first.account.id : null
109
+ const same = (a) => a && (!who || a.account.id === who)
110
+ return new PassSource({
111
+ now,
112
+ fetchPass: async (room) => {
113
+ try {
114
+ return await requestPass(fetchImpl, api, current, SIGNED_OUT, room, timeoutMs)
115
+ } catch (err) {
116
+ if (!err.signedOut) throw err
117
+ const saved = readAccount(file)
118
+ if (saved && saved.token !== current) {
119
+ if (!same(saved)) throw err
120
+ current = saved.token
121
+ } else {
122
+ const back = await resume({ api, fetch: fetchImpl, file })
123
+ if (!same(back)) throw err
124
+ current = back.token
125
+ }
126
+ return requestPass(fetchImpl, api, current, SIGNED_OUT, room, timeoutMs)
127
+ }
128
+ }
129
+ })
130
+ }
131
+
132
+ /** Passes for a saved agent, from its access key (refreshed with its refresh key when it runs out). */
133
+ export function agentPasses ({ name, dir, fetch: fetchImpl = globalThis.fetch, now } = {}) {
134
+ return new PassSource({
135
+ now,
136
+ fetchPass: async (room) => {
137
+ let saved
138
+ try {
139
+ saved = await agentAccess({ name, dir, fetch: fetchImpl, now })
140
+ } catch (err) {
141
+ if (err.status === 401) throw new SignedOutError(err.message)
142
+ throw err
143
+ }
144
+ try {
145
+ return await requestPass(fetchImpl, saved.api, saved.accessKey, AGENT_SIGNED_OUT, room)
146
+ } catch (err) {
147
+ if (!err.signedOut) throw err
148
+ // Keys revoked before the access key ran out: the agent signs back in with its resume key.
149
+ let back
150
+ try {
151
+ back = await agentResume({ name, dir, accessKey: saved.accessKey, fetch: fetchImpl })
152
+ } catch (e) {
153
+ if (e.status === 401) throw new SignedOutError(e.message)
154
+ throw e
155
+ }
156
+ if (!back) throw err
157
+ return requestPass(fetchImpl, back.api, back.accessKey, AGENT_SIGNED_OUT, room)
158
+ }
159
+ }
160
+ })
161
+ }
162
+
163
+ /**
164
+ * What a session started here signs in with: a saved agent's passes and key, or
165
+ * this computer's account. `name` is the name saved on this computer, so a
166
+ * session can start before (or without, when offline) its first pass.
167
+ */
168
+ export function sessionPasses ({ agent = null, dir } = {}) {
169
+ if (agent) {
170
+ const saved = readAgent({ name: agent, dir })
171
+ return { passes: agentPasses({ name: agent, dir }), identity: saved.identity, kind: 'agent', name: saved.name || agent }
172
+ }
173
+ const account = readAccount()
174
+ if (!account) throw new Error(NOT_SIGNED_IN)
175
+ return { passes: personPasses({ token: account.token }), identity: null, kind: 'human', name: account.account.name || null }
176
+ }
package/src/passes.js ADDED
@@ -0,0 +1,64 @@
1
+ // Session passes: short-lived tickets the accounts API signs and the relay checks.
2
+ // A pass says who you are (an account or an agent), your name, and the identity
3
+ // key you'll prove when you connect (empty for a hosted agent, which only ever
4
+ // talks to the relay over HTTPS and never proves a key). Its format is
5
+ // base64url(JSON payload) + "." + base64url(Ed25519 signature of the first part).
6
+ import crypto from 'node:crypto'
7
+ import { generateIdentity, parsePublicKey } from './identity.js'
8
+
9
+ export const PASS_VERSION = 1
10
+ export const PASS_TTL_MS = 10 * 60 * 1000
11
+ const MAX_NAME = 64
12
+ const KINDS = ['person', 'agent']
13
+ const ROOM = /^[A-Za-z0-9_-]{1,64}$/
14
+
15
+ /** A new signing pair, encoded like identity keys: SPKI and PKCS8 DER, in base64url. */
16
+ export function newPassKeys () {
17
+ return generateIdentity()
18
+ }
19
+
20
+ const privateKeyOf = (k) => typeof k === 'string' ? crypto.createPrivateKey({ key: Buffer.from(k, 'base64url'), format: 'der', type: 'pkcs8' }) : k
21
+ const publicKeyOf = (k) => typeof k === 'string' ? parsePublicKey(k) : k
22
+
23
+ /** The public half of a PASS_SIGNING_KEY, as QUILT_PASS_PUBLIC_KEY takes it. */
24
+ export function passPublicKey (signingKey) {
25
+ return crypto.createPublicKey(privateKeyOf(signingKey)).export({ type: 'spki', format: 'der' }).toString('base64url')
26
+ }
27
+
28
+ export function signPass (payload, signingKey) {
29
+ const body = Buffer.from(JSON.stringify(payload)).toString('base64url')
30
+ const sig = crypto.sign(null, Buffer.from(body), privateKeyOf(signingKey)).toString('base64url')
31
+ return `${body}.${sig}`
32
+ }
33
+
34
+ /** A pass's payload without checking it (clients read their name from it). Null if malformed. */
35
+ export function readPass (pass) {
36
+ const [body, sig, extra] = String(pass ?? '').split('.')
37
+ if (!body || !sig || extra !== undefined) return null
38
+ try {
39
+ const p = JSON.parse(Buffer.from(body, 'base64url').toString('utf8'))
40
+ return p && typeof p === 'object' && !Array.isArray(p) ? p : null
41
+ } catch { return null }
42
+ }
43
+
44
+ /**
45
+ * The payload when the signature verifies, it's version 1, it hasn't expired and
46
+ * its fields make sense; otherwise null.
47
+ */
48
+ export function verifyPass (pass, key, { now = Date.now() } = {}) {
49
+ const k = publicKeyOf(key)
50
+ const p = readPass(pass)
51
+ if (!k || !p) return null
52
+ const [body, sig] = String(pass).split('.')
53
+ let signed = false
54
+ try { signed = crypto.verify(null, Buffer.from(body), k, Buffer.from(sig, 'base64url')) } catch {}
55
+ if (!signed || p.v !== PASS_VERSION) return null
56
+ if (typeof p.exp !== 'number' || p.exp <= now) return null
57
+ if (typeof p.sub !== 'string' || !p.sub || !KINDS.includes(p.kind)) return null
58
+ if (typeof p.name !== 'string' || !p.name.trim() || p.name.length > MAX_NAME) return null
59
+ // A room pass is good in that room only (see access in session-access.js).
60
+ if (p.room !== undefined && (typeof p.room !== 'string' || !ROOM.test(p.room))) return null
61
+ // No key: an HTTP-only pass (a hosted agent, see relay-mcp.js); it can't open a WebSocket.
62
+ if (typeof p.key !== 'string' || (p.key && !parsePublicKey(p.key))) return null
63
+ return p
64
+ }
@@ -0,0 +1,127 @@
1
+ // Which paths are synced, and which are safe to accept from a peer. No Node
2
+ // APIs here: the web app uses the same rules as the CLI.
3
+ import ignore from 'ignore'
4
+
5
+ export const MAX_TEXT_BYTES = 2 * 1024 * 1024
6
+ export const MAX_BINARY_BYTES = 8 * 1024 * 1024
7
+ // Binary files this big or bigger are stored encrypted outside the session document.
8
+ export const LARGE_FILE_BYTES = 256 * 1024
9
+ // The most a stored file may be (the relay may allow less).
10
+ export const MAX_STORED_BINARY_BYTES = 100 * 1024 * 1024
11
+
12
+ // Never synced, regardless of .gitignore. .env files are excluded so secrets
13
+ // stay on each person's machine; Claude Code worktrees are whole private
14
+ // copies of the project.
15
+ export const ALWAYS_IGNORED = [
16
+ '.git', '.quilt', '.cowove', 'node_modules', '.DS_Store', 'Thumbs.db', '.claude/worktrees',
17
+ // Each person's own Claude Code settings (permissions, Quilt's hooks) stay on their machine.
18
+ '.claude/settings.local.json',
19
+ // Build output and caches: large, machine-specific, and rebuilt by each person.
20
+ '.next', '.turbo', '.nuxt', '.svelte-kit', '.parcel-cache', '.vercel',
21
+ '.env', '.env.*', '!.env.example',
22
+ // A .quiltignore is each person's own list of what stays on their machine, so it stays there too.
23
+ '.quiltignore', '.cowoveignore',
24
+ '*.swp', '*.swo', '*~', '.#*'
25
+ ]
26
+
27
+ /** Files whose patterns say what isn't synced, in the folder they sit in. */
28
+ export const IGNORE_FILES = ['.gitignore', '.quiltignore', '.cowoveignore']
29
+
30
+ /**
31
+ * Rewrites an ignore file found in subfolder `dir` so its patterns apply from
32
+ * the project root, the way git scopes a nested .gitignore to its folder.
33
+ */
34
+ export function scopeIgnore (text, dir) {
35
+ if (!dir) return text
36
+ return text.split(/\r?\n/).map((line) => {
37
+ if (!line.trim() || line.startsWith('#')) return line
38
+ const neg = line.startsWith('!')
39
+ const body = neg ? line.slice(1) : line
40
+ // A slash anywhere but the end anchors the pattern to this folder.
41
+ const anchored = body.replace(/\/+\s*$/, '').includes('/')
42
+ const scoped = anchored ? `${dir}/${body.replace(/^\//, '')}` : `${dir}/**/${body}`
43
+ return (neg ? '!' : '') + scoped
44
+ }).join('\n')
45
+ }
46
+
47
+ /**
48
+ * An ignore matcher from the built-ins plus the text of .gitignore / .quiltignore
49
+ * files. The built-ins are kept apart from the project patterns, so a shared
50
+ * .gitignore saying "!.env" can't make anyone's secrets sync.
51
+ */
52
+ export function makeIgnore (texts = []) {
53
+ const builtin = ignore().add(ALWAYS_IGNORED)
54
+ const project = ignore()
55
+ for (const t of texts) if (t) project.add(t)
56
+ return {
57
+ add (text) { project.add(text); return this },
58
+ ignores (p) { return builtin.ignores(p) || project.ignores(p) }
59
+ }
60
+ }
61
+
62
+ export function isIgnored (ig, rel) {
63
+ if (!rel || rel === '.') return false
64
+ // Check every ancestor directory so "dist/" also excludes "dist/a/b.js".
65
+ const parts = rel.split('/')
66
+ for (let i = 1; i <= parts.length; i++) {
67
+ const sub = parts.slice(0, i).join('/')
68
+ if (ig.ignores(sub)) return true
69
+ if (i < parts.length && ig.ignores(sub + '/')) return true
70
+ }
71
+ return false
72
+ }
73
+
74
+ // Names Windows reserves for devices, with any extension: a member there could never write them.
75
+ const WINDOWS_DEVICE = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i
76
+ const utf8Length = (s) => new TextEncoder().encode(s).length
77
+
78
+ /**
79
+ * Validates a path received from a peer. Rejects anything that could escape
80
+ * the project folder or touch git internals, and names that some member's
81
+ * disk could not hold (a change nobody can write is a change nobody should
82
+ * share).
83
+ */
84
+ export function isSafeRelPath (rel) {
85
+ if (typeof rel !== 'string' || !rel || rel.length > 1024) return false
86
+ if (rel.includes('\\') || rel.includes('\0')) return false
87
+ if (rel.startsWith('/') || /^[A-Za-z]:/.test(rel)) return false
88
+ const parts = rel.split('/')
89
+ for (const part of parts) {
90
+ if (part === '' || part === '.' || part === '..') return false
91
+ if (WINDOWS_DEVICE.test(part) || part.endsWith('.') || part.endsWith(' ')) return false
92
+ if (part.length > 255 || utf8Length(part) > 255) return false
93
+ }
94
+ const first = parts[0].toLowerCase()
95
+ if (first === '.git' || first === '.quilt' || first === '.cowove') return false
96
+ return true
97
+ }
98
+
99
+ /** Whether two claim patterns can cover the same file (paths: files that exist now). */
100
+ export function patternsOverlap (a, b, paths = []) {
101
+ if (a === b) return true
102
+ const ma = globMatcher(a)
103
+ const mb = globMatcher(b)
104
+ return ma(b) || mb(a) || paths.some((p) => ma(p) && mb(p))
105
+ }
106
+
107
+ /** Converts a simple glob (*, **, ?) or a plain path/folder into a matcher. */
108
+ export function globMatcher (pattern) {
109
+ let p = pattern.trim().replace(/^\.\//, '')
110
+ if (!/[*?]/.test(p)) {
111
+ const base = p.replace(/\/+$/, '')
112
+ return (rel) => rel === base || rel.startsWith(base + '/')
113
+ }
114
+ let re = ''
115
+ for (let i = 0; i < p.length; i++) {
116
+ const c = p[i]
117
+ if (c === '*' && p[i + 1] === '*') {
118
+ re += '.*'
119
+ i++
120
+ if (p[i + 1] === '/') i++
121
+ } else if (c === '*') re += '[^/]*'
122
+ else if (c === '?') re += '[^/]'
123
+ else re += c.replace(/[.+^${}()|[\]\\]/g, '\\$&')
124
+ }
125
+ const rx = new RegExp(`^${re}$`)
126
+ return (rel) => rx.test(rel)
127
+ }