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,178 @@
1
+ // The project's own rules for AI, gathered from whichever tool's files hold them, so every
2
+ // agent reads the same thing whatever tool it runs in (quilt_project_rules, on the local and
3
+ // hosted MCP): AGENTS.md at the root and in folders (or CLAUDE.md when there is none), the
4
+ // Cursor rules that always apply, and the design system.
5
+ //
6
+ // Pure: `list` is the session's file paths, `read(path)` their text (or null).
7
+
8
+ const START = '<!-- quilt:start -->'
9
+ const END = '<!-- quilt:end -->'
10
+ const MAX_FILE = 20000
11
+ const MAX_TOTAL = 60000
12
+ const SKIP = /(^|\/)(node_modules|\.git|\.quilt|dist|build|vendor)\//
13
+ // The design system: named in AGENTS.md ("Design system: docs/brand.md"), else a usual name.
14
+ const DESIGN_NAMES = ['DESIGN.md', 'design.md', 'DESIGN_SYSTEM.md', 'design-system.md', 'docs/DESIGN.md', 'docs/design.md', 'docs/design-system.md']
15
+ const DESIGN_LINE = /^\s*(?:[-*]\s*)?\**design system\**\s*:\s*`?([^\s`]+\.(?:md|mdx|txt))`?/im
16
+
17
+ /** A rules file without Quilt's own guide block (agents have that from Quilt already). */
18
+ export function withoutQuiltBlock (text) {
19
+ const s = String(text || '')
20
+ const a = s.indexOf(START)
21
+ const b = s.indexOf(END)
22
+ if (a === -1 || b === -1 || b < a) return s.trim()
23
+ return (s.slice(0, a) + s.slice(b + END.length)).replace(/\n{3,}/g, '\n\n').trim()
24
+ }
25
+
26
+ /** A Cursor rule's front matter: { alwaysApply, globs, description } and its body. */
27
+ export function parseCursorRule (text) {
28
+ const s = String(text || '')
29
+ const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(s)
30
+ const meta = { alwaysApply: false, globs: [], description: '' }
31
+ if (!m) return { meta, body: s.trim() }
32
+ for (const line of m[1].split(/\r?\n/)) {
33
+ const kv = /^(\w+)\s*:\s*(.*)$/.exec(line.trim())
34
+ if (!kv) continue
35
+ const [, k, v] = kv
36
+ if (k === 'alwaysApply') meta.alwaysApply = /^true$/i.test(v.trim())
37
+ else if (k === 'description') meta.description = v.trim().replace(/^["']|["']$/g, '')
38
+ else if (k === 'globs') meta.globs = v.replace(/^\[|\]$/g, '').split(',').map((g) => g.trim().replace(/^["']|["']$/g, '')).filter(Boolean)
39
+ }
40
+ return { meta, body: s.slice(m[0].length).trim() }
41
+ }
42
+
43
+ /** Whether a CLAUDE.md (or GEMINI.md) only points at AGENTS.md, with nothing of its own. */
44
+ export const onlyPointer = (text) => withoutQuiltBlock(text).replace(/^\s*@AGENTS\.md\s*$/m, '').trim() === ''
45
+
46
+ /**
47
+ * The rules: { files: [{ path, kind, text }], design: { path, text } | null, missing: [] }.
48
+ * `kind`: 'agents' (AGENTS.md), 'claude' (CLAUDE.md, when there is no AGENTS.md), 'cursor'.
49
+ */
50
+ export function projectRules ({ list = [], read = () => null } = {}) {
51
+ const paths = [...new Set(list)].filter((p) => typeof p === 'string' && !SKIP.test(p)).sort((a, b) => depth(a) - depth(b) || a.localeCompare(b))
52
+ const files = []
53
+ let total = 0
54
+ const add = (path, kind, text) => {
55
+ const t = String(text || '').trim()
56
+ if (!t || total >= MAX_TOTAL) return
57
+ const cut = t.length > MAX_FILE ? `${t.slice(0, MAX_FILE)}\n\n… (cut: read ${path} for the rest)` : t
58
+ total += cut.length
59
+ files.push({ path, kind, text: cut })
60
+ }
61
+ const agents = paths.filter((p) => p === 'AGENTS.md' || p.endsWith('/AGENTS.md'))
62
+ for (const p of agents) add(p, 'agents', withoutQuiltBlock(read(p)))
63
+ // No AGENTS.md: a CLAUDE.md holds the rules (Claude Code's own file), at the root and in folders.
64
+ if (!files.length) for (const p of paths.filter((x) => x === 'CLAUDE.md' || x.endsWith('/CLAUDE.md'))) add(p, 'claude', withoutQuiltBlock(read(p)))
65
+ // Cursor's rules that apply everywhere; ones for some files only are named, to read when working on those.
66
+ const scoped = []
67
+ for (const p of paths.filter((x) => /^\.cursor\/rules\/.+\.mdc?$/.test(x))) {
68
+ const { meta, body } = parseCursorRule(read(p))
69
+ if (meta.alwaysApply) add(p, 'cursor', body)
70
+ else if (body) scoped.push({ path: p, globs: meta.globs, description: meta.description })
71
+ }
72
+ if (paths.includes('.cursorrules')) add('.cursorrules', 'cursor', read('.cursorrules'))
73
+ // The design system: named in the rules, else a usual file name.
74
+ let design = null
75
+ const named = files.map((f) => DESIGN_LINE.exec(f.text)?.[1]).find(Boolean)
76
+ const designPath = [named, ...DESIGN_NAMES].find((p) => p && paths.includes(p.replace(/^\.\//, '')))
77
+ if (designPath) {
78
+ const t = String(read(designPath.replace(/^\.\//, '')) || '').trim()
79
+ if (t) design = { path: designPath.replace(/^\.\//, ''), text: t.length > MAX_FILE ? `${t.slice(0, MAX_FILE)}\n\n… (cut: read ${designPath} for the rest)` : t }
80
+ }
81
+ return { files, scoped, design }
82
+ }
83
+
84
+ const depth = (p) => p.split('/').length
85
+
86
+ /** The rules as one text for an agent. */
87
+ export function renderRules (r) {
88
+ if (!r.files.length && !r.design && !r.scoped.length) {
89
+ return 'This project has no rules for AI yet (no AGENTS.md, CLAUDE.md or Cursor rules) and no design system file. Follow the code around what you change.'
90
+ }
91
+ const out = ['The project\'s rules for AI, the same for every tool. Folder AGENTS.md files apply to the files under that folder.', '']
92
+ for (const f of r.files) out.push(`## ${f.path}`, '', f.text, '')
93
+ if (r.scoped.length) {
94
+ out.push('## Rules for some files only (Cursor)', '')
95
+ for (const s of r.scoped) out.push(`- ${s.path}${s.globs.length ? ` (for ${s.globs.join(', ')})` : ''}${s.description ? `: ${s.description}` : ''}`)
96
+ out.push('', 'Read one with your file tools when you work on files it covers.', '')
97
+ }
98
+ if (r.design) out.push(`## Design system (${r.design.path})`, '', r.design.text, '')
99
+ return out.join('\n').trim()
100
+ }
101
+
102
+ // ------------------------------------------------- gathering into AGENTS.md --
103
+
104
+ /** The one folder every glob of a Cursor rule is under ("src/api/**\/*.ts" → "src/api"), or null. */
105
+ export function globFolder (globs) {
106
+ const dirs = (globs || []).map((g) => {
107
+ const parts = String(g).replace(/^\.\//, '').split('/')
108
+ const fixed = []
109
+ for (const p of parts.slice(0, -1)) { if (/[*?[{]/.test(p)) break; fixed.push(p) }
110
+ return fixed.join('/')
111
+ })
112
+ if (!dirs.length || dirs.some((d) => !d)) return null
113
+ return dirs.every((d) => d === dirs[0]) ? dirs[0] : null
114
+ }
115
+
116
+ const section = (title, body) => `## ${title}\n\n${String(body).trim()}\n`
117
+ const joinTo = (before, parts) => [String(before || '').replace(/\s*$/, ''), ...parts.map((p) => p.trim())].filter(Boolean).join('\n\n').replace(/^\n+/, '') + '\n'
118
+
119
+ /**
120
+ * What gathering the project's rules into AGENTS.md would change, for the person to look at
121
+ * before it is applied: { changes: [{ path, before, after }] (after null: the file goes),
122
+ * manual: [{ path, why }] }. CLAUDE.md and GEMINI.md keep only a pointer at AGENTS.md (and
123
+ * Quilt's guide); Cursor rules that always apply, and .cursorrules, move into AGENTS.md; a
124
+ * rule for files in one folder moves into that folder's AGENTS.md; anything else is listed for
125
+ * the person to place by hand.
126
+ */
127
+ export function gatherPlan ({ list = [], read = () => null } = {}) {
128
+ const paths = [...new Set(list)].filter((p) => typeof p === 'string' && !SKIP.test(p)).sort()
129
+ const has = new Set(paths)
130
+ const writes = new Map() // path -> { before, parts: [] }
131
+ const removes = new Map() // path -> before
132
+ const pointers = new Map() // path -> { before, after }
133
+ const manual = []
134
+ const into = (folder, title, body) => {
135
+ const p = folder ? `${folder}/AGENTS.md` : 'AGENTS.md'
136
+ if (!writes.has(p)) writes.set(p, { before: has.has(p) ? String(read(p) ?? '') : null, parts: [] })
137
+ writes.get(p).parts.push(section(title, body))
138
+ return p
139
+ }
140
+ // A tool's own file (CLAUDE.md, GEMINI.md) at the root or in a folder: its rules go to that folder's AGENTS.md.
141
+ for (const p of paths.filter((x) => /(^|\/)(CLAUDE|GEMINI)\.md$/.test(x))) {
142
+ const before = String(read(p) ?? '')
143
+ const own = withoutQuiltBlock(before).replace(/^\s*@AGENTS\.md\s*$/m, '').trim()
144
+ if (!own) continue
145
+ const folder = p.includes('/') ? p.slice(0, p.lastIndexOf('/')) : ''
146
+ into(folder, `From ${p.split('/').pop()}`, own)
147
+ const block = before.match(/<!-- quilt:start -->[\s\S]*?<!-- quilt:end -->/)
148
+ pointers.set(p, { before, after: `@AGENTS.md\n${block ? `\n${block[0]}\n` : ''}` })
149
+ }
150
+ for (const p of paths.filter((x) => /^\.cursor\/rules\/.+\.mdc?$/.test(x))) {
151
+ const before = String(read(p) ?? '')
152
+ const { meta, body } = parseCursorRule(before)
153
+ if (!body) continue
154
+ const title = meta.description || p.split('/').pop().replace(/\.mdc?$/, '')
155
+ if (meta.alwaysApply) { removes.set(p, { before, to: into('', title, body) }); continue }
156
+ const folder = globFolder(meta.globs)
157
+ if (folder) { removes.set(p, { before, to: into(folder, title, body) }); continue }
158
+ manual.push({ path: p, why: meta.globs.length ? `it applies to ${meta.globs.join(', ')}, which isn't one folder` : 'Cursor applies it when asked for, not to a folder' })
159
+ }
160
+ if (has.has('.cursorrules')) {
161
+ const before = String(read('.cursorrules') ?? '')
162
+ if (before.trim()) removes.set('.cursorrules', { before, to: into('', 'From .cursorrules', before) })
163
+ }
164
+ const changes = []
165
+ for (const [p, w] of writes) changes.push({ path: p, before: w.before, after: joinTo(w.before, w.parts) })
166
+ for (const [p, c] of pointers) if (c.after !== c.before) changes.push({ path: p, before: c.before, after: c.after })
167
+ for (const [p, r] of removes) changes.push({ path: p, before: r.before, after: null, to: r.to })
168
+ changes.sort((a, b) => (a.path === 'AGENTS.md' ? -1 : b.path === 'AGENTS.md' ? 1 : a.path.localeCompare(b.path)))
169
+ return { changes, manual }
170
+ }
171
+
172
+ /** A short fingerprint of a plan, so what is applied is what was shown. */
173
+ export function planKey (plan) {
174
+ let h = 0x811c9dc5
175
+ const s = JSON.stringify(plan.changes.map((c) => [c.path, c.before, c.after]))
176
+ for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 0x01000193)
177
+ return `${s.length}:${(h >>> 0).toString(36)}`
178
+ }
@@ -0,0 +1,92 @@
1
+ // Wire protocol shared by the relay server and clients.
2
+ // Compatible with the standard y-websocket message layout.
3
+ import * as encoding from 'lib0/encoding'
4
+ import * as decoding from 'lib0/decoding'
5
+ import * as syncProtocol from 'y-protocols/sync'
6
+ import * as awarenessProtocol from 'y-protocols/awareness'
7
+
8
+ export const MSG_SYNC = 0
9
+ export const MSG_AWARENESS = 1
10
+ export const MSG_QUERY_AWARENESS = 3
11
+ // quilt extensions (ignored by plain y-websocket clients):
12
+ export const MSG_AUTH = 10 // relay -> client: nonce; client -> relay: signature
13
+ export const MSG_CLAIM = 11 // client -> relay: JSON { id, op: 'claim'|'release', pattern, note }
14
+ export const MSG_CLAIMS = 12 // relay -> client: JSON { claims, reply?: { id, ok, error, released } }
15
+ export const MSG_ACCESS = 13 // relay -> client: JSON { state: 'pending'|'approved', role, scopes, scopesExcept, talk, owner, controlled, refresh?, refused?, why? }
16
+ export const MSG_ADMIN = 14 // client (owner) -> relay: JSON { id, op: 'approve'|'deny'|'set'|'remove'|'end'|'name', key, role, scopes, access?, typeId?, name }
17
+ export const MSG_MEMBERS = 15 // relay -> client: JSON { members, sessionName, pending?, reply?: { id, ok, error } }
18
+ export const MSG_PASS = 16 // client -> relay: JSON { pass }: a fresh session pass, sent at least every 5 minutes
19
+ export const MSG_BRANCH = 17 // client -> relay: JSON { id, op: 'join'|'confirm'|'remove', branch, base?, adopt?, sv? }
20
+ export const MSG_BRANCHES = 18 // relay -> client: JSON { branches, reply?: { id, op, ok, error?, branch?, created?, base? } }
21
+
22
+ // Every sync message names its document: the room's (chat, tasks, the feed, commit requests,
23
+ // activity) is '', a branch's (its files) is the branch key. Git never allows an empty branch name.
24
+ export const ROOM_DOC = ''
25
+
26
+ // What this app can do, sent when it connects. The relay turns away apps without what a session needs.
27
+ export const FEATURES = 'large-files,branches'
28
+
29
+ // WebSocket close codes the relay uses to refuse a client for good.
30
+ export const CLOSE_AUTH_FAILED = 4401
31
+ export const CLOSE_NAME_TAKEN = 4403
32
+ export const CLOSE_DENIED = 4406 // the owner said no, or removed you
33
+
34
+ // WebSocket close code the relay uses when a room is over its size quota.
35
+ export const CLOSE_ROOM_FULL = 4413
36
+
37
+ export const CLOSE_ENDED = 4410 // the owner ended the session and it was deleted
38
+
39
+ // The connection's session pass ran out without a new one. Clients reconnect with a fresh pass.
40
+ export const CLOSE_PASS_EXPIRED = 4419
41
+
42
+ // Sent to apps too old for a session that now stores large files. It reuses
43
+ // 4401 because every app that knows close codes stops on it and shows the
44
+ // reason; the oldest ones reconnect instead, and the relay refuses that
45
+ // reconnect with the same message.
46
+ export const CLOSE_NEEDS_UPDATE = CLOSE_AUTH_FAILED
47
+
48
+ // Largest file that can be sent in chat.
49
+ export const MAX_SHARED_FILE_BYTES = 100 * 1024 * 1024
50
+
51
+ export { encoding, decoding, syncProtocol, awarenessProtocol }
52
+
53
+ /** An encoder with a sync message's header written: the type and the document it is for. */
54
+ export function syncHeader (docId = ROOM_DOC) {
55
+ const enc = encoding.createEncoder()
56
+ encoding.writeVarUint(enc, MSG_SYNC)
57
+ encoding.writeVarString(enc, docId)
58
+ return enc
59
+ }
60
+
61
+ export function syncStep1Message (doc, docId = ROOM_DOC) {
62
+ const enc = syncHeader(docId)
63
+ syncProtocol.writeSyncStep1(enc, doc)
64
+ return encoding.toUint8Array(enc)
65
+ }
66
+
67
+ export function updateMessage (update, docId = ROOM_DOC) {
68
+ const enc = syncHeader(docId)
69
+ syncProtocol.writeUpdate(enc, update)
70
+ return encoding.toUint8Array(enc)
71
+ }
72
+
73
+ export function awarenessMessage (awareness, clients) {
74
+ const enc = encoding.createEncoder()
75
+ encoding.writeVarUint(enc, MSG_AWARENESS)
76
+ encoding.writeVarUint8Array(enc, awarenessProtocol.encodeAwarenessUpdate(awareness, clients))
77
+ return encoding.toUint8Array(enc)
78
+ }
79
+
80
+ export function bytesMessage (type, bytes) {
81
+ const enc = encoding.createEncoder()
82
+ encoding.writeVarUint(enc, type)
83
+ encoding.writeVarUint8Array(enc, bytes)
84
+ return encoding.toUint8Array(enc)
85
+ }
86
+
87
+ export function jsonMessage (type, value) {
88
+ const enc = encoding.createEncoder()
89
+ encoding.writeVarUint(enc, type)
90
+ encoding.writeVarString(enc, JSON.stringify(value))
91
+ return encoding.toUint8Array(enc)
92
+ }
@@ -0,0 +1,9 @@
1
+ // Node prints "SQLite is an experimental feature" the moment node:sqlite loads, which
2
+ // Quilt does to read Cursor's chat database. The warning is known and harmless, so it is
3
+ // kept off people's terminals; every other warning is still printed as Node would.
4
+ const printers = process.listeners('warning')
5
+ process.removeAllListeners('warning')
6
+ process.on('warning', (warning) => {
7
+ if (warning && warning.name === 'ExperimentalWarning' && /sqlite/i.test(String(warning.message))) return
8
+ for (const print of printers) print.call(process, warning)
9
+ })
@@ -0,0 +1,82 @@
1
+ // The relay wakes a person's cloud AI (routes/ai-vm.js on the API) when something waits for it
2
+ // and it isn't connected (its machine suspends when idle): an @mention, a direct message or a
3
+ // task for it, or for "<person>'s AI" while none of the person's AI sessions on a computer is
4
+ // live. A room remembers each cloud AI that joined it (meta.cloudAis, keyed by its person's
5
+ // account) with what it has been told of, so a wake is asked for once per new event, and the
6
+ // machine's own inbox finds the event when it is back.
7
+ import { scanInbox } from './inbox.js'
8
+ import { readTasks } from './tasks.js'
9
+ import { cloudReader } from './cloud-ai.js'
10
+
11
+ const PERSON = /^person:[A-Za-z0-9_-]{1,64}$/
12
+
13
+ /** Notes the cloud AI a pass is for in `meta.cloudAis`; returns whether anything changed. */
14
+ export function noteCloudAi (meta, pass, name) {
15
+ if (!pass || pass.kind !== 'agent' || !PERSON.test(String(pass.of || '')) || typeof pass.ofName !== 'string') return false
16
+ meta.cloudAis ||= {}
17
+ const old = meta.cloudAis[pass.of]
18
+ const agent = `agent:${pass.sub}`
19
+ if (old && old.agent === agent && old.name === name && old.ofName === pass.ofName) return false
20
+ meta.cloudAis[pass.of] = { agent, name, ofName: pass.ofName, inbox: old && old.agent === agent ? old.inbox : null }
21
+ return true
22
+ }
23
+
24
+ /** Whether a connection in `room` is `account` ('agent:<id>'). */
25
+ const connected = (room, account) => [...room.conns.keys()].some((ws) => ws.pass && `${ws.pass.kind}:${ws.pass.sub}` === account)
26
+
27
+ /**
28
+ * Looks at `room` for each cloud AI it knows: what is new for it. Returns the cloud AIs to wake
29
+ * ([{ agent, room }]); `changed` says whether meta needs saving. Pure but for the room it reads.
30
+ */
31
+ export function cloudAisToWake (room) {
32
+ const out = []
33
+ let changed = false
34
+ const list = room.meta.cloudAis || {}
35
+ if (!Object.keys(list).length) return { wake: out, changed }
36
+ const states = [...room.awareness.getStates().values()]
37
+ const chat = room.doc.getArray('chat').toArray()
38
+ const tasks = readTasks(room.doc.getMap('tasks'))
39
+ for (const entry of Object.values(list)) {
40
+ if (!entry || !entry.agent) continue
41
+ const reader = cloudReader({ name: entry.name, person: entry.ofName, states })
42
+ const messages = chat.filter((m) => m && m.id && (!m.to || m.to === entry.name || reader.aliases.includes(m.to) || m.by === entry.name))
43
+ const got = scanInbox({ messages, tasks, reader }, entry.inbox || null)
44
+ const seeded = !entry.inbox
45
+ entry.inbox = got.state
46
+ changed = true
47
+ if (seeded || !got.events.length) continue
48
+ if (!connected(room, entry.agent)) out.push({ agent: entry.agent.slice('agent:'.length), room: room.name })
49
+ }
50
+ return { wake: out, changed }
51
+ }
52
+
53
+ /**
54
+ * Watches rooms' chat and boards and asks the API (`apiUrl`, signed with `secret`) to wake each
55
+ * cloud AI something waits for. `fetch` is for tests.
56
+ */
57
+ export function cloudAiWaker ({ apiUrl, secret, fetch: fetchImpl = globalThis.fetch, log = () => {} }) {
58
+ const watched = new WeakSet()
59
+ const base = String(apiUrl).replace(/\/+$/, '')
60
+ const ask = (w) => fetchImpl(`${base}/v1/relay/ai-vm/wake`, {
61
+ method: 'POST',
62
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${secret}` },
63
+ body: JSON.stringify(w),
64
+ signal: AbortSignal.timeout(30_000)
65
+ }).then((res) => { if (!res.ok) log(`[${w.room}] could not wake a cloud AI (${res.status})`) }, (err) => log(`[${w.room}] could not wake a cloud AI: ${err.message}`))
66
+
67
+ const check = (room) => {
68
+ const { wake, changed } = cloudAisToWake(room)
69
+ if (changed) room.saveMeta()
70
+ return Promise.all(wake.map(ask))
71
+ }
72
+
73
+ const watch = (room) => {
74
+ if (watched.has(room)) return
75
+ watched.add(room)
76
+ const run = () => { try { check(room) } catch (err) { log(`[${room.name}] cloud AI wake: ${err.message}`) } }
77
+ room.doc.getArray('chat').observe(run)
78
+ room.doc.getMap('tasks').observeDeep(run)
79
+ }
80
+
81
+ return { watch, check }
82
+ }
@@ -0,0 +1,141 @@
1
+ // Questions for the relay, "Quilt" in chat. Anyone in a session can @mention @Quilt (or message it directly)
2
+ // and it answers from what it knows: how the session's branches stand against git, who is
3
+ // connected, which branches it keeps, and (for the owner) trimming branches nobody uses. No
4
+ // model runs here: each question is matched by its words and answered by code. Pure but for
5
+ // `room`, which the relay passes in (server.js answerRelay).
6
+
7
+ import { RELAY_BY, isRelay } from './branches.js'
8
+ import { mentioned } from './inbox.js'
9
+ import { RELAY_QUESTIONS, RELAY_OLD_NAMES } from './ui/chat.js'
10
+
11
+ export { RELAY_QUESTIONS }
12
+
13
+ /** A branch nobody has been on for this long, with nobody on it now, is unused (trim). */
14
+ export const TRIM_IDLE_MS = 24 * 60 * 60 * 1000
15
+
16
+ /** Whether chat message `m` is for the relay: sent to it directly, or @mentioning it. */
17
+ export function asksRelay (m) {
18
+ if (!m || typeof m !== 'object' || typeof m.text !== 'string' || isRelay(m.by)) return false
19
+ if (m.to) return isRelay(m.to)
20
+ return mentioned(m.text, [RELAY_BY, ...RELAY_OLD_NAMES]).length > 0
21
+ }
22
+
23
+ /** Which question `text` asks (an id of RELAY_QUESTIONS), or 'help' when it is none of them. */
24
+ export function questionOf (text) {
25
+ const t = String(text || '').replace(/@(the relay|quilt)\b/gi, ' ').toLowerCase()
26
+ if (/\b(trim|prune|clean ?up|remove)\b/.test(t)) return 'trim'
27
+ if (/\b(git|github|upstream|commits?|behind|synced?)\b/.test(t)) return 'git'
28
+ if (/\b(members?|connected|online|who|people|agents?)\b/.test(t)) return 'members'
29
+ if (/\bbranch(es)?\b/.test(t)) return 'branches'
30
+ return 'help'
31
+ }
32
+
33
+ const plural = (n, w) => `${n} ${w}${n === 1 ? '' : /(ch|sh|s|x)$/.test(w) ? 'es' : 's'}`
34
+
35
+ /** "3 min ago", "2 h ago", "4 days ago". */
36
+ export function ago (ts, now = Date.now()) {
37
+ const s = Math.max(0, Math.round((now - ts) / 1000))
38
+ if (s < 60) return 'just now'
39
+ if (s < 3600) return `${Math.round(s / 60)} min ago`
40
+ if (s < 86400) return `${Math.round(s / 3600)} h ago`
41
+ return plural(Math.round(s / 86400), 'day') + ' ago'
42
+ }
43
+
44
+ const list = (names) => names.length <= 2 ? names.join(' and ') : `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`
45
+
46
+ /** Who is on each branch now: Map key -> [names], from connected apps and hosted agents active now. */
47
+ function whoIsOn (room) {
48
+ const on = new Map()
49
+ const add = (k, n) => { if (k && n && !(on.get(k) || []).includes(n)) on.set(k, [...(on.get(k) || []), n]) }
50
+ for (const ws of room.conns.keys()) add(ws.branch, room.names.get(ws))
51
+ for (const h of room.hostedOnline()) add(room.hostedBranch(h.id), h.name)
52
+ return on
53
+ }
54
+
55
+ function gitAnswer (room, now) {
56
+ const ups = room.meta.upstreams || {}
57
+ const lines = []
58
+ for (const key of Object.keys(room.meta.branches)) {
59
+ const r = ups[key]
60
+ if (!r || !r.name) continue
61
+ const parts = [`${key} follows ${r.name}${r.repo ? ` (${r.repo})` : ''}`]
62
+ if (r.sha) parts.push(`at ${r.sha.slice(0, 7)}${r.checkedAt ? `, checked ${ago(r.checkedAt, now)}` : ''}`)
63
+ if (r.brought && r.brought.at) parts.push(`brought in ${plural(r.brought.count || 0, 'commit')} ${ago(r.brought.at, now)}`)
64
+ if (r.clash && r.clash.task) parts.push(`waiting on merge task ${r.clash.task}`)
65
+ if (r.problem) parts.push(`problem: ${typeof r.problem === 'string' ? r.problem : r.problem.message || 'see the branch menu'}`)
66
+ const why = r.github && GITHUB_BLOCKED[r.github.state]
67
+ if (why) parts.push(`agents can't commit: ${why}`)
68
+ lines.push(parts.join('; '))
69
+ }
70
+ if (!lines.length) return 'No branch here follows a GitHub repository yet. It starts when a member with a git folder joins.'
71
+ return lines.length === 1 ? `${lines[0]}.` : `Against git:\n${lines.map((l) => `- ${l}`).join('\n')}`
72
+ }
73
+
74
+ // Why agents can't commit to a branch's repository, by its GitHub connection's state (relay-upstream.js noteConnection).
75
+ const GITHUB_BLOCKED = {
76
+ 'not-connected': 'nobody here has connected GitHub',
77
+ 'not-installed': 'the Quilt app isn\'t installed on the repository',
78
+ 'no-access': 'the connected GitHub account can\'t write to it'
79
+ }
80
+
81
+ function membersAnswer (room) {
82
+ const people = new Set()
83
+ for (const ws of room.conns.keys()) { const n = room.names.get(ws); if (n) people.add(n) }
84
+ for (const h of room.hostedOnline()) people.add(h.name)
85
+ const total = Object.keys(room.meta.members || {}).length + 1 // members, and the owner
86
+ const names = [...people].sort()
87
+ if (!names.length) return `Nobody is connected right now (${plural(total, 'member')} in the session).`
88
+ return `${plural(names.length, 'member')} connected of ${total}: ${list(names)}.`
89
+ }
90
+
91
+ function branchesAnswer (room, now) {
92
+ const on = whoIsOn(room)
93
+ const keys = Object.keys(room.meta.branches)
94
+ const lines = keys.sort((a, b) => (b === room.defaultKey) - (a === room.defaultKey) || a.localeCompare(b)).map((k) => {
95
+ const b = room.meta.branches[k]
96
+ const here = on.get(k) || []
97
+ const tag = k === room.defaultKey ? ' (default)' : ''
98
+ return `- ${k}${tag}: ${here.length ? `${list(here)} on it` : `nobody on it${b.used ? `, last used ${ago(b.used, now)}` : ''}`}`
99
+ })
100
+ return keys.length === 1 ? `1 branch tracked: ${lines[0].slice(2)}.` : `${plural(keys.length, 'branch')} tracked:\n${lines.join('\n')}`
101
+ }
102
+
103
+ /**
104
+ * The branches trim would remove: not the default, nobody on it or holding a file there, no
105
+ * hosted agent's choice, and no member on it for TRIM_IDLE_MS. A branch from before `used` was
106
+ * kept counts from its last `seen` (the relay's own saves move that too, so it only waits longer).
107
+ */
108
+ export function unusedBranches (room, now = Date.now()) {
109
+ const on = whoIsOn(room)
110
+ const claimed = new Set(Object.values(room.meta.claims || {}).map((c) => room.branchOf(c)))
111
+ const chosen = new Set(Object.values(room.meta.hostedBranch || {}))
112
+ return Object.entries(room.meta.branches)
113
+ .filter(([k, b]) => k !== room.defaultKey && !on.has(k) && !claimed.has(k) && !chosen.has(k) && now - (b.used || b.seen || b.at || 0) >= TRIM_IDLE_MS)
114
+ .map(([k]) => k)
115
+ }
116
+
117
+ function trimAnswer (room, asker, now) {
118
+ if (!asker.mayTrim) return 'Only the session owner can trim branches.'
119
+ const gone = unusedBranches(room, now)
120
+ if (!gone.length) return 'Nothing to trim: every branch is in use or was used in the last day.'
121
+ for (const k of gone) room.removeBranch(k, { save: false })
122
+ room.saveMeta()
123
+ room.broadcastBranches()
124
+ room.log(`[${room.name}] ${asker.name} trimmed ${plural(gone.length, 'unused branch')}: ${gone.join(', ')}`)
125
+ return `Trimmed ${plural(gone.length, 'branch')} nobody used for a day: ${list(gone)}. Git keeps them, and a branch comes back when someone checks it out.`
126
+ }
127
+
128
+ const HELP = `I can answer:\n${RELAY_QUESTIONS.map((q) => `- ${q.text}${q.owner ? ' (owner only)' : ''}`).join('\n')}`
129
+
130
+ /**
131
+ * The relay's answer to message `m`. `asker`: { name, mayTrim }. Returns the text.
132
+ */
133
+ export function relayAnswer (room, m, asker, now = Date.now()) {
134
+ switch (questionOf(m.text)) {
135
+ case 'git': return gitAnswer(room, now)
136
+ case 'members': return membersAnswer(room)
137
+ case 'branches': return branchesAnswer(room, now)
138
+ case 'trim': return trimAnswer(room, asker, now)
139
+ default: return HELP
140
+ }
141
+ }
@@ -0,0 +1,111 @@
1
+ // CI on GitHub becomes work on the board: checks failing on a branch the session follows open one task,
2
+ // "CI failing on <branch>", for the member whose commit it is (when they are in the session); a newer commit
3
+ // failing too is noted on it; checks passing again move it to Done. One task per branch (its id comes from the
4
+ // branch), so a run reported twice never adds a second.
5
+ import crypto from 'node:crypto'
6
+ import { readTasks, addTask, updateTask } from './tasks.js'
7
+ import { addComment } from './task-comments.js'
8
+ import { githubWriter } from './github-commit.js'
9
+ import { credentialsFor } from './relay-commit.js'
10
+ import { upstreamsOf, parseRepo } from './relay-upstream.js'
11
+ import { memberForAuthor, everyone } from './relay-clash.js'
12
+ import { runGit } from './relay-git.js'
13
+ import { RELAY_BY } from './branches.js'
14
+
15
+ const ORIGIN = 'relay-publish'
16
+ // What GitHub calls a check suite that failed (check_suite.conclusion).
17
+ const FAILED = new Set(['failure', 'timed_out', 'action_required', 'startup_failure'])
18
+
19
+ /** The task id for CI on `branch` of this session: the same every time. */
20
+ export const ciTaskId = (room, branch) => crypto.createHash('sha256').update(`ci\0${room.name}\0${branch}`).digest('hex').slice(0, 16)
21
+
22
+ /** Who made commit `sha` (from the relay's mirror): the member it maps to, or ''. */
23
+ async function authorOf (room, rec, sha) {
24
+ if (!room.mirrors) return ''
25
+ try {
26
+ const repo = parseRepo(`https://${rec.repo}`)
27
+ const dir = (await room.mirrors.ensure(repo, { url: room.gitRemoteUrl(repo) })).dir
28
+ const r = await runGit(dir, ['log', '-1', '--format=%an%x1f%ae', sha])
29
+ if (!r.ok) return ''
30
+ const [name, email] = r.out.trim().split('\x1f')
31
+ // By their noreply address, else by name: a member on record, or one connected now.
32
+ const online = room.names ? [...room.names.values()] : []
33
+ return memberForAuthor(room, { name, email }) || everyone(room).find((m) => m.name === name)?.name || online.find((n) => n === name) || ''
34
+ } catch { return '' }
35
+ }
36
+
37
+ /**
38
+ * A check suite on `repo` finished at commit `sha` of `branch` with `conclusion`. When the session follows that
39
+ * branch: failing opens (or reopens, or notes) its CI task; passing moves an open one to Done. Returns what it did
40
+ * ('opened', 'noted', 'closed') or null. Never throws.
41
+ */
42
+ export function noteCiResult (room, result, opts = {}) {
43
+ // One at a time per branch, in the order GitHub's results arrive: a failure takes longer to note (GitHub is asked
44
+ // which checks failed), and a pass right after it must not be noted first.
45
+ if (!room.ciQueue) room.ciQueue = new Map()
46
+ const k = String(result && result.branch)
47
+ const next = (room.ciQueue.get(k) || Promise.resolve()).then(() => noteCiResultNow(room, result, opts))
48
+ room.ciQueue.set(k, next.catch(() => null))
49
+ return next
50
+ }
51
+
52
+ /** Whether commit `older` is an ancestor of `newer` in the relay's mirror of `rec`'s repository (false when it can't say). */
53
+ async function before (room, rec, older, newer) {
54
+ try {
55
+ const repo = parseRepo(`https://${rec.repo}`)
56
+ const dir = (await room.mirrors.ensure(repo, { url: room.gitRemoteUrl(repo) })).dir
57
+ return (await runGit(dir, ['merge-base', '--is-ancestor', older, newer])).ok
58
+ } catch { return false }
59
+ }
60
+
61
+ async function noteCiResultNow (room, { repo, sha, branch, conclusion }, { fetch = globalThis.fetch, log = () => {} } = {}) {
62
+ try {
63
+ const want = String(repo).toLowerCase().replace(/^github\.com\//, '')
64
+ const rec = Object.values(upstreamsOf(room)).find((r) => r && r.repo && r.ref === branch && String(r.repo).toLowerCase().replace(/^github\.com\//, '') === want)
65
+ if (!rec || !/^[0-9a-f]{40}$/.test(sha || '')) return null
66
+ const failed = FAILED.has(String(conclusion))
67
+ if (!failed && conclusion !== 'success') return null
68
+ // A failure on an older commit than one that passed since (GitHub's results can come out of order) is old news.
69
+ if (!room.ciPassed) room.ciPassed = new Map()
70
+ const passed = room.ciPassed.get(branch)
71
+ if (!failed) room.ciPassed.set(branch, sha)
72
+ else if (passed && passed !== sha && room.mirrors && await before(room, rec, sha, passed)) return null
73
+ const map = room.doc.getMap('tasks')
74
+ const id = ciTaskId(room, branch)
75
+ const task = readTasks(map).find((t) => t.id === id)
76
+ const open = task && !task.archived && task.column !== 'done'
77
+ const comment = (text) => addComment(room.doc, room.doc.getMap('taskComments'), readTasks(map), { taskId: id, by: RELAY_BY, text }, ORIGIN)
78
+ if (!failed) {
79
+ if (!open) return null
80
+ comment(`CI passes again on ${branch} at ${sha.slice(0, 7)}.`)
81
+ updateTask(room.doc, map, { id, column: 'done' }, ORIGIN)
82
+ return 'closed'
83
+ }
84
+ // Which checks failed, when GitHub says (a failing suite is enough to act on).
85
+ let names = []
86
+ try {
87
+ const r = parseRepo(`https://${rec.repo}`)
88
+ const c = await githubWriter({ fetch, token: await credentialsFor(room, r), owner: r.owner, name: r.name }).checks(sha)
89
+ names = c.failing || []
90
+ } catch (err) { log(`[${room.name}] ${branch}: could not read which checks failed on ${sha.slice(0, 7)}: ${err.message}`) }
91
+ const what = names.length ? names.slice(0, 5).join(', ') : 'checks'
92
+ // The relay shut down while GitHub was asked: the room notes it when it is back (server.js runCi).
93
+ if (room.destroyed) return null
94
+ const url = `https://${rec.repo}/commit/${sha}`
95
+ if (open) {
96
+ comment(`Still failing at ${sha.slice(0, 7)}: ${what}. ${url}`)
97
+ return 'noted'
98
+ }
99
+ const who = await authorOf(room, rec, sha)
100
+ if (room.destroyed) return null
101
+ const title = `CI failing on ${branch}: ${what}`
102
+ if (task) updateTask(room.doc, map, { id, title, column: 'todo', archived: false, ...(who ? { assignee: who } : {}) }, ORIGIN)
103
+ else addTask(room.doc, map, { id, title, by: RELAY_BY, assignee: who, column: 'todo' }, ORIGIN)
104
+ comment(`GitHub's checks failed on ${branch} at ${sha.slice(0, 7)}: ${what}. ${url}${who ? ` The commit is ${who}'s.` : ''}`)
105
+ log(`[${room.name}] ${branch}: CI failing at ${sha.slice(0, 7)} (${what}): task ${id}${who ? ` for ${who}` : ''}`)
106
+ return 'opened'
107
+ } catch (err) {
108
+ log(`[${room.name}] ${branch}: could not note CI on ${String(sha).slice(0, 7)}: ${err.message}`)
109
+ return null
110
+ }
111
+ }