heyquilt 0.0.1 → 0.3.106

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (221) hide show
  1. package/LICENSE +85 -0
  2. package/README.md +633 -2
  3. package/RELEASES.md +862 -0
  4. package/assets/logo.svg +1 -0
  5. package/bin/quilt.js +849 -0
  6. package/desktop/main.js +315 -0
  7. package/package.json +163 -7
  8. package/src/account.js +591 -0
  9. package/src/admit-policy.js +32 -0
  10. package/src/agent-join.js +370 -0
  11. package/src/agent-task-workflow.js +176 -0
  12. package/src/agents/actions.js +85 -0
  13. package/src/agents/claude-code.js +187 -0
  14. package/src/agents/common.js +21 -0
  15. package/src/agents/cursor-transcripts.js +177 -0
  16. package/src/agents/cursor.js +404 -0
  17. package/src/agents/index.js +69 -0
  18. package/src/ai-vm-signin.js +110 -0
  19. package/src/ai-vm.js +522 -0
  20. package/src/api/access.js +75 -0
  21. package/src/api/activity.js +181 -0
  22. package/src/api/agent-auth.js +136 -0
  23. package/src/api/agent-placement.js +157 -0
  24. package/src/api/audit.js +83 -0
  25. package/src/api/auth.js +17 -0
  26. package/src/api/domains.js +30 -0
  27. package/src/api/file-paths.js +40 -0
  28. package/src/api/file-store.js +99 -0
  29. package/src/api/fly-machines.js +133 -0
  30. package/src/api/github-app.js +172 -0
  31. package/src/api/http.js +34 -0
  32. package/src/api/invite-email.js +53 -0
  33. package/src/api/issues.js +110 -0
  34. package/src/api/join-text.js +78 -0
  35. package/src/api/mailer.js +47 -0
  36. package/src/api/memory-store.js +1219 -0
  37. package/src/api/org-access.js +54 -0
  38. package/src/api/permissions.js +71 -0
  39. package/src/api/relay-cluster.js +545 -0
  40. package/src/api/relay-machines.js +92 -0
  41. package/src/api/releases.js +87 -0
  42. package/src/api/routes/access-types.js +54 -0
  43. package/src/api/routes/agent-invites.js +115 -0
  44. package/src/api/routes/agents.js +252 -0
  45. package/src/api/routes/ai-vm.js +495 -0
  46. package/src/api/routes/github.js +380 -0
  47. package/src/api/routes/grants.js +70 -0
  48. package/src/api/routes/invites.js +188 -0
  49. package/src/api/routes/issues.js +47 -0
  50. package/src/api/routes/join.js +167 -0
  51. package/src/api/routes/members.js +110 -0
  52. package/src/api/routes/orgs.js +179 -0
  53. package/src/api/routes/relay-cluster.js +90 -0
  54. package/src/api/routes/relay.js +76 -0
  55. package/src/api/routes/secrets.js +448 -0
  56. package/src/api/routes/session-invites.js +119 -0
  57. package/src/api/routes/sessions.js +92 -0
  58. package/src/api/routes/teams.js +132 -0
  59. package/src/api/routes/workspace-agents.js +332 -0
  60. package/src/api/routes/workspace-files.js +277 -0
  61. package/src/api/routes/workspace-invites.js +196 -0
  62. package/src/api/routes/workspaces.js +232 -0
  63. package/src/api/sandbox-keys.js +57 -0
  64. package/src/api/secrets-access.js +93 -0
  65. package/src/api/server.js +654 -0
  66. package/src/api/slugs.js +20 -0
  67. package/src/api/supabase-store.js +875 -0
  68. package/src/api/team-access.js +30 -0
  69. package/src/api/tokens.js +31 -0
  70. package/src/api/workspace-access.js +62 -0
  71. package/src/api/workspace-reach.js +60 -0
  72. package/src/autowork-tools.js +166 -0
  73. package/src/autowork.js +375 -0
  74. package/src/blobstore.js +99 -0
  75. package/src/boxes.js +37 -0
  76. package/src/branchdocs.js +235 -0
  77. package/src/branches.js +194 -0
  78. package/src/catchup.js +111 -0
  79. package/src/chat-archive.js +85 -0
  80. package/src/chat-links.js +923 -0
  81. package/src/clash.js +128 -0
  82. package/src/cloud-ai.js +50 -0
  83. package/src/commit-request.js +59 -0
  84. package/src/commit.js +213 -0
  85. package/src/connection.js +569 -0
  86. package/src/control.js +248 -0
  87. package/src/conversation.js +125 -0
  88. package/src/doctor.js +156 -0
  89. package/src/duties.js +303 -0
  90. package/src/editors.js +228 -0
  91. package/src/fsutil.js +97 -0
  92. package/src/git-hook.js +48 -0
  93. package/src/git.js +147 -0
  94. package/src/github-commit.js +320 -0
  95. package/src/gitignore.js +49 -0
  96. package/src/gitstate.js +924 -0
  97. package/src/history.js +307 -0
  98. package/src/hooks.js +371 -0
  99. package/src/hosted-agents.js +24 -0
  100. package/src/hosted-client.js +112 -0
  101. package/src/identity.js +99 -0
  102. package/src/inbox.js +237 -0
  103. package/src/integrations.js +221 -0
  104. package/src/key-cli.js +115 -0
  105. package/src/largefiles.js +91 -0
  106. package/src/legacy.js +25 -0
  107. package/src/mcp-proxy.js +45 -0
  108. package/src/mcp-room.js +32 -0
  109. package/src/mcp.js +1258 -0
  110. package/src/merge3.js +240 -0
  111. package/src/merges.js +148 -0
  112. package/src/pass-source.js +183 -0
  113. package/src/passes.js +64 -0
  114. package/src/pathrules.js +134 -0
  115. package/src/persona.js +101 -0
  116. package/src/presence.js +360 -0
  117. package/src/private-file.js +41 -0
  118. package/src/procs.js +70 -0
  119. package/src/project-rules.js +178 -0
  120. package/src/protocol.js +92 -0
  121. package/src/quiet-warnings.js +9 -0
  122. package/src/relay-ai-vm.js +82 -0
  123. package/src/relay-ask.js +141 -0
  124. package/src/relay-ci.js +111 -0
  125. package/src/relay-clash.js +214 -0
  126. package/src/relay-cluster.js +454 -0
  127. package/src/relay-commit.js +887 -0
  128. package/src/relay-filekey.js +46 -0
  129. package/src/relay-git.js +274 -0
  130. package/src/relay-githttp.js +166 -0
  131. package/src/relay-github.js +109 -0
  132. package/src/relay-lease.js +137 -0
  133. package/src/relay-lfs.js +37 -0
  134. package/src/relay-mcp.js +1691 -0
  135. package/src/relay-metrics.js +140 -0
  136. package/src/relay-outbound.js +1273 -0
  137. package/src/relay-sync.js +716 -0
  138. package/src/relay-turn-sync.js +506 -0
  139. package/src/relay-upstream.js +804 -0
  140. package/src/relay-webhooks.js +111 -0
  141. package/src/releases.js +138 -0
  142. package/src/report.js +129 -0
  143. package/src/runner.js +307 -0
  144. package/src/same-edit.js +18 -0
  145. package/src/secret-import.js +55 -0
  146. package/src/secret-names.js +26 -0
  147. package/src/secret-run.js +80 -0
  148. package/src/secrets-client.js +56 -0
  149. package/src/server.js +4214 -0
  150. package/src/session-access.js +170 -0
  151. package/src/session-name.js +15 -0
  152. package/src/session.js +7446 -0
  153. package/src/settings.js +46 -0
  154. package/src/setup.js +184 -0
  155. package/src/shell-path.js +46 -0
  156. package/src/status.js +197 -0
  157. package/src/summarize.js +109 -0
  158. package/src/sync-words.js +42 -0
  159. package/src/task-comments.js +92 -0
  160. package/src/tasks.js +670 -0
  161. package/src/textdiff.js +20 -0
  162. package/src/turn-sync-plan.js +145 -0
  163. package/src/turn-sync.js +212 -0
  164. package/src/ui/access-form.js +37 -0
  165. package/src/ui/agent-env-key.js +76 -0
  166. package/src/ui/agent-guide.js +127 -0
  167. package/src/ui/agent-kinds.js +175 -0
  168. package/src/ui/agent-place.js +265 -0
  169. package/src/ui/app.css +2494 -0
  170. package/src/ui/app.js +546 -0
  171. package/src/ui/board.js +465 -0
  172. package/src/ui/boxes.js +76 -0
  173. package/src/ui/branches.js +111 -0
  174. package/src/ui/catchup.js +77 -0
  175. package/src/ui/changes.js +159 -0
  176. package/src/ui/chat.js +266 -0
  177. package/src/ui/cloud-ai-model.js +108 -0
  178. package/src/ui/cloud-ai.js +221 -0
  179. package/src/ui/common.js +433 -0
  180. package/src/ui/csv.js +21 -0
  181. package/src/ui/diffview.js +74 -0
  182. package/src/ui/feed-convs.js +39 -0
  183. package/src/ui/feed.js +174 -0
  184. package/src/ui/files.js +579 -0
  185. package/src/ui/fileview.js +150 -0
  186. package/src/ui/git-rule.js +60 -0
  187. package/src/ui/home.js +1322 -0
  188. package/src/ui/index.html +16 -0
  189. package/src/ui/invite.js +97 -0
  190. package/src/ui/invites.js +79 -0
  191. package/src/ui/loom-model.js +516 -0
  192. package/src/ui/loom.css +365 -0
  193. package/src/ui/loom.js +786 -0
  194. package/src/ui/mark.js +104 -0
  195. package/src/ui/rail-model.js +75 -0
  196. package/src/ui/rail.js +173 -0
  197. package/src/ui/releases.js +182 -0
  198. package/src/ui/sandbox-keys-model.js +53 -0
  199. package/src/ui/sandbox-keys.js +132 -0
  200. package/src/ui/schedule.js +156 -0
  201. package/src/ui/secret-rules.js +255 -0
  202. package/src/ui/secrets-view.js +162 -0
  203. package/src/ui/secrets.js +283 -0
  204. package/src/ui/session.js +3292 -0
  205. package/src/ui/signin.js +75 -0
  206. package/src/ui/sounds.js +110 -0
  207. package/src/ui/tool-logo.js +86 -0
  208. package/src/ui/tree.js +281 -0
  209. package/src/ui/workspaces.js +547 -0
  210. package/src/ui-server.js +1381 -0
  211. package/src/update-check.js +67 -0
  212. package/src/upstream.js +192 -0
  213. package/src/vault/crypto.js +96 -0
  214. package/src/vault/kms.js +61 -0
  215. package/src/vault/main.js +33 -0
  216. package/src/vault/server.js +279 -0
  217. package/src/vault/sigv4.js +38 -0
  218. package/src/webhooks.js +235 -0
  219. package/src/workspace-cli.js +62 -0
  220. package/src/workspace-tools.js +340 -0
  221. package/index.js +0 -2
package/src/procs.js ADDED
@@ -0,0 +1,70 @@
1
+ // Registry of running quilt processes (relays, the app, folder syncs) so
2
+ // `quilt stop` and the app's Shut down button can stop all of them.
3
+ // Each process writes ~/.quilt/procs/<pid>.json and removes it on exit.
4
+ import fs from 'node:fs'
5
+ import os from 'node:os'
6
+ import path from 'node:path'
7
+ import { quiltHome } from './legacy.js'
8
+
9
+ const procsDir = () => path.join(quiltHome(), 'procs')
10
+
11
+ const alive = (pid) => {
12
+ try { process.kill(pid, 0); return true } catch (err) { return err.code === 'EPERM' }
13
+ }
14
+
15
+ /** Records this process as a running quilt `kind` ('relay', 'app', 'sync'). */
16
+ export function registerProcess (kind, info = {}) {
17
+ const file = path.join(procsDir(), `${process.pid}.json`)
18
+ fs.mkdirSync(procsDir(), { recursive: true, mode: 0o700 })
19
+ // Private: an app's entry carries its link (with the per-launch token).
20
+ fs.writeFileSync(file, JSON.stringify({ pid: process.pid, kind, ...info, startedAt: Date.now() }), { mode: 0o600 })
21
+ fs.chmodSync(file, 0o600)
22
+ process.on('exit', () => { try { fs.rmSync(file, { force: true }) } catch {} })
23
+ }
24
+
25
+ /** Running quilt processes, oldest first. Cleans up entries for dead ones. */
26
+ export function listProcesses () {
27
+ let files = []
28
+ try { files = fs.readdirSync(procsDir()).filter((f) => f.endsWith('.json')) } catch { return [] }
29
+ const out = []
30
+ for (const f of files) {
31
+ const file = path.join(procsDir(), f)
32
+ let info
33
+ try { info = JSON.parse(fs.readFileSync(file, 'utf8')) } catch { info = null }
34
+ if (info && alive(info.pid)) out.push(info)
35
+ else fs.rmSync(file, { force: true })
36
+ }
37
+ return out.sort((a, b) => a.startedAt - b.startedAt)
38
+ }
39
+
40
+ /**
41
+ * Stops every registered quilt process except `exclude` (a pid). Asks nicely
42
+ * first (so syncs flush and relays save), then force-kills stragglers.
43
+ * Returns the processes that were stopped.
44
+ */
45
+ export async function stopProcesses ({ exclude = process.pid, timeoutMs = 5000 } = {}) {
46
+ const targets = listProcesses().filter((p) => p.pid !== exclude)
47
+ for (const p of targets) { try { process.kill(p.pid, 'SIGTERM') } catch {} }
48
+ const deadline = Date.now() + timeoutMs
49
+ while (targets.some((p) => alive(p.pid)) && Date.now() < deadline) await new Promise((r) => setTimeout(r, 100))
50
+ for (const p of targets) {
51
+ if (alive(p.pid)) { try { process.kill(p.pid, 'SIGKILL') } catch {} }
52
+ fs.rmSync(path.join(procsDir(), `${p.pid}.json`), { force: true })
53
+ }
54
+ return targets
55
+ }
56
+
57
+ /** The newest running app's private link, preferring the desktop app; null if none. */
58
+ export function runningAppUrl () {
59
+ const apps = listProcesses().filter((p) => p.kind === 'app' && p.url)
60
+ const pick = apps.filter((p) => p.desktop).pop() || apps.pop()
61
+ return pick ? pick.url : null
62
+ }
63
+
64
+ /** One-line description of a registered process, for `quilt stop`. */
65
+ export function describeProcess (p) {
66
+ if (p.kind === 'relay') return `relay on :${p.port} (pid ${p.pid})`
67
+ if (p.kind === 'app') return `app on :${p.port} (pid ${p.pid})`
68
+ if (p.kind === 'sync') return `sync of ${p.dir} (pid ${p.pid})`
69
+ return `${p.kind} (pid ${p.pid})`
70
+ }
@@ -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
+ }