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,172 @@
1
+ // Quilt's GitHub App ("Quilt by heyquilt"): a person connects their GitHub account once, installs
2
+ // the app on the repositories they choose, and from then on the relay commits agents' work there
3
+ // (relay-commit.js) with short-lived credentials made for one repository at a time. Nobody creates
4
+ // or pastes a token, and nobody needs to be online.
5
+ //
6
+ // The app's private key lives only on this API. The relay asks for a repository's credentials
7
+ // for a session's owner (routes/github.js); they are given only when the owner's GitHub account
8
+ // may write to that repository, so a session can't point agents at a repository its owner can't
9
+ // push to just because the app is installed there.
10
+ //
11
+ // GitHub is the first host. The relay only sees { token, expiresAt } for a repository, so other
12
+ // hosts (GitLab, Bitbucket, Azure DevOps, Gitea) can be added the same way.
13
+ import crypto from 'node:crypto'
14
+
15
+ const API = 'https://api.github.com'
16
+ export const WRITE_ROLES = ['admin', 'maintain', 'write']
17
+ export const READ_ROLES = [...WRITE_ROLES, 'triage', 'read']
18
+ // What the relay's credentials may do: commit (contents, workflow files), open and merge pull
19
+ // requests and read checks (write); or fetch and clone (read: a folder's git, for agents).
20
+ export const COMMIT_PERMISSIONS = { contents: 'write', pull_requests: 'write', workflows: 'write', checks: 'read', statuses: 'read', metadata: 'read' }
21
+ export const READ_PERMISSIONS = { contents: 'read', metadata: 'read' }
22
+
23
+ const b64url = (buf) => Buffer.from(buf).toString('base64').replace(/=+$/, '').replace(/\+/g, '-').replace(/\//g, '_')
24
+
25
+ /** The app's settings from the environment, or null when it isn't set up. The key may be base64 of the PEM. */
26
+ export function appConfig (env = process.env) {
27
+ const id = String(env.GITHUB_APP_ID || '').trim()
28
+ let key = String(env.GITHUB_APP_PRIVATE_KEY || '').trim()
29
+ if (key && !key.includes('BEGIN')) { try { key = Buffer.from(key, 'base64').toString('utf8') } catch { key = '' } }
30
+ const cfg = { id, key, slug: String(env.GITHUB_APP_SLUG || '').trim(), clientId: String(env.GITHUB_APP_CLIENT_ID || '').trim(), clientSecret: String(env.GITHUB_APP_CLIENT_SECRET || '').trim(), webhookSecret: String(env.GITHUB_APP_WEBHOOK_SECRET || '').trim() }
31
+ return cfg.id && cfg.key.includes('PRIVATE KEY') && cfg.slug && cfg.clientId && cfg.clientSecret ? cfg : null
32
+ }
33
+
34
+ /** The app's own sign-in (a JWT, ten minutes at most), for asking GitHub about installations. */
35
+ export function appJwt (cfg, now = Date.now()) {
36
+ const iat = Math.floor(now / 1000) - 60 // GitHub's clock may be a little behind
37
+ const head = b64url(JSON.stringify({ alg: 'RS256', typ: 'JWT' }))
38
+ const body = b64url(JSON.stringify({ iat, exp: iat + 9 * 60, iss: cfg.id }))
39
+ const sig = crypto.sign('RSA-SHA256', Buffer.from(`${head}.${body}`), cfg.key)
40
+ return `${head}.${body}.${b64url(sig)}`
41
+ }
42
+
43
+ /** A link's `state`: who started connecting (signed, for ten minutes), so the callback knows whose account it is. */
44
+ export function signState (cfg, userId, now = Date.now(), { scope = '', scopeId = '' } = {}) {
45
+ // A connection for a workspace or an org carries its scope (w or o) and id along with the person.
46
+ const who = scope && scopeId ? `${userId}~${scope === 'workspace' ? 'w' : 'o'}${scopeId}` : userId
47
+ const payload = `${who}.${now + 10 * 60 * 1000}`
48
+ const mac = crypto.createHmac('sha256', cfg.clientSecret).update(payload).digest('hex').slice(0, 32)
49
+ return `${payload}.${mac}`
50
+ }
51
+
52
+ /** What a state made by signState says: { userId, scope?, scopeId? }, or null (forged, or older than ten minutes). */
53
+ export function readStateFull (cfg, state, now = Date.now()) {
54
+ const m = /^([A-Za-z0-9_-]{1,64})(?:~([wo])([0-9a-f-]{36}))?\.(\d{1,16})\.([0-9a-f]{32})$/.exec(String(state || ''))
55
+ if (!m) return null
56
+ const who = m[2] ? `${m[1]}~${m[2]}${m[3]}` : m[1]
57
+ const want = crypto.createHmac('sha256', cfg.clientSecret).update(`${who}.${m[4]}`).digest('hex').slice(0, 32)
58
+ if (!crypto.timingSafeEqual(Buffer.from(want), Buffer.from(m[5]))) return null
59
+ if (Number(m[4]) < now) return null
60
+ return { userId: m[1], ...(m[2] ? { scope: m[2] === 'w' ? 'workspace' : 'org', scopeId: m[3] } : {}) }
61
+ }
62
+
63
+ /** The user id in a state made by signState, or null (forged, or older than ten minutes). */
64
+ export function readState (cfg, state, now = Date.now()) {
65
+ const r = readStateFull(cfg, state, now)
66
+ return r ? r.userId : null
67
+ }
68
+
69
+ export class GitHubAppError extends Error {
70
+ constructor (status, message) { super(message); this.status = status }
71
+ }
72
+
73
+ /** The calls the API makes to GitHub for the app. `fetch` is for tests. */
74
+ export function githubApp (cfg, { fetch = globalThis.fetch, now = Date.now } = {}) {
75
+ const accountIds = new Map() // login -> { id, at }
76
+ const call = async (method, url, { auth, body, form } = {}) => {
77
+ const res = await fetch(url.startsWith('http') ? url : `${API}${url}`, {
78
+ method,
79
+ headers: { 'user-agent': 'quilt-api', accept: 'application/json', 'x-github-api-version': '2022-11-28', ...(auth ? { authorization: auth } : {}), ...(body || form ? { 'content-type': 'application/json' } : {}) },
80
+ ...(body || form ? { body: JSON.stringify(body || form) } : {})
81
+ })
82
+ let json = null
83
+ try { json = await res.json() } catch {}
84
+ if (!res.ok) throw new GitHubAppError(res.status, (json && json.message) || `GitHub answered ${res.status}`)
85
+ return json
86
+ }
87
+ const asApp = () => `Bearer ${appJwt(cfg, now())}`
88
+ return {
89
+ /** The link that installs the app (or changes which repositories it has), carrying `state`; `targetId` goes straight to that account's install page. */
90
+ installUrl: (state, { targetId = null } = {}) => targetId
91
+ ? `https://github.com/apps/${cfg.slug}/installations/new/permissions?target_id=${encodeURIComponent(targetId)}&state=${encodeURIComponent(state)}`
92
+ : `https://github.com/apps/${cfg.slug}/installations/new?state=${encodeURIComponent(state)}`,
93
+ /** A GitHub account's (person's or organization's) id, from its public profile; remembered for a day. */
94
+ async accountId (login) {
95
+ const hit = accountIds.get(login.toLowerCase())
96
+ if (hit && now() - hit.at < 24 * 60 * 60 * 1000) return hit.id
97
+ const u = await call('GET', `/users/${encodeURIComponent(login)}`)
98
+ const id = Number(u && u.id) || null
99
+ if (id) accountIds.set(login.toLowerCase(), { id, at: now() })
100
+ return id
101
+ },
102
+ /** Who signed in on GitHub: the code from the callback, exchanged and asked. { id, login }. */
103
+ async whoAuthorized (code) {
104
+ const t = await call('POST', 'https://github.com/login/oauth/access_token', { form: { client_id: cfg.clientId, client_secret: cfg.clientSecret, code } })
105
+ if (!t || !t.access_token) throw new GitHubAppError(400, (t && (t.error_description || t.error)) || 'GitHub did not sign you in')
106
+ const u = await call('GET', '/user', { auth: `Bearer ${t.access_token}` })
107
+ return { id: Number(u.id), login: String(u.login) }
108
+ },
109
+ /** The app's installation on owner/name, or null when it isn't installed there. */
110
+ async installation (owner, name) {
111
+ try { return (await call('GET', `/repos/${owner}/${name}/installation`, { auth: asApp() })).id } catch (err) { if (err.status === 404) return null; throw err }
112
+ },
113
+ /** Credentials for one repository of an installation, for about an hour: { token, expiresAt }. */
114
+ async repoToken (installationId, name, { access = 'write' } = {}) {
115
+ const r = await call('POST', `/app/installations/${installationId}/access_tokens`, { auth: asApp(), body: { repositories: [name], permissions: access === 'read' ? READ_PERMISSIONS : COMMIT_PERMISSIONS } })
116
+ return { token: r.token, expiresAt: Date.parse(r.expires_at) || now() + 50 * 60 * 1000 }
117
+ },
118
+ /** Sets the app's webhook to deliver JSON signed with `secret` (PATCH /app/hook/config, as the app). Idempotent. */
119
+ async setWebhookSecret (secret) {
120
+ await call('PATCH', '/app/hook/config', { auth: asApp(), body: { content_type: 'json', secret } })
121
+ },
122
+ /** The app's recent webhook deliveries that failed and were not delivered again yet: [{ id }]. */
123
+ async failedDeliveries () {
124
+ const list = await call('GET', '/app/hook/deliveries?per_page=100', { auth: asApp() })
125
+ return (Array.isArray(list) ? list : []).filter((d) => d && Number.isInteger(d.id) && !d.redelivery && (d.status_code >= 400 || d.status_code === 0 || (d.status && d.status !== 'OK'))).map((d) => ({ id: d.id }))
126
+ },
127
+ /** Asks GitHub to deliver webhook `id` again. */
128
+ async redeliver (id) { await call('POST', `/app/hook/deliveries/${Number(id)}/attempts`, { auth: asApp() }) },
129
+ /** What `login` may do in owner/name ('admin', 'maintain', 'write', 'triage', 'read' or 'none'), asked with `token`. */
130
+ async role (token, owner, name, login) {
131
+ try {
132
+ const r = await call('GET', `/repos/${owner}/${name}/collaborators/${encodeURIComponent(login)}/permission`, { auth: `Bearer ${token}` })
133
+ return String((r && (r.role_name || r.permission)) || 'none')
134
+ } catch (err) { if (err.status === 404 || err.status === 403) return 'none'; throw err }
135
+ }
136
+ }
137
+ }
138
+
139
+ /**
140
+ * The webhook secret the API verifies GitHub's events with, set on the app by the API itself at
141
+ * start (PATCH /app/hook/config): the one from the environment when given, else one made once and
142
+ * kept in the store (`github_webhook_secret`), so nobody pastes a secret into GitHub's settings.
143
+ * `hook.value` is where the route reads it. Resolves the secret, or '' when the app isn't set up.
144
+ */
145
+ export async function ensureWebhookSecret ({ cfg, app, store, hook, log = () => {} }) {
146
+ if (!cfg || !app) return ''
147
+ let secret = cfg.webhookSecret || ''
148
+ if (!secret && store && store.getSetting) {
149
+ secret = (await store.getSetting('github_webhook_secret')) || ''
150
+ if (!secret) {
151
+ // Two API machines starting at once each make one: the first stored is the one both use.
152
+ const made = crypto.randomBytes(32).toString('base64url')
153
+ if (store.setSettingIfAbsent) secret = await store.setSettingIfAbsent('github_webhook_secret', made)
154
+ else { await store.setSetting('github_webhook_secret', made); secret = made }
155
+ }
156
+ }
157
+ if (!secret) return ''
158
+ hook.value = secret
159
+ try { await app.setWebhookSecret(secret) } catch (err) { log(`github: could not set the app's webhook secret: ${err.message}`) }
160
+ return secret
161
+ }
162
+
163
+ /** At start: GitHub delivers again every recent webhook that failed to reach us (a deploy, an outage). How many were asked for. */
164
+ export async function redeliverFailed ({ app, log = () => {} }) {
165
+ if (!app) return 0
166
+ let n = 0
167
+ try {
168
+ for (const d of await app.failedDeliveries()) { try { await app.redeliver(d.id); n++ } catch (err) { log(`github: could not ask for webhook ${d.id} again: ${err.message}`) } }
169
+ } catch (err) { log(`github: could not list webhook deliveries: ${err.message}`) }
170
+ if (n) log(`github: asked for ${n} failed webhook delivery(ies) again`)
171
+ return n
172
+ }
@@ -0,0 +1,34 @@
1
+ // Small pieces every API route module shares.
2
+ export class HttpError extends Error { constructor (status, message) { super(message); this.status = status } }
3
+
4
+ export const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
5
+
6
+ /** Ids are uuids; anything else can't exist (and Postgres would reject it), so it's a 404. */
7
+ export function needId (id, what = 'thing') {
8
+ if (!UUID.test(String(id || ''))) throw new HttpError(404, `no such ${what}`)
9
+ return String(id)
10
+ }
11
+
12
+ // C0/C1 controls, zero-width characters (including the byte-order mark), and
13
+ // bidi overrides/isolates: none of these belong in a name, and a bidi override
14
+ // can make a name display backwards or hide characters, e.g. in an email subject.
15
+ const INVISIBLE = /[\u0000-\u001f\u007f-\u009f​-‏‪-‮⁠⁦-⁩؜]/
16
+
17
+ /** The code points of `value` with control/zero-width/bidi characters removed. */
18
+ export function stripInvisible (value) {
19
+ return Array.from(String(value ?? '')).filter((ch) => !INVISIBLE.test(ch))
20
+ }
21
+
22
+ /** A trimmed name with no control characters (it may end up in an email subject).
23
+ * Slices by code point, not UTF-16 unit, so cutting at `max` never splits a
24
+ * surrogate pair (e.g. an emoji) into two lone, invalid surrogates. */
25
+ export function cleanName (value, max, message) {
26
+ const s = stripInvisible(value).slice(0, max).join('').trim()
27
+ if (!s) throw new HttpError(400, message)
28
+ return s
29
+ }
30
+
31
+ /** A non-JSON reply, e.g. the plain-text join instructions an AI reads. */
32
+ export class Raw {
33
+ constructor (status, body, type = 'text/markdown; charset=utf-8') { this.status = status; this.body = body; this.type = type }
34
+ }
@@ -0,0 +1,53 @@
1
+ // The emails the accounts API sends itself: org, session and workspace invites.
2
+ export function inviteEmail ({ orgName, inviterName, roleName, link }) {
3
+ const who = inviterName || 'Someone'
4
+ return {
5
+ subject: `${who} invited you to ${orgName} on Quilt`,
6
+ text: [
7
+ `${who} invited you to join ${orgName} on Quilt as ${roleName}.`,
8
+ '',
9
+ `Accept the invite: ${link}`,
10
+ '',
11
+ 'Sign in, or create your account, with this email address. The link works for 7 days.',
12
+ "If you weren't expecting this, you can ignore this email."
13
+ ].join('\n')
14
+ }
15
+ }
16
+
17
+ // Every invite comes from the API's one sender (SMTP_FROM, e.g. "Quilt <hello@heyquilt.com>"): the mailer fills it in.
18
+
19
+ /** An invite to one session. `link` is the session's join link (it holds the room secret); `site` is heyquilt.com. */
20
+ export function sessionInviteEmail ({ inviterName, sessionName, link, site }) {
21
+ const who = inviterName || 'Someone'
22
+ return {
23
+ subject: `${who} invited you to ${sessionName} on Quilt`,
24
+ text: [
25
+ `${who} invited you to ${sessionName}, a live Quilt session where people and their AIs build one project together.`,
26
+ '',
27
+ `Join the session: ${link}`,
28
+ '',
29
+ 'This invite expires in 7 days.',
30
+ '',
31
+ `New to Quilt? Download the app from ${site}, sign in with this email address, then open the link again.`,
32
+ "If you weren't expecting this, you can ignore this email."
33
+ ].join('\n')
34
+ }
35
+ }
36
+
37
+ /** An invite to a workspace. It is accepted in Quilt: the app's home, or `site`/dashboard. */
38
+ export function workspaceInviteEmail ({ inviterName, workspaceName, access, site }) {
39
+ const who = inviterName || 'Someone'
40
+ return {
41
+ subject: `${who} invited you to the ${workspaceName} workspace on Quilt`,
42
+ text: [
43
+ `${who} invited you to ${workspaceName}, a Quilt workspace: its sessions, files and people in one place. You would ${access === 'view' ? 'be able to view it' : 'be able to edit it'}.`,
44
+ '',
45
+ `Accept it in the Quilt app, or at ${site}/dashboard`,
46
+ '',
47
+ 'This invite expires in 7 days.',
48
+ '',
49
+ `New to Quilt? Create your account at ${site}/signup with this email address and the invite will be waiting for you.`,
50
+ "If you weren't expecting this, you can ignore this email."
51
+ ].join('\n')
52
+ }
53
+ }
@@ -0,0 +1,110 @@
1
+ // What an issue report looks like, and how two reports of the same problem end up
2
+ // in one issue: shared by the /v1/issues route, the API's own error recording and
3
+ // both stores, so the memory store (the tested reference) and Postgres agree.
4
+ import crypto from 'node:crypto'
5
+ import { HttpError, UUID, stripInvisible } from './http.js'
6
+
7
+ export const SURFACES = ['app', 'api', 'web']
8
+ export const KINDS = ['action', 'error', 'http404', 'crash']
9
+ export const OUTCOMES = ['ok', 'slow', 'error']
10
+ export const MAX_BATCH = 20
11
+ const DAY = 24 * 60 * 60 * 1000
12
+ const MAX_CONTEXT_KEYS = 16
13
+ const MAX_CONTEXT_BYTES = 2048
14
+
15
+ const cut = (value, max) => stripInvisible(value).slice(0, max).join('').trim()
16
+
17
+ /** A message with the parts that differ between repeats of one problem replaced. */
18
+ export function normalizeMessage (message) {
19
+ return String(message ?? '').toLowerCase()
20
+ .replace(/(["'`])(?:(?!\1).){1,500}\1/g, '<str>')
21
+ .replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g, '<id>')
22
+ .replace(/\b[0-9a-f]{8,}\b/g, '<hex>')
23
+ .replace(/(?:~|[a-z]:\\|\/)[^\s'"`:,()]*[\\/][^\s'"`:,()]*/g, '<path>')
24
+ .replace(/\d+(\.\d+)?/g, '<n>')
25
+ .replace(/\s+/g, ' ')
26
+ .trim()
27
+ .slice(0, 200)
28
+ }
29
+
30
+ /** The same problem on the same surface, kind and name always gets this fingerprint. */
31
+ export function fingerprint ({ surface, kind, name, message }) {
32
+ return crypto.createHash('sha256').update([surface, kind, name, normalizeMessage(message)].join('|')).digest('hex')
33
+ }
34
+
35
+ /** Context is a few flat facts (which editor, which route): scalars only, bounded. Never throws. */
36
+ export function cleanContext (value) {
37
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return {}
38
+ const out = {}
39
+ for (const [k, v] of Object.entries(value)) {
40
+ if (Object.keys(out).length >= MAX_CONTEXT_KEYS) break
41
+ const key = cut(k, 40)
42
+ if (!key) continue
43
+ if (typeof v === 'string') out[key] = cut(v, 200)
44
+ else if (typeof v === 'number' && Number.isFinite(v)) out[key] = v
45
+ else if (typeof v === 'boolean') out[key] = v
46
+ }
47
+ // Still too big (16 long strings): drop fields from the end until it fits.
48
+ while (JSON.stringify(out).length > MAX_CONTEXT_BYTES) delete out[Object.keys(out).pop()]
49
+ return out
50
+ }
51
+
52
+ const oneOf = (list, value, what) => {
53
+ const v = String(value ?? '')
54
+ if (!list.includes(v)) throw new HttpError(400, `${what} must be one of ${list.join(', ')}`)
55
+ return v
56
+ }
57
+ // Postgres's `integer` column can't hold more than this; a value outside it would turn a
58
+ // whole batch into a 500 (22003). Clamping to null here keeps a bad report a 400, never a crash.
59
+ const INT_MAX = 2_147_483_647
60
+ const intOrNull = (v) => {
61
+ if (typeof v !== 'number' || !Number.isFinite(v)) return null
62
+ const n = Math.round(v)
63
+ return n >= 0 && n <= INT_MAX ? n : null
64
+ }
65
+
66
+ /**
67
+ * One event as the stores take it. `surface`, `appVersion`, `platform`, `userId` and
68
+ * `deviceId` are decided by the caller (the route knows who is reporting), never by the body.
69
+ */
70
+ export function cleanEvent (raw, { surface, appVersion = '', platform = '', userId = null, deviceId = null, now = Date.now }) {
71
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new HttpError(400, 'each event must be an object')
72
+ const kind = oneOf(KINDS, raw.kind, 'kind')
73
+ const outcome = oneOf(OUTCOMES, raw.outcome ?? 'ok', 'outcome')
74
+ const name = cut(raw.name, 80)
75
+ if (!name) throw new HttpError(400, 'name is empty')
76
+ const t = now()
77
+ const at = typeof raw.occurredAt === 'number' ? raw.occurredAt : Date.parse(raw.occurredAt)
78
+ const occurredAt = Number.isFinite(at) ? Math.min(Math.max(at, t - DAY), t + DAY) : t
79
+ const message = cut(raw.message, 500)
80
+ const e = {
81
+ surface: oneOf(SURFACES, surface, 'surface'),
82
+ kind, name, outcome,
83
+ status: intOrNull(raw.status),
84
+ durationMs: intOrNull(raw.durationMs),
85
+ message,
86
+ appVersion: cut(appVersion, 40),
87
+ platform: cut(platform, 40),
88
+ userId: userId ? String(userId) : null,
89
+ deviceId: deviceId ? String(deviceId) : null,
90
+ context: cleanContext(raw.context),
91
+ occurredAt,
92
+ fingerprint: null
93
+ }
94
+ if (outcome !== 'ok') e.fingerprint = fingerprint(e)
95
+ return e
96
+ }
97
+
98
+ /** "GET /v1/orgs/:slug/members": a request's path with the parts that vary replaced, so requests group. */
99
+ export function routeName (method, pathname) {
100
+ const segs = String(pathname || '/').split('/')
101
+ const parts = segs.map((p, i) => {
102
+ if (UUID.test(p)) return ':id'
103
+ if (/^[A-Za-z0-9_-]{24,64}$/.test(p)) return ':token'
104
+ if (/^[A-Z0-9]{4}-[A-Z0-9]{4}$/.test(p)) return ':code'
105
+ // The slug right after /orgs/ varies per org, same as an id would; group them too.
106
+ if (segs[i - 1] === 'orgs') return ':slug'
107
+ return p
108
+ })
109
+ return `${method} ${parts.join('/')}`.slice(0, 80)
110
+ }
@@ -0,0 +1,78 @@
1
+ // What an AI reads when it opens an agent invite link. Plain text, so any AI
2
+ // that can fetch a URL can follow it.
3
+ import { agentGuide, CLI_FIRST, INSTALL_CLI, KEEP_JOIN_RUNNING, SANDBOX_CLI } from '../ui/agent-guide.js'
4
+
5
+ const STATUS = {
6
+ waiting: (expiresAt) => `Status: this invite is open. It works once, until ${new Date(expiresAt).toISOString()}.`,
7
+ used: () => 'Status: this invite was already used. Ask the person who sent it for a new one.',
8
+ expired: () => 'Status: this invite has expired. Ask the person who sent it for a new one.',
9
+ cancelled: () => 'Status: this invite was cancelled. Ask the person who sent it for a new one.',
10
+ unknown: () => "Status: this invite link isn't valid. Check that you copied all of it."
11
+ }
12
+
13
+ // Shared by the invite page and the paste: how an agent comes back as itself.
14
+ export const REJOIN = 'If you joined Quilt before, send the agentId Quilt gave you then: you come back as the same agent (your name, history, tasks and access), with new keys, instead of as a second agent. Your agentId is public, like a username: it only says who you are, and this invite is what lets you in. If you never joined, or lost it, leave agentId out and you join as a new agent.'
15
+
16
+ export function joinInstructions ({ link, apiUrl, status, expiresAt }) {
17
+ return [
18
+ '# Join Quilt as an agent',
19
+ '',
20
+ STATUS[status](expiresAt),
21
+ '',
22
+ 'Quilt lets people and AI agents work in the same project folder in real time. A person invited you to join as their agent.',
23
+ '',
24
+ CLI_FIRST,
25
+ '',
26
+ '## If you can run commands (anywhere): use the Quilt CLI',
27
+ '',
28
+ `${INSTALL_CLI} Then run \`quilt agent join ${link} --name <your name>\` and, in the project folder, \`quilt join <session invite link> --agent <your name>\`. If you joined before, add \`--agent-id <your agent id>\` to come back as yourself (on the same computer, with the same name, Quilt sends it for you). ${KEEP_JOIN_RUNNING}`,
29
+ '',
30
+ '## In a sandbox wiped after every turn: one key and `npx heyquilt sync`',
31
+ '',
32
+ SANDBOX_CLI,
33
+ '',
34
+ '## Only if you have no computer: join over HTTP',
35
+ '',
36
+ 'Skip this section if you can run shell commands anywhere: use the CLI above. This is for a bot that can only make HTTP requests.',
37
+ '',
38
+ 'Send one request:',
39
+ '',
40
+ `POST ${link}`,
41
+ 'Content-Type: application/json',
42
+ '',
43
+ '{"name": "your name, up to 40 characters", "provider": "who made you, e.g. xAI, Anthropic, OpenAI or Cursor", "type": "what you are, e.g. coding agent", "description": "optional, up to 180 characters", "agentId": "optional: your agent id, if you joined Quilt before"}',
44
+ '',
45
+ 'If you can only fetch URLs, open this instead, with your own values in place of the',
46
+ '<placeholders> (description and agentId are optional):',
47
+ '',
48
+ `GET ${link}?name=<your-agent-name>&provider=<provider>&type=<type>&description=<short-description>&agentId=<your-agent-id>`,
49
+ '',
50
+ REJOIN,
51
+ '',
52
+ 'The reply is JSON with your agentId, an access key (valid for 1 hour), a refresh key (valid for 30 days, single use) and a resume key (does not expire). Keep the three keys secret, in one place only. Your agentId is not a secret: save it where you will still have it next time (your memory, notes or config), next to your name.',
53
+ `Send the access key as "Authorization: Bearer <accessKey>". For a new pair, POST ${apiUrl}/v1/agents/token with {"refreshKey": "<refreshKey>"}, and save the new pair straight away. Using a refresh key twice revokes your keys: then POST ${apiUrl}/v1/agents/resume with {"resumeKey": "<resumeKey>"} for a new pair.`,
54
+ '',
55
+ 'Opening this link without a name does not use the invite.',
56
+ '',
57
+ agentGuide({ apiUrl }),
58
+ ''
59
+ ].join('\n')
60
+ }
61
+
62
+ /**
63
+ * The short "what now" an agent gets with its first keys. `hasKey` is true when it
64
+ * sent a publicKey when it joined: then it can also enter sessions from a computer
65
+ * running Quilt. Every agent can join through the hosted MCP.
66
+ */
67
+ export function joinNext ({ name, agentId, apiUrl, hasKey, rejoined = false }) {
68
+ const mcp = `${apiUrl}/mcp`
69
+ return [
70
+ rejoined ? `Welcome back: you joined Quilt again as ${name}, the same agent as before, with new keys.` : `You joined Quilt as ${name}. Keep your keys secret.`,
71
+ agentId ? `Your agent id is ${agentId}. Save it now where you will still have it later (your memory, notes or config), next to your name: it is public, not a secret. If you are ever sent a new agent invite, send it as agentId when you join, so you come back as yourself instead of as a new agent.` : '',
72
+ hasKey ? '' : `You joined over HTTP. That is only for an agent with no computer. If you can run shell commands anywhere, stop and switch to the Quilt CLI now: ask your person for a new agent invite and run \`quilt agent join <invite link> --name ${name}${agentId ? ` --agent-id ${agentId}` : ''}\` (so you stay the same agent), then \`quilt join <session invite link> --agent ${name}\`. ${INSTALL_CLI} In a sandbox wiped after every turn, use a QUILT_AGENT_KEY instead: POST ${apiUrl}/v1/agents/me/keys with {"session": "<session invite link>"} and your access key gives you one (as envKey); ask your person to put it in the sandbox's environment or secrets settings as QUILT_AGENT_KEY, then run \`npx heyquilt sync\` at the start and end of every turn.`,
73
+ `To work in a session: connect to the MCP server at ${mcp} (Streamable HTTP) with the header "Authorization: Bearer <accessKey>", then call quilt_join_session with the invite link the person gives you (https://join.heyquilt.com/<room>#<secret>). The session owner lets you in; quilt_session_info tells you when. Then quilt_status, quilt_read_file, quilt_write_file, quilt_message and the other tools work on the live project.`,
74
+ 'Over HTTP you show as online for 30 minutes after each tool call: while idle, call quilt_inbox at least every 30 minutes so people can see you are still there.',
75
+ `Your access key lasts 1 hour. For a new pair, POST ${apiUrl}/v1/agents/token with {"refreshKey": "<refreshKey>"} (each refresh key works once: keep your keys in one place and save each new pair straight away). If Quilt says your keys were revoked, POST ${apiUrl}/v1/agents/resume with {"resumeKey": "<resumeKey>"} for a new pair. Check who you are with GET ${apiUrl}/v1/agents/me.`,
76
+ hasKey ? 'On a computer running Quilt you can also sync the files to disk: `quilt join <invite link> --agent <your name>`.' : ''
77
+ ].filter(Boolean).join(' ') + '\n\n' + agentGuide({ apiUrl, via: hasKey ? 'both' : 'http' })
78
+ }
@@ -0,0 +1,47 @@
1
+ // How the accounts API sends email (org invites). Production uses SMTP, e.g.
2
+ // SMTP_URL=smtp://resend:<key>@smtp.resend.com:587 and SMTP_FROM="Quilt <invites@heyquilt.com>".
3
+ import nodemailer from 'nodemailer'
4
+
5
+ // Parses SMTP_URL into nodemailer transport options ourselves, rather than handing
6
+ // the raw string to nodemailer.createTransport, so smtp:// always requires TLS.
7
+ // Without requireTLS, smtp:// (port 587) only offers opportunistic STARTTLS: a
8
+ // network attacker who strips the STARTTLS response from the server's reply can
9
+ // make nodemailer fall back to authenticating in the clear. smtps:// is already
10
+ // always-TLS via `secure`. The error for a bad URL never includes the password.
11
+ function parseSmtpUrl (raw) {
12
+ let u
13
+ try {
14
+ u = new URL(raw || '')
15
+ } catch {
16
+ throw new Error('SMTP_URL is not a valid URL')
17
+ }
18
+ if (u.protocol !== 'smtp:' && u.protocol !== 'smtps:') {
19
+ throw new Error('SMTP_URL must start with smtp:// or smtps://')
20
+ }
21
+ const secure = u.protocol === 'smtps:'
22
+ // A hung SMTP connection would otherwise hold the invite request open forever.
23
+ const options = {
24
+ host: u.hostname,
25
+ port: u.port ? Number(u.port) : (secure ? 465 : 587),
26
+ secure,
27
+ connectionTimeout: 10000,
28
+ greetingTimeout: 10000,
29
+ socketTimeout: 15000
30
+ }
31
+ if (u.username || u.password) {
32
+ options.auth = { user: decodeURIComponent(u.username), pass: decodeURIComponent(u.password) }
33
+ }
34
+ if (!secure) options.requireTLS = true
35
+ return options
36
+ }
37
+
38
+ export function createSmtpMailer ({ url, from, transport, createTransport = nodemailer.createTransport }) {
39
+ const t = transport || createTransport(parseSmtpUrl(url))
40
+ // A message may name its own sender (session invites come from hello@); otherwise SMTP_FROM.
41
+ return { send: ({ to, subject, text, from: sender }) => t.sendMail({ from: sender || from, to, subject, text }) }
42
+ }
43
+
44
+ // `quilt api --memory` prints emails instead, so invite links can be copied from the terminal.
45
+ export function createConsoleMailer (log = console.log) {
46
+ return { send: async ({ to, subject, text }) => { log(`--- email to ${to}: ${subject}\n${text}\n---`) } }
47
+ }