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
@@ -0,0 +1,235 @@
1
+ // The relay's branch documents: one Yjs document per branch a room's members
2
+ // work on, holding that branch's files (files, blobs, fileKeys) and what goes
3
+ // with them (merge records, the chronology, per-person tallies). The room's own
4
+ // document keeps everything room-wide. A branch document is loaded when someone
5
+ // joins it (or a hosted agent uses it), saved a second after each change, and
6
+ // unloaded once nobody has been on it for a while.
7
+ import fs from 'node:fs'
8
+ import path from 'node:path'
9
+ import * as Y from 'yjs'
10
+
11
+ // A folder without git syncs this key: the room's default branch (see Room.resolveKey).
12
+ export const DEFAULT_KEY = '∅'
13
+ export const MAX_BRANCH_KEY = 200
14
+ // Branch documents a room may have at once (an empty one still costs a meta entry, a
15
+ // broadcast to everyone on every create, and a loaded Y.Doc): refused in plain English past this.
16
+ export const MAX_BRANCHES = 50
17
+ export const BRANCH_IDLE_MS = 10 * 60 * 1000
18
+ // A branch nobody has been on this long leaves the session (never the default branch).
19
+ export const BRANCH_TTL_MS = 30 * 24 * 60 * 60 * 1000
20
+ // A detached checkout (key starting with '@') nobody has been on this long leaves the session:
21
+ // it has no branch name to come back to, so there's no reason to keep it around as long as a
22
+ // real branch.
23
+ export const DETACHED_TTL_MS = 24 * 60 * 60 * 1000
24
+ // What a room's document keeps for everyone, whatever branch they're on. The rest is a branch's
25
+ // (files, blobs, fileKeys; and merges, history, changes, which stay behind in whichever document
26
+ // they were already in, since this list is only what moves out into the room document).
27
+ export const ROOM_TYPES = [['chat', 'array'], ['agentFeed', 'array'], ['activity', 'array'], ['commitRequests', 'map'], ['tasks', 'map'], ['taskComments', 'map'], ['published', 'map']]
28
+
29
+ // eslint-disable-next-line no-control-regex
30
+ const NOT_IN_REFS = /[\u0000- \u007f~^:?*[\\]/
31
+
32
+ /**
33
+ * Whether `key` may name a branch document: a git branch name (the rules of
34
+ * `git check-ref-format --branch`, checked here since the relay has no git),
35
+ * a detached commit (@ and 12 hex digits), or ∅.
36
+ */
37
+ export function validBranchKey (key) {
38
+ if (typeof key !== 'string' || !key || key.length > MAX_BRANCH_KEY) return false
39
+ if (key === DEFAULT_KEY || /^@[0-9a-f]{12}$/.test(key)) return true
40
+ if (key === 'HEAD' || key === '@' || key.startsWith('-') || NOT_IN_REFS.test(key)) return false
41
+ if (key.includes('..') || key.includes('@{') || key.includes('//')) return false
42
+ if (key.startsWith('/') || key.endsWith('/') || key.endsWith('.')) return false
43
+ return key.split('/').every((part) => part && !part.startsWith('.') && !part.endsWith('.lock'))
44
+ }
45
+
46
+ /** A branch document's file name: the key in base64url (branch names have slashes, and ∅). */
47
+ export const branchFileName = (key) => `${Buffer.from(key, 'utf8').toString('base64url')}.ydoc`
48
+
49
+ /** Whether a document whose state vector is `have` holds everything one at `want` holds. */
50
+ export function covers (have, want) {
51
+ const h = Y.decodeStateVector(have)
52
+ for (const [client, clock] of Y.decodeStateVector(want)) if ((h.get(client) || 0) < clock) return false
53
+ return true
54
+ }
55
+
56
+ /**
57
+ * A room saved before branch documents kept everything in one document. Its
58
+ * room-wide parts are copied into a new room document (returned) and emptied
59
+ * in the old one, which stays as it is otherwise: it becomes the default
60
+ * branch's document, so every app's saved copy of it still matches.
61
+ */
62
+ export function splitLegacyDoc (legacy) {
63
+ const room = new Y.Doc()
64
+ room.transact(() => {
65
+ for (const [name, kind] of ROOM_TYPES) {
66
+ if (kind === 'array') room.getArray(name).push(legacy.getArray(name).toJSON())
67
+ else for (const [k, v] of Object.entries(legacy.getMap(name).toJSON())) room.getMap(name).set(k, v)
68
+ }
69
+ })
70
+ legacy.transact(() => {
71
+ for (const [name, kind] of ROOM_TYPES) {
72
+ if (kind === 'array') { const a = legacy.getArray(name); if (a.length) a.delete(0, a.length) } else {
73
+ const m = legacy.getMap(name)
74
+ for (const k of [...m.keys()]) m.delete(k)
75
+ }
76
+ }
77
+ })
78
+ return room
79
+ }
80
+
81
+ export class BranchStore {
82
+ /**
83
+ * @param {object} o
84
+ * @param {string|null} o.dir where this room's branch documents are saved (null: memory only)
85
+ * @param {number} [o.idleMs] a document nobody is on is unloaded this long after its last use
86
+ * @param {(entry: object) => void} o.onLoad wires a document just loaded (listeners, the guard)
87
+ * @param {(entry: object) => void} [o.onSave] after each save
88
+ * @param {(err: Error) => void} [o.onDiskError] a save failed
89
+ */
90
+ constructor ({ dir, idleMs = BRANCH_IDLE_MS, onLoad, onSave = () => {}, onDiskError = () => {} }) {
91
+ this.dir = dir || null
92
+ this.idleMs = idleMs
93
+ this.onLoad = onLoad
94
+ this.onSave = onSave
95
+ this.onDiskError = onDiskError
96
+ this.loaded = new Map() // key -> { key, doc, files, blobs, fileKeys, conns, bytes, saveTimer, unloadTimer }
97
+ this.sizes = new Map() // key -> bytes on disk, for the room's size limit
98
+ }
99
+
100
+ file (key) { return this.dir ? path.join(this.dir, branchFileName(key)) : null }
101
+
102
+ /** Whether branch `key` has a saved document. */
103
+ stored (key) { return !!this.dir && fs.existsSync(this.file(key)) }
104
+
105
+ /** The loaded entry for `key`, or null (never loads). */
106
+ get (key) { return this.loaded.get(key) || null }
107
+
108
+ /** Bytes branch `key` takes: its loaded size, or its file's. */
109
+ size (key) {
110
+ const e = this.loaded.get(key)
111
+ if (e) return e.bytes
112
+ if (!this.sizes.has(key)) {
113
+ let n = 0
114
+ try { n = fs.statSync(this.file(key)).size } catch {}
115
+ this.sizes.set(key, n)
116
+ }
117
+ return this.sizes.get(key)
118
+ }
119
+
120
+ /** Branch `key`'s entry, its document loaded from disk (or new and empty). Throws { unreadable } when its file can't be read. */
121
+ load (key) {
122
+ let e = this.loaded.get(key)
123
+ if (e) { this.touch(e); return e }
124
+ const doc = new Y.Doc()
125
+ let bytes = 0
126
+ if (this.stored(key)) {
127
+ try {
128
+ const buf = fs.readFileSync(this.file(key))
129
+ Y.applyUpdate(doc, buf)
130
+ bytes = buf.length
131
+ } catch (err) {
132
+ doc.destroy()
133
+ throw Object.assign(new Error(`could not read branch ${key}: ${err.message}`), { unreadable: true })
134
+ }
135
+ }
136
+ e = { key, doc, files: doc.getMap('files'), blobs: doc.getMap('blobs'), fileKeys: doc.getMap('fileKeys'), relayKeys: doc.getMap('relayKeys'), modes: doc.getMap('modes'), conns: new Set(), bytes, saveTimer: null, unloadTimer: null }
137
+ this.loaded.set(key, e)
138
+ this.onLoad(e)
139
+ this.touch(e)
140
+ return e
141
+ }
142
+
143
+ subscribe (ws, key) {
144
+ const e = this.load(key)
145
+ e.conns.add(ws)
146
+ this.touch(e)
147
+ return e
148
+ }
149
+
150
+ unsubscribe (ws, key) {
151
+ const e = this.loaded.get(key)
152
+ if (!e) return
153
+ e.conns.delete(ws)
154
+ this.touch(e)
155
+ }
156
+
157
+ /** Used just now: a document nobody is on is unloaded idleMs from now (only one that can be saved). */
158
+ touch (e) {
159
+ clearTimeout(e.unloadTimer)
160
+ e.unloadTimer = null
161
+ if (e.conns.size || !this.dir) return
162
+ e.unloadTimer = setTimeout(() => this.unload(e.key), this.idleMs)
163
+ e.unloadTimer.unref?.()
164
+ }
165
+
166
+ unload (key) {
167
+ const e = this.loaded.get(key)
168
+ if (!e || e.conns.size) return
169
+ if (this.save(e)) this.drop(e)
170
+ }
171
+
172
+ drop (e) {
173
+ clearTimeout(e.saveTimer)
174
+ clearTimeout(e.unloadTimer)
175
+ if (e.guard) e.guard.destroy()
176
+ e.doc.destroy()
177
+ this.loaded.delete(e.key)
178
+ }
179
+
180
+ scheduleSave (e) {
181
+ if (!this.dir || e.saveTimer) return
182
+ e.saveTimer = setTimeout(() => this.save(e), 1000)
183
+ }
184
+
185
+ /** Saves a loaded document now; false when the disk refused (it then stays loaded). */
186
+ save (e) {
187
+ clearTimeout(e.saveTimer)
188
+ e.saveTimer = null
189
+ if (!this.dir || this.frozen) return true // frozen: a crash injected in a test (relay-sync.js crashPoint)
190
+ const state = Y.encodeStateAsUpdate(e.doc)
191
+ if (!this.write(e.key, state)) return false
192
+ e.bytes = state.length
193
+ this.onSave(e)
194
+ return true
195
+ }
196
+
197
+ /** Writes a branch document's state whole, then renames it: a crash leaves the old file, never a cut-off one. */
198
+ write (key, state) {
199
+ try {
200
+ fs.mkdirSync(this.dir, { recursive: true })
201
+ const f = this.file(key)
202
+ fs.writeFileSync(f + '.tmp', state)
203
+ fs.renameSync(f + '.tmp', f)
204
+ this.sizes.set(key, state.length)
205
+ return true
206
+ } catch (err) {
207
+ this.onDiskError(err)
208
+ return false
209
+ }
210
+ }
211
+
212
+ /** Branch `from` is called `to` from now on (∅ taken by the session's first real branch). */
213
+ rename (from, to) {
214
+ const e = this.loaded.get(from)
215
+ if (e) { this.loaded.delete(from); e.key = to; this.loaded.set(to, e) }
216
+ if (this.stored(from)) {
217
+ try { fs.renameSync(this.file(from), this.file(to)) } catch (err) { this.onDiskError(err) }
218
+ }
219
+ if (this.sizes.has(from)) { this.sizes.set(to, this.sizes.get(from)); this.sizes.delete(from) }
220
+ }
221
+
222
+ /** Deletes branch `key`'s document, loaded or not. */
223
+ remove (key) {
224
+ const e = this.loaded.get(key)
225
+ if (e) this.drop(e)
226
+ if (this.dir) fs.rmSync(this.file(key), { force: true })
227
+ this.sizes.delete(key)
228
+ }
229
+
230
+ /** Saves and drops every loaded document (the room is leaving memory). */
231
+ destroy () { for (const e of [...this.loaded.values()]) { this.save(e); this.drop(e) } }
232
+
233
+ /** Drops every loaded document without saving (the room was ended and its data deleted). */
234
+ discard () { for (const e of [...this.loaded.values()]) this.drop(e) }
235
+ }
@@ -0,0 +1,194 @@
1
+ // The session's branches: which branch each member's folder is on, how it
2
+ // stands against its upstream, and every branch and worktree of the
3
+ // repository with who last worked on it. Each folder tells the room about its
4
+ // own git (Session.gitSummary, in presence); this folds those into one list.
5
+ // Pure: Session.status() and the hosted relay both call it.
6
+
7
+ import { RELAY_NAME, isRelay } from './ui/chat.js'
8
+
9
+ // Who the relay is in chat, the activity log, the chronology and the branch list: what it posts
10
+ // itself, the commits it brings in for a branch no folder is online on (relay-upstream.js), and
11
+ // the questions it answers (relay-ask.js).
12
+ export const RELAY_BY = RELAY_NAME
13
+
14
+ export { isRelay }
15
+
16
+ const str = (v, n) => typeof v === 'string' ? v.slice(0, n) : null
17
+ const num = (v) => Number.isFinite(v) && v >= 0 ? Math.floor(v) : 0
18
+
19
+ /** A member's git summary as it came over presence (anyone's to write), trimmed to its shape. */
20
+ export function cleanGit (g) {
21
+ if (!g || typeof g !== 'object') return null
22
+ const u = g.upstream && typeof g.upstream === 'object' ? g.upstream : null
23
+ const r = g.repo && typeof g.repo === 'object' ? g.repo : null
24
+ return {
25
+ branch: str(g.branch, 200),
26
+ key: str(g.key, 200),
27
+ on: str(g.on, 200),
28
+ held: str(g.held, 20),
29
+ upstream: u ? { name: str(u.name, 200), url: str(u.url, 500), behind: num(u.behind), ahead: num(u.ahead), diverged: !!u.diverged, conflicts: num(u.conflicts), waiting: str(u.waiting, 300), mergedBy: str(u.mergedBy, 120), clashTask: typeof u.clashTask === 'string' && /^[0-9a-f]{16}$/.test(u.clashTask) ? u.clashTask : null, ...(u.fetchOk === false ? { fetchOk: false, fetchError: str(u.fetchError, 200) || 'git fetch failed' } : {}) } : null,
30
+ repo: r ? {
31
+ worktrees: (Array.isArray(r.worktrees) ? r.worktrees : []).slice(0, 50).map((w) => ({ name: str(w && w.name, 200), branch: str(w && w.branch, 200) })),
32
+ branches: (Array.isArray(r.branches) ? r.branches : []).slice(0, 30).map((b) => ({
33
+ name: str(b && b.name, 200), upstream: str(b && b.upstream, 200), ahead: num(b && b.ahead), behind: num(b && b.behind),
34
+ author: str(b && b.author, 100), ts: num(b && b.ts), subject: str(b && b.subject, 200)
35
+ })).filter((b) => b.name)
36
+ } : null
37
+ }
38
+ }
39
+
40
+ /**
41
+ * One entry per branch: { name, folders: [{ name, held }] (members whose folder is on it),
42
+ * ais: [names] (AI sessions named after it), worktrees: [names], hosted: [names] (hosted agents
43
+ * working on it, from the room's branch list), upstream: { name, url,
44
+ * behind, ahead, diverged, conflicts, waiting } | null, last: { author, ts, subject } | null,
45
+ * session: true when the branch has a live document in this session (a copy, whether or not
46
+ * anyone is on it right now), default: true for the room's default branch, full: true when its
47
+ * document is over the session's size limit (it takes no new changes), relay: what the relay
48
+ * brought in itself while no folder was on it, and when it last looked (relayLine) | null }.
49
+ * Branches someone is on come first, then the rest by last commit. `members`: [{ name, git, persona? }].
50
+ * `sessionBranches`: the room's own branch list (Session.branchList / the relay's), [{ key, default, full }].
51
+ */
52
+ export function branchBoard (members, sessionBranches = []) {
53
+ const byName = new Map()
54
+ const entry = (name) => {
55
+ if (!byName.has(name)) byName.set(name, { name, folders: [], ais: [], worktrees: [], hosted: [], upstream: null, last: null, session: false, default: false, full: false, relay: null, onGitHub: false, merged: null })
56
+ return byName.get(name)
57
+ }
58
+ for (const m of members) {
59
+ const g = m.git
60
+ if (!g) continue
61
+ if (g.branch || g.key) {
62
+ const e = entry(g.on || g.key || g.branch)
63
+ e.folders.push({ name: m.name, held: g.held === 'switching' ? null : g.held, upstream: g.upstream })
64
+ // The folder that sees the most: one whose fetch works over one whose doesn't, then the least behind.
65
+ const better = (a, b) => !b || ((a.fetchOk === false) !== (b.fetchOk === false) ? b.fetchOk === false : a.behind < b.behind)
66
+ if (g.upstream && better(g.upstream, e.upstream)) e.upstream = g.upstream
67
+ }
68
+ if (!g.repo) continue
69
+ for (const b of g.repo.branches) {
70
+ const e = entry(b.name)
71
+ if (!e.last || b.ts > e.last.ts) e.last = { author: b.author, ts: b.ts, subject: b.subject }
72
+ if (!e.upstream && b.upstream) e.upstream = { name: b.upstream, url: null, behind: b.behind, ahead: b.ahead, diverged: b.ahead > 0 && b.behind > 0, conflicts: 0, waiting: null }
73
+ }
74
+ for (const w of g.repo.worktrees) if (w.branch && w.name !== '.' && !entry(w.branch).worktrees.includes(w.name)) entry(w.branch).worktrees.push(w.name)
75
+ }
76
+ // An AI session is named after its work, its git branch when it has one (persona.js).
77
+ for (const m of members) {
78
+ if (!m.persona) continue
79
+ const label = m.name.includes(' · ') ? m.name.slice(m.name.indexOf(' · ') + 3) : null
80
+ if (label && byName.has(label)) byName.get(label).ais.push(m.name)
81
+ }
82
+ // Every branch with a live copy in the session, whether or not anyone is on it right now
83
+ // (it still appears here), and which one is the room's default.
84
+ for (const b of (Array.isArray(sessionBranches) ? sessionBranches : [])) {
85
+ if (!b || typeof b.key !== 'string') continue
86
+ const e = entry(b.key)
87
+ if (b.onGitHub) { e.onGitHub = true; continue }
88
+ e.session = true
89
+ if (b.merged) e.merged = b.merged
90
+ if (b.default) e.default = true
91
+ if (b.full) e.full = true
92
+ if (b.relay && typeof b.relay === 'object') e.relay = b.relay
93
+ for (const n of (Array.isArray(b.hosted) ? b.hosted : [])) if (!e.hosted.includes(n)) e.hosted.push(n)
94
+ }
95
+ const active = (e) => e.folders.length + e.ais.length + e.worktrees.length + e.hosted.length
96
+ return [...byName.values()].sort((a, b) => (active(b) > 0) - (active(a) > 0) || (b.last?.ts || 0) - (a.last?.ts || 0) || (a.name < b.name ? -1 : 1))
97
+ }
98
+
99
+ const ago = (ts, now) => {
100
+ const s = Math.max(0, Math.round((now - ts) / 1000))
101
+ if (s < 90) return 'just now'
102
+ if (s < 5400) return `${Math.round(s / 60)}m ago`
103
+ if (s < 129600) return `${Math.round(s / 3600)}h ago`
104
+ return `${Math.round(s / 86400)}d ago`
105
+ }
106
+
107
+ /** One line on how a branch stands against its upstream, or ''. */
108
+ export function upstreamLine (u) {
109
+ if (!u || !u.name) return ''
110
+ // A fetch that failed: what git last knew is stale, so never "up to date".
111
+ if (u.fetchOk === false) return `can't fetch ${u.name}: ${u.fetchError || 'git fetch failed'}${u.behind ? ` (${u.behind} behind at the last fetch that worked)` : ''}`
112
+ // A clash is handed to one AI as a task (clash.js): who, when it is.
113
+ const by = u.mergedBy ? `; being merged by ${u.mergedBy}${u.clashTask ? ` (task ${u.clashTask})` : ''}` : ''
114
+ if (u.diverged) return `diverged from ${u.name} (${u.ahead} ahead, ${u.behind} behind)${by}`
115
+ const n = Array.isArray(u.conflicts) ? u.conflicts.length : u.conflicts
116
+ if (n) return `${u.behind} behind ${u.name}; ${n} file${n === 1 ? '' : 's'} clash with the session's work${by}`
117
+ if (u.waiting) return `${u.behind} behind ${u.name}; waiting: ${u.waiting}`
118
+ if (u.behind) return `${u.behind} behind ${u.name}`
119
+ if (u.ahead) return `${u.ahead} ahead of ${u.name}`
120
+ return `up to date with ${u.name}`
121
+ }
122
+
123
+ /**
124
+ * One line on what the relay does for a branch no folder is online on: brought in by the relay
125
+ * (when, how many commits), a clash it handed out, why it can't, and when it last looked; or ''.
126
+ */
127
+ export function relayLine (r, now = Date.now()) {
128
+ if (!r || !(r.checkedAt || (r.brought && r.brought.at))) return ''
129
+ const up = r.upstream || 'its upstream'
130
+ const bits = []
131
+ if (r.brought && r.brought.at) bits.push(`brought in by the relay ${ago(r.brought.at, now)} (${r.brought.count} commit${r.brought.count === 1 ? '' : 's'} from ${up}, ${r.brought.files} file${r.brought.files === 1 ? '' : 's'})`)
132
+ if (r.clashTask) bits.push(`the relay handed a clash with ${up} to task ${r.clashTask}`)
133
+ if (r.problem) bits.push(r.problem)
134
+ if (r.checkedAt) bits.push(`the relay last checked ${up} ${ago(r.checkedAt, now)}`)
135
+ return bits.join('; ')
136
+ }
137
+
138
+ /** The board as markdown for an AI (quilt_status, quilt_branches). `mine`: the caller's own branch, marked "(yours)". */
139
+ export function branchesMarkdown (board, { now = Date.now(), limit = 12, mine = null } = {}) {
140
+ if (!board.length) return '_No git branches: the folders in this session are not git repositories._'
141
+ const out = []
142
+ for (const b of board.slice(0, limit)) {
143
+ const who = [
144
+ ...b.folders.map((f) => `${f.name}'s folder${f.held ? ` (${f.held})` : ''}${f.upstream && (f.upstream.conflicts || f.upstream.diverged || f.upstream.fetchOk === false) ? ` (${upstreamLine(f.upstream)})` : ''}`),
145
+ ...b.ais.map((n) => `AI session ${n}`),
146
+ ...b.hosted.map((n) => `hosted agent ${n}`),
147
+ ...b.worktrees.map((w) => `worktree \`${w}\``)
148
+ ]
149
+ const line = upstreamLine(b.upstream)
150
+ const bits = [
151
+ b.default ? 'default' : '',
152
+ b.session ? 'in the session' : b.onGitHub ? 'on GitHub, not in the session yet (quilt_git_branch with create: true opens it)' : '',
153
+ b.merged ? `merged into ${b.merged.into}${b.merged.number ? ` (pull request #${b.merged.number})` : ''}` : '',
154
+ who.length ? `on it: ${who.join(', ')}` : '',
155
+ who.some((w) => w.includes(`(${line})`)) ? '' : line,
156
+ relayLine(b.relay, now),
157
+ b.last ? `last commit ${ago(b.last.ts, now)} by ${b.last.author}: ${b.last.subject}` : ''
158
+ ].filter(Boolean)
159
+ out.push(`- \`${b.name}\`${b.name === mine ? ' (yours)' : ''}${bits.length ? ` · ${bits.join(' · ')}` : ''}`)
160
+ }
161
+ if (board.length > limit) out.push(`- …and ${board.length - limit} more`)
162
+ return out.join('\n')
163
+ }
164
+
165
+ /** What the relay said when asked to look at GitHub now (Room.syncUpstreamNow), for an AI; '' when it wasn't asked. */
166
+ export function describeRelaySync (r, branch = '') {
167
+ if (!r) return ''
168
+ if (r.error) return `The relay couldn't be asked to look at GitHub for \`${branch}\` (${r.error}).`
169
+ return r.said || ''
170
+ }
171
+
172
+ /** What quilt_sync_branch did, for the AI that asked. */
173
+ export function describeBranchSync (r) {
174
+ if (!r || !r.git) {
175
+ const relay = r && r.relay && !r.relay.error && r.relay.state !== 'unknown-repo' ? describeRelaySync(r.relay) : ''
176
+ return `This folder is not a git repository, so it can't bring commits in itself: clone the repository into this folder (\`git clone <url> .\`, with credentials that work without a prompt) and join again from there.${relay ? `\n\nMeanwhile the relay brings commits in for this branch from GitHub: ${relay}` : ' Until then, the relay brings commits in for this branch from GitHub once a member\'s folder on it has told it which repository the branch follows.'}`
177
+ }
178
+ if (!r.branch) return 'This folder is not on a branch (detached HEAD): check out a branch first.'
179
+ if (r.busy) return `Not now: ${r.busy === 'switching' ? 'this folder is moving to the branch you checked out' : r.busy === 'busy' || r.busy === 'settling' ? 'git is at work in this folder' : r.busy}. Quilt looks again by itself in a minute.`
180
+ const u = r.upstream
181
+ if (!u || !u.name) return `\`${r.branch}\` has no upstream (git branch --set-upstream-to sets one), so there is nothing to bring in.`
182
+ if (u.fetchOk === false) {
183
+ const relay = describeRelaySync(r.relay, r.branch)
184
+ return `Couldn't fetch ${u.name}: ${u.fetchError || 'git fetch failed'}. Nothing new could be looked for through this folder, so \`${r.branch}\` may be behind ${u.name}. Give git in this folder credentials that work without a prompt (a credential helper, an SSH key, or a token in the remote's URL) and call quilt_sync_branch again.${relay ? `\n\nThe relay brings commits in for \`${r.branch}\` from GitHub while no folder on it can: ${relay}` : ''}`
185
+ }
186
+ if (r.moved) {
187
+ const b = u.brought
188
+ return `Brought ${b && b.count ? `${b.count} commit${b.count === 1 ? '' : 's'}` : 'the new commits'} from ${u.name} into the session (${b ? b.files : 0} file${b && b.files === 1 ? '' : 's'}), merged with its uncommitted work. \`${r.branch}\` is up to date.`
189
+ }
190
+ if (u.conflicts && u.conflicts.length) {
191
+ return `Nothing brought in: ${u.behind} commit${u.behind === 1 ? '' : 's'} on ${u.name} clash with the session's uncommitted work in:\n${u.conflicts.map((c) => `- ${c.path}: ${c.why}`).join('\n')}\n${u.mergedBy && !u.clashMine ? `${u.mergedBy} is merging them (task ${u.clashTask}): leave those files to them.` : `Run git pull in this folder and resolve those; the session takes in the result.${u.clashTask ? ` This merge is yours: task ${u.clashTask} on the board.` : ''}`}`
192
+ }
193
+ return `\`${r.branch}\`: ${upstreamLine(u)}.${u.diverged ? ' Quilt never merges git history: pull or rebase it yourself, and the session takes in whatever that changes.' : ''}`
194
+ }
package/src/catchup.js ADDED
@@ -0,0 +1,111 @@
1
+ // "While you were away": what the session did between this folder's last stop
2
+ // and its rejoin. Built from the shared history (history.js) by comparing the
3
+ // entries the saved doc already had with the ones the relay brought, so it
4
+ // needs no clocks to agree and nothing new on the relay. Shown in the app and
5
+ // in every tool's quilt_status until the person dismisses it.
6
+
7
+ /** id -> ts of every history entry the doc has now (a folded entry keeps its id but moves its ts). */
8
+ export function historyMarks (entries) {
9
+ const marks = new Map()
10
+ for (const e of entries || []) if (e && e.id) marks.set(e.id, e.ts)
11
+ return marks
12
+ }
13
+
14
+ /**
15
+ * The changes others made that `marks` (taken before syncing) hadn't seen,
16
+ * grouped by person, newest first. `partial`: the history was trimmed past
17
+ * where we left off, so older changes may be missing from the list.
18
+ */
19
+ export function awayChanges (entries, marks, me) {
20
+ const list = (entries || []).filter((e) => e && e.id && e.path && (!marks.has(e.id) || marks.get(e.id) !== e.ts))
21
+ const people = new Map()
22
+ for (const e of list) {
23
+ if (e.by === me) continue
24
+ const p = people.get(e.by) || { name: e.by, added: 0, removed: 0, ts: 0, files: new Map() }
25
+ // A folded entry we had seen as "created" was already there for us: it's an edit now.
26
+ const kind = e.kind === 'created' && marks.has(e.id) ? 'edited' : e.kind
27
+ const f = p.files.get(e.path) || { path: e.path, kind, added: 0, removed: 0, ts: 0 }
28
+ // Created then edited is still new; deleted last is gone.
29
+ if (kind === 'deleted') f.kind = 'deleted'
30
+ else if (f.kind === 'deleted' || (f.kind !== 'created' && kind === 'created')) f.kind = kind
31
+ f.added += e.added || 0; f.removed += e.removed || 0; f.ts = Math.max(f.ts, e.ts || 0)
32
+ if (e.pulled) f.pulled = true
33
+ p.added += e.added || 0; p.removed += e.removed || 0; p.ts = Math.max(p.ts, e.ts || 0)
34
+ p.files.set(e.path, f)
35
+ people.set(e.by, p)
36
+ }
37
+ const newest = (a, b) => b.ts - a.ts
38
+ const out = [...people.values()].map((p) => ({ ...p, files: [...p.files.values()].sort(newest) })).sort(newest)
39
+ const partial = marks.size > 0 && entries.length > 0 && !entries.some((e) => e && marks.has(e.id))
40
+ return { people: out, partial }
41
+ }
42
+
43
+ /**
44
+ * Folds a new catch-up into one not yet dismissed (the person left and came
45
+ * back again before reading it): changes add up, notes are kept once.
46
+ */
47
+ export function mergeCatchUp (prev, next) {
48
+ if (!prev) return next
49
+ const people = new Map(prev.people.map((p) => [p.name, { ...p, files: new Map(p.files.map((f) => [f.path, { ...f }])) }]))
50
+ for (const p of next.people) {
51
+ const q = people.get(p.name) || { name: p.name, added: 0, removed: 0, ts: 0, files: new Map() }
52
+ q.fileCount = Math.max(q.fileCount || 0, p.fileCount || 0)
53
+ q.added += p.added; q.removed += p.removed; q.ts = Math.max(q.ts, p.ts)
54
+ for (const f of p.files) {
55
+ const g = q.files.get(f.path)
56
+ if (!g) { q.files.set(f.path, { ...f }); continue }
57
+ g.added += f.added; g.removed += f.removed; g.ts = Math.max(g.ts, f.ts)
58
+ g.kind = f.kind === 'deleted' ? 'deleted' : g.kind === 'created' && f.kind !== 'deleted' ? 'created' : f.kind
59
+ if (f.pulled) g.pulled = true
60
+ }
61
+ people.set(p.name, q)
62
+ }
63
+ const newest = (a, b) => b.ts - a.ts
64
+ const uniq = (xs, key) => [...new Map(xs.map((x) => [key(x), x])).values()]
65
+ return {
66
+ ...next,
67
+ since: prev.since ?? next.since,
68
+ first: prev.first || next.first,
69
+ pulled: (prev.pulled || 0) + (next.pulled || 0),
70
+ partial: prev.partial || next.partial,
71
+ people: [...people.values()].map((p) => ({ ...p, fileCount: Math.max(p.fileCount || 0, p.files.size), files: [...p.files.values()].sort(newest) })).sort(newest),
72
+ backups: uniq([...(prev.backups || []), ...(next.backups || [])], (b) => b.copy),
73
+ mine: {
74
+ shared: (prev.mine?.shared || 0) + (next.mine?.shared || 0),
75
+ merged: uniq([...(prev.mine?.merged || []), ...(next.mine?.merged || [])], (p) => p),
76
+ conflicts: uniq([...(prev.mine?.conflicts || []), ...(next.mine?.conflicts || [])], (p) => p)
77
+ }
78
+ }
79
+ }
80
+
81
+ /** True when there's nothing worth showing. */
82
+ export function emptyCatchUp (c) {
83
+ return !c || (!c.people.length && !c.pulled && !(c.backups || []).length && !c.mine?.shared && !(c.mine?.merged || []).length && !(c.mine?.conflicts || []).length)
84
+ }
85
+
86
+ const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`
87
+ const kindWord = (k) => k === 'created' ? 'new' : k === 'deleted' ? 'deleted' : null
88
+
89
+ /** The catch-up as Markdown lines, for quilt_status / STATUS.md / `quilt status`. */
90
+ export function catchUpMarkdown (c, { ago = () => '' } = {}) {
91
+ if (emptyCatchUp(c)) return []
92
+ const out = [`## While you were away${c.since ? ` (you left ${ago(c.since)})` : ''}`]
93
+ if (c.first && c.pulled) out.push(`- The session put ${plural(c.pulled, 'file')} in this folder when you joined.`)
94
+ for (const p of c.people) {
95
+ const files = p.files.slice(0, 12).map((f) => {
96
+ const k = kindWord(f.kind)
97
+ return `\`${f.path}\` (${k || `+${f.added} -${f.removed}`}${f.pulled ? ', pulled from git' : ''})`
98
+ })
99
+ const n = Math.max(p.fileCount || 0, p.files.length)
100
+ if (n > files.length) files.push(`and ${n - files.length} more`)
101
+ out.push(`- **${p.name}** changed ${plural(n, 'file')}, +${p.added} -${p.removed}: ${files.join(', ')}`)
102
+ }
103
+ if (c.partial) out.push('- Older changes from while you were away are no longer in the history.')
104
+ const m = c.mine || {}
105
+ if (m.shared) out.push(`- Your ${plural(m.shared, 'change')} from while you were away went into the session.`)
106
+ if (m.merged?.length) out.push(`- Combined with the session's changes: ${m.merged.map((p) => `\`${p}\``).join(', ')}.`)
107
+ if (m.conflicts?.length) out.push(`- Clashed with the session's changes (the session's version is in the file, yours is in the merge; see quilt_merges): ${m.conflicts.map((p) => `\`${p}\``).join(', ')}.`)
108
+ if (c.backups?.length) out.push(`- Your copies of ${plural(c.backups.length, 'file')} differed from the session's and were replaced; they are kept: ${c.backups.map((b) => `\`${b.path}\` → \`${b.copy}\``).join(', ')}.`)
109
+ out.push('Read quilt_history for the diffs before you change these files.')
110
+ return out
111
+ }
@@ -0,0 +1,85 @@
1
+ // The session's chat, kept longer than the room keeps it. The room's chat holds the newest 500
2
+ // messages (every writer trims it, so the shared document stays small); a busy session with
3
+ // several agents passes that in days, and a person's conversation with one agent is gone with
4
+ // it. Each place that reads conversations for agents (a folder's daemon, the relay for hosted
5
+ // agents) keeps every message it sees in a file beside its copy of the room, and reads the two
6
+ // together (conversation.js). Who may read what is decided when it is read, as for the room's chat.
7
+ import fs from 'node:fs'
8
+ import path from 'node:path'
9
+
10
+ export const ARCHIVE_CAP = 5000 // messages kept; the file is rewritten without the oldest past this
11
+ const SLACK = 1000
12
+
13
+ const valid = (m) => m && typeof m === 'object' && typeof m.id === 'string' && m.id && typeof m.by === 'string' && typeof m.text === 'string'
14
+
15
+ export class ChatArchive {
16
+ /** `file`: where it is kept (null keeps it in memory only, for tests and rooms without a disk). */
17
+ constructor (file, { cap = ARCHIVE_CAP, log = () => {} } = {}) {
18
+ this.file = file
19
+ this.cap = cap
20
+ this.log = log
21
+ this.list = []
22
+ this.ids = new Set()
23
+ this.lines = 0
24
+ if (file) {
25
+ try {
26
+ for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
27
+ if (!line) continue
28
+ let m
29
+ try { m = JSON.parse(line) } catch { continue } // a line cut off by a crash
30
+ this.lines++
31
+ if (valid(m) && !this.ids.has(m.id)) { this.ids.add(m.id); this.list.push(m) }
32
+ }
33
+ } catch {}
34
+ this.list.sort((a, b) => (a.ts || 0) - (b.ts || 0))
35
+ // The file keeps up to SLACK more than the cap between rewrites.
36
+ for (const m of this.list.splice(0, Math.max(0, this.list.length - this.cap))) this.ids.delete(m.id)
37
+ }
38
+ }
39
+
40
+ /** Keeps messages not kept yet (from the room's chat, oldest first). */
41
+ add (messages) {
42
+ const fresh = []
43
+ for (const m of messages || []) {
44
+ if (!valid(m) || this.ids.has(m.id)) continue
45
+ const keep = { id: m.id, by: m.by, ...(m.to ? { to: m.to } : {}), ...(m.of ? { of: m.of } : {}), ...(m.kind ? { kind: m.kind } : {}), text: m.text, ts: Number(m.ts) || 0 }
46
+ this.ids.add(m.id)
47
+ this.list.push(keep)
48
+ fresh.push(keep)
49
+ }
50
+ if (!fresh.length) return 0
51
+ for (let i = Math.max(1, this.list.length - fresh.length); i < this.list.length; i++) {
52
+ if (this.list[i].ts < this.list[i - 1].ts) { this.list.sort((a, b) => (a.ts || 0) - (b.ts || 0)); break }
53
+ }
54
+ if (this.list.length > this.cap) {
55
+ for (const m of this.list.splice(0, this.list.length - this.cap)) this.ids.delete(m.id)
56
+ }
57
+ if (!this.file) return fresh.length
58
+ try {
59
+ if (this.lines + fresh.length > this.cap + SLACK) {
60
+ fs.mkdirSync(path.dirname(this.file), { recursive: true })
61
+ const tmp = this.file + '.tmp'
62
+ fs.writeFileSync(tmp, this.list.map((m) => JSON.stringify(m)).join('\n') + '\n', { mode: 0o600 })
63
+ fs.renameSync(tmp, this.file)
64
+ this.lines = this.list.length
65
+ } else {
66
+ fs.mkdirSync(path.dirname(this.file), { recursive: true })
67
+ fs.appendFileSync(this.file, fresh.map((m) => JSON.stringify(m)).join('\n') + '\n', { mode: 0o600 })
68
+ this.lines += fresh.length
69
+ }
70
+ } catch (err) { this.log(`could not keep chat history: ${err.message}`) }
71
+ return fresh.length
72
+ }
73
+
74
+ /**
75
+ * Everything kept, with the room's current chat (`live`, which wins for a message in both),
76
+ * oldest first.
77
+ */
78
+ with (live = []) {
79
+ const liveIds = new Set()
80
+ for (const m of live) if (valid(m)) liveIds.add(m.id)
81
+ const older = this.list.filter((m) => !liveIds.has(m.id))
82
+ const out = older.concat(live.filter(valid))
83
+ return older.length && live.length && older[older.length - 1].ts > (live[0].ts || 0) ? out.sort((a, b) => (a.ts || 0) - (b.ts || 0)) : out
84
+ }
85
+ }