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,157 @@
1
+ // Agents: swapping a refresh key, who an agent is, and a person's personal agents,
2
+ // with the app keys (qk_) that let an app like Pipedream sign one in.
3
+ import { HttpError, needId, cleanName } from '../http.js'
4
+ import { parsePublicKey, verifyAgentResume } from '../../identity.js'
5
+ import { keyStatus } from '../agent-auth.js'
6
+ import { encodeAgentEnvKey } from '../../ui/agent-env-key.js'
7
+
8
+ // Live app keys an agent may hold at once.
9
+ export const MAX_APP_KEYS = 10
10
+ const keyName = (v) => cleanName(typeof v === 'string' ? v : '', 40, 'name the key (up to 40 characters), e.g. Pipedream')
11
+ const keyView = (k) => ({ id: k.id, name: k.name, createdAt: k.createdAt, lastUsedAt: k.lastUsedAt })
12
+
13
+ // Never the key itself, just whether it has one. With a key it joins sessions from a computer
14
+ // running Quilt; without one it is hosted: it joins through the API's /mcp. Either way it can join.
15
+ const profileOf = (a) => ({ id: a.id, name: a.name, provider: a.provider, type: a.type, description: a.description, canJoinSessions: true, hosted: !a.publicKey })
16
+
17
+ export function agentRoutes ({ store, user, person, now, limit, limitTokens, limitStarts, spendResume, resumeWindowMs, agentAuth, apiUrl }) {
18
+ // A personal agent of this person's that isn't revoked; anyone else's gets the same 404 as a missing one.
19
+ async function mine (req, id, who = user) {
20
+ const u = await who(req)
21
+ const agent = await store.agentById(needId(id, 'agent'))
22
+ if (!agent || agent.ownerUserId !== u.userId || agent.revokedAt) throw new HttpError(404, 'no such agent')
23
+ return agent
24
+ }
25
+ async function newAppKey (agent, name) {
26
+ if ((await store.listAgentAppKeys(agent.id)).length >= MAX_APP_KEYS) throw new HttpError(409, `an agent can have ${MAX_APP_KEYS} app keys; revoke one first`)
27
+ return agentAuth.mintAppKey(agent.id, name)
28
+ }
29
+ const connect = (agent, key) => ({ agent: profileOf(agent), key, mcp: `${apiUrl}/mcp` })
30
+ // A sandbox's QUILT_AGENT_KEY (ui/agent-env-key.js): a new app key with the API and the session it works in.
31
+ async function envKeyFor (agent, session, name) {
32
+ let envKey
33
+ const check = { api: apiUrl, key: 'qk_check', session, name: agent.name }
34
+ try { encodeAgentEnvKey(check) } catch (err) { throw new HttpError(400, err.message) }
35
+ const key = await newAppKey(agent, keyName(name || 'Sandbox'))
36
+ try { envKey = encodeAgentEnvKey({ ...check, key: key.key }) } catch (err) { throw new HttpError(400, err.message) }
37
+ return { envKey, key: keyView(key) }
38
+ }
39
+
40
+ return [
41
+ ['POST', /^\/v1\/agents\/token$/, async (req, body) => {
42
+ limitTokens(req)
43
+ return agentAuth.refresh(body.refreshKey)
44
+ }],
45
+
46
+ // An agent whose refresh key stopped working gets new keys with its resume key (every
47
+ // agent gets one when it joins), or by signing for the key it joined with (agents on a
48
+ // computer that joined before resume keys). A stolen refresh key alone can't do this,
49
+ // and an agent a person revoked can't either: it's invited again.
50
+ ['POST', /^\/v1\/agents\/resume$/, async (req, body) => {
51
+ limitStarts(req)
52
+ if (body.resumeKey !== undefined) return agentAuth.resumeByKey(body.resumeKey)
53
+ const at = Number(body.at)
54
+ if (!Number.isFinite(at) || Math.abs(now() - at) > resumeWindowMs) throw new HttpError(400, "this computer's clock is off; check its date and time")
55
+ const agent = await store.agentById(needId(body.agentId, 'agent'))
56
+ const key = agent && parsePublicKey(agent.publicKey)
57
+ if (!key) throw new HttpError(404, 'no such agent')
58
+ if (!verifyAgentResume(key, agent.id, at, body.signature)) throw new HttpError(401, "this agent's signature doesn't match")
59
+ await spendResume(String(body.signature), at)
60
+ return agentAuth.resume(agent)
61
+ }],
62
+
63
+ ['GET', /^\/v1\/agents\/me$/, async (req) => {
64
+ const { agent } = await agentAuth.agentFromRequest(req)
65
+ const mcp = `${apiUrl}/mcp`
66
+ if (!agent.orgId) return { agent: { ...profileOf(agent), kind: 'personal', org: null }, teams: [], role: null, mcp }
67
+ const [org, m, teams] = await Promise.all([store.orgById(agent.orgId), store.memberByAgent(agent.orgId, agent.id), store.listTeams(agent.orgId)])
68
+ const [role, mine] = await Promise.all([m?.roleId ? store.roleById(agent.orgId, m.roleId) : null, m ? store.teamsOfMember(m.id) : []])
69
+ const names = new Map(teams.map((x) => [x.id, x.name]))
70
+ return {
71
+ agent: { ...profileOf(agent), kind: 'org', org: { slug: org.slug, name: org.name } },
72
+ teams: mine.map((x) => ({ id: x.teamId, name: names.get(x.teamId) || '', access: x.access, scopes: x.scopes })),
73
+ role: role ? { name: role.name } : null,
74
+ mcp
75
+ }
76
+ }],
77
+
78
+ // Listed on the website and in the app (a linked computer's token counts as its person).
79
+ ['GET', /^\/v1\/agents$/, async (req) => {
80
+ const u = await person(req)
81
+ // The person's cloud AI (routes/ai-vm.js) has its own card, so its agent isn't listed with the others.
82
+ const vm = store.aiVmOf ? await store.aiVmOf(u.userId) : null
83
+ // Until the machine first asks for work, its agent is known by the one-time invite it joined with.
84
+ const invite = vm && !vm.agentId && vm.inviteId ? await store.agentInviteById(vm.inviteId) : null
85
+ const cloudId = (vm && vm.agentId) || (invite && invite.usedByAgentId) || null
86
+ const agents = (await store.listPersonalAgents(u.userId)).filter((a) => a.id !== cloudId)
87
+ return {
88
+ agents: await Promise.all(agents.map(async (a) => ({
89
+ ...profileOf(a),
90
+ createdAt: a.createdAt,
91
+ lastUsedAt: a.lastUsedAt,
92
+ // Why an agent is signed out (reused or expired keys), so the dashboard can say so.
93
+ // A live app key keeps it signed in whatever its own keys did.
94
+ status: (await store.listAgentAppKeys(a.id)).length ? 'active' : keyStatus(await store.listAgentKeys(a.id), now())
95
+ })))
96
+ }
97
+ }],
98
+
99
+ // Connecting an app (Pipedream, Zapier, Make, n8n, a script): a new hosted agent of this
100
+ // person's, named for the app, and its first app key. The key is shown once.
101
+ ['POST', /^\/v1\/agents\/apps$/, async (req, body) => {
102
+ const u = await user(req)
103
+ limit(req)
104
+ const name = cleanName(typeof body.name === 'string' ? body.name : '', 40, 'give the agent a name (up to 40 characters), e.g. Pipedream')
105
+ const provider = body.provider == null || body.provider === '' ? name : cleanName(String(body.provider), 40, 'provider: up to 40 characters')
106
+ const agent = await store.createAgent({ name, provider, type: 'app', ownerUserId: u.userId, invitedBy: u.userId })
107
+ return connect(agent, await newAppKey(agent, provider))
108
+ }],
109
+
110
+ // An agent making an app key for itself, signed in with its access key (never an app key: a
111
+ // key pasted into an app or a sandbox can't make more). For `quilt agent join --print-env` and
112
+ // `quilt agent env`: its owner sees the key with its others on heyquilt.com and can revoke it.
113
+ // With `session`, the answer is a whole QUILT_AGENT_KEY.
114
+ ['POST', /^\/v1\/agents\/me\/keys$/, async (req, body) => {
115
+ const { agent, keyRow } = await agentAuth.agentFromRequest(req)
116
+ if (!keyRow) throw new HttpError(403, 'sign in with the agent\'s access key (an app key can\'t make keys)')
117
+ if (!agent.ownerUserId) throw new HttpError(403, 'only a personal agent makes its own keys; an org agent\'s are made by its org')
118
+ limit(req)
119
+ if (body.session != null && body.session !== '') return envKeyFor(agent, String(body.session), body.name)
120
+ return newAppKey(agent, keyName(body.name || 'Sandbox'))
121
+ }],
122
+
123
+ // A person making a sandbox's QUILT_AGENT_KEY for their agent (the website, the app): a new app key
124
+ // with the session it works in. Shown once; revoked like any of the agent's app keys.
125
+ ['POST', /^\/v1\/agents\/([^/]+)\/env-key$/, async (req, body, [id]) => {
126
+ const agent = await mine(req, id, person)
127
+ limit(req)
128
+ return envKeyFor(agent, String(body.session || ''), body.name)
129
+ }],
130
+
131
+ ['GET', /^\/v1\/agents\/([^/]+)\/keys$/, async (req, body, [id]) => {
132
+ const agent = await mine(req, id)
133
+ return { keys: (await store.listAgentAppKeys(agent.id)).map(keyView) }
134
+ }],
135
+
136
+ ['POST', /^\/v1\/agents\/([^/]+)\/keys$/, async (req, body, [id]) => {
137
+ const agent = await mine(req, id)
138
+ limit(req)
139
+ return connect(agent, await newAppKey(agent, keyName(body.name)))
140
+ }],
141
+
142
+ ['DELETE', /^\/v1\/agents\/([^/]+)\/keys\/([^/]+)$/, async (req, body, [id, keyId]) => {
143
+ const agent = await mine(req, id)
144
+ if (!await store.revokeAgentAppKey(agent.id, needId(keyId, 'key'))) throw new HttpError(404, 'no such key')
145
+ return { ok: true }
146
+ }],
147
+
148
+ ['DELETE', /^\/v1\/agents\/([^/]+)$/, async (req, body, [id]) => {
149
+ const u = await user(req)
150
+ const agent = await store.agentById(needId(id, 'agent'))
151
+ // Someone else's agent gets the same answer as a missing one.
152
+ if (!agent || agent.ownerUserId !== u.userId || agent.revokedAt) throw new HttpError(404, 'no such agent')
153
+ await store.revokeAgent(agent.id)
154
+ return { ok: true }
155
+ }]
156
+ ]
157
+ }
@@ -0,0 +1,369 @@
1
+ // A person's AI on its own cloud machine (docs/superpowers/specs/2026-10-17-ai-vm.md): Quilt
2
+ // starts one Fly Machine with one volume per person, which signs in as an agent of theirs (keys
3
+ // only on the machine) and signs their AI tool in with the tool's own login. These routes are
4
+ // the person's (connect, settings, the sign-in, sessions to follow, wake, disconnect), the
5
+ // machine's own (what is waiting for it, its reports, "suspend me"), and the relay's (wake it).
6
+ //
7
+ // Links to sessions, sign-in steps and pasted codes live here in memory only, until the machine
8
+ // or the app collects them: none is ever stored. Quilt never sees a Claude, OpenAI or Cursor token.
9
+ import crypto from 'node:crypto'
10
+ import { HttpError, UUID } from '../http.js'
11
+ import { newToken, hashToken } from '../tokens.js'
12
+ import { AGENT_INVITE_TTL_MS } from './agent-invites.js'
13
+ import { parseInvite, buildInvite } from '../../ui/invite.js'
14
+ import { isHostedRelay, HOSTED_RELAY } from '../../settings.js'
15
+ import { autoToolFor } from '../../autowork-tools.js'
16
+
17
+ export const AI_VM_TOOLS = ['Claude Code', 'Codex', 'Cursor']
18
+ // The machine reports about once a minute; one not heard from for this long may have stopped.
19
+ const QUIET_MS = 3 * 60 * 1000
20
+ // How long a waiting GET is held before it answers with nothing new.
21
+ export const WAIT_MS = 25 * 1000
22
+ // The relay asks again for each new event; once a minute per machine is plenty.
23
+ const RELAY_WAKE_MS = 60 * 1000
24
+ const MAX_PENDING = 50
25
+ // Control characters, kept out of anything shown in the app.
26
+ const CONTROL = /[\u0000-\u001f\u007f]/g // eslint-disable-line no-control-regex
27
+ const STEPS = ['starting', 'link', 'code', 'waiting', 'done', 'failed']
28
+ const CODE = /^[A-Za-z0-9-]{1,40}$/
29
+ const ROOM = /^[A-Za-z0-9_-]{1,64}$/
30
+ const digest = (s) => crypto.createHash('sha256').update(String(s)).digest()
31
+
32
+ /** What the person's app shows of a sign-in step: a link, a device code, whether to paste a code back. */
33
+ export function cleanSignIn (s) {
34
+ if (!s || typeof s !== 'object' || !STEPS.includes(s.step)) return null
35
+ let url = ''
36
+ try { const u = new URL(String(s.url || '')); if (u.protocol === 'https:' && u.href.length <= 2000) url = u.href } catch {}
37
+ const code = typeof s.code === 'string' && CODE.test(s.code) ? s.code : ''
38
+ const error = typeof s.error === 'string' ? s.error.replace(CONTROL, ' ').slice(0, 300) : ''
39
+ return { step: s.step, url, code, needsCode: s.needsCode === true, error }
40
+ }
41
+
42
+ const cleanUsage = (u) => {
43
+ if (!u || typeof u !== 'object') return {}
44
+ const n = (x) => (Number.isFinite(Number(x)) && Number(x) >= 0 ? Math.floor(Number(x)) : 0)
45
+ return { tokens: n(u.tokens), runs: n(u.runs) }
46
+ }
47
+
48
+ const cleanSessions = (list) => (Array.isArray(list) ? list : []).slice(0, 50)
49
+ .filter((x) => x && ROOM.test(String(x.room)))
50
+ .map((x) => ({ room: String(x.room), name: String(x.name || '').replace(CONTROL, '').slice(0, 80) }))
51
+
52
+ /**
53
+ * The cloud AI machine record whose agent `agent` is, or null. The agent that used a machine's
54
+ * one-time invite is its agent: the record learns so the first time it is asked.
55
+ */
56
+ export async function cloudAiOf (store, agent) {
57
+ if (!agent) return null
58
+ const vm = await store.aiVmByAgent(agent.id)
59
+ if (vm || !agent.ownerUserId) return vm
60
+ const theirs = await store.aiVmOf(agent.ownerUserId)
61
+ const invite = theirs && !theirs.agentId && theirs.inviteId ? await store.agentInviteById(theirs.inviteId) : null
62
+ if (!invite || invite.usedByAgentId !== agent.id) return null
63
+ return store.updateAiVm(theirs.userId, { agentId: agent.id })
64
+ }
65
+
66
+ export function aiVmRoutes ({ store, person, agentAuth, bearer, now, apiUrl, log = () => {}, limit, relaySecret = '', relayUrl = HOSTED_RELAY, fly = null, waitMs = WAIT_MS }) {
67
+ // user id -> { version, signIn, sessions, waiters, forVm: { follow, leave, signIn, code, waiters } }
68
+ const live = new Map()
69
+ const ownRelay = String(relayUrl).replace(/\/+$/, '').replace(/^http(s?):\/\//, 'ws$1://')
70
+ const relayWokeAt = new Map()
71
+
72
+ const liveOf = (userId) => {
73
+ let l = live.get(userId)
74
+ if (!l) { l = { version: 1, signIn: null, sessions: [], waiters: new Set(), forVm: { follow: [], leave: [], signIn: false, code: null, waiters: new Set() } }; live.set(userId, l) }
75
+ return l
76
+ }
77
+ const wakeWaiters = (set) => { for (const w of [...set]) w(); set.clear() }
78
+ /** Something the app shows changed: its waiting GET answers. */
79
+ const bump = (userId) => { const l = liveOf(userId); l.version++; wakeWaiters(l.waiters) }
80
+ /** Something is waiting for the machine: its waiting GET answers. */
81
+ const poke = (userId) => wakeWaiters(liveOf(userId).forVm.waiters)
82
+ const hold = (set, ms) => new Promise((resolve) => {
83
+ const done = () => { clearTimeout(timer); set.delete(done); resolve() }
84
+ const timer = setTimeout(done, ms)
85
+ set.add(done)
86
+ })
87
+
88
+ const needFly = () => { if (!fly) throw new HttpError(503, "Cloud AI isn't set up on this server.") }
89
+ async function mine (req) {
90
+ const u = await person(req)
91
+ const vm = await store.aiVmOf(u.userId)
92
+ if (!vm) throw new HttpError(404, "You haven't connected a cloud AI.")
93
+ return { u, vm }
94
+ }
95
+
96
+ /** The machine's record as the person's app shows it. */
97
+ function view (vm) {
98
+ const l = liveOf(vm.userId)
99
+ return {
100
+ tool: vm.tool,
101
+ model: vm.model,
102
+ models: autoToolFor(vm.tool)?.models || [],
103
+ prompt: vm.prompt,
104
+ cap: vm.cap,
105
+ state: vm.state,
106
+ error: vm.error,
107
+ signedIn: vm.signedIn,
108
+ signIn: l.signIn,
109
+ sessions: l.sessions,
110
+ usageToday: vm.usageToday || {},
111
+ region: vm.region,
112
+ lastReportAt: vm.lastReportAt,
113
+ connectedAt: vm.createdAt,
114
+ version: l.version
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Resumes the machine (or starts it after a crash) unless it is running and was heard from
120
+ * lately. A Fly failure is logged and the machine is left as it was: the next wake tries again.
121
+ */
122
+ async function wake (vm, why) {
123
+ if (!fly || !vm.machineId) return vm
124
+ const quiet = !vm.lastReportAt || now() - vm.lastReportAt > QUIET_MS
125
+ if (vm.state === 'running' && !quiet) return vm
126
+ if (vm.state === 'starting' && vm.updatedAt && now() - vm.updatedAt < QUIET_MS) return vm
127
+ try {
128
+ await fly.start(vm.machineId)
129
+ log(`cloud AI ${vm.userId}: woken (${why})`)
130
+ const up = await store.updateAiVm(vm.userId, { state: 'starting', error: '' })
131
+ bump(vm.userId)
132
+ return up || vm
133
+ } catch (err) {
134
+ log(`cloud AI ${vm.userId}: could not wake it (${why}): ${err.message}`)
135
+ return vm
136
+ }
137
+ }
138
+
139
+ /** Deletes the person's machine, its disk and its agent. Fly answering "already gone" is fine. */
140
+ async function destroy (vm) {
141
+ if (fly && vm.machineId) await fly.destroyMachine(vm.machineId)
142
+ if (fly && vm.volumeId) await fly.destroyVolume(vm.volumeId)
143
+ if (vm.agentId) await store.revokeAgent(vm.agentId)
144
+ if (vm.inviteId) await store.cancelAgentInvite(vm.inviteId).catch(() => {})
145
+ await store.deleteAiVm(vm.userId)
146
+ wakeWaiters(liveOf(vm.userId).forVm.waiters)
147
+ bump(vm.userId)
148
+ live.delete(vm.userId)
149
+ }
150
+
151
+ /** The machine calling, with the person's record: its agent must be the person's cloud AI. */
152
+ async function machine (req) {
153
+ const { agent } = await agentAuth.agentFromRequest(req)
154
+ const vm = await cloudAiOf(store, agent)
155
+ if (!vm) throw new HttpError(404, "this agent isn't a cloud AI")
156
+ return { agent, vm }
157
+ }
158
+
159
+ const pendingFor = (f) => f.follow.length || f.leave.length || f.signIn || f.code !== null
160
+
161
+ function cleanSettings (b, vm) {
162
+ const out = {}
163
+ if (b.tool !== undefined) {
164
+ if (!AI_VM_TOOLS.includes(b.tool)) throw new HttpError(400, `tool is one of ${AI_VM_TOOLS.join(', ')}`)
165
+ // A new tool signs in again and starts from its own models.
166
+ if (b.tool !== vm.tool) Object.assign(out, { tool: b.tool, signedIn: false, model: '' })
167
+ }
168
+ if (b.model !== undefined) {
169
+ if (typeof b.model !== 'string' || b.model.length > 80 || /[\s<>]/.test(b.model)) throw new HttpError(400, 'model: up to 80 characters, no spaces')
170
+ out.model = b.model
171
+ }
172
+ if (b.prompt !== undefined) {
173
+ if (typeof b.prompt !== 'string' || b.prompt.length > 4000) throw new HttpError(400, 'prompt: up to 4000 characters')
174
+ out.prompt = b.prompt.replace(/\r\n/g, '\n')
175
+ }
176
+ if (b.cap !== undefined) {
177
+ if (!Number.isInteger(b.cap) || b.cap < 0 || b.cap > 1e10) throw new HttpError(400, 'cap: tokens a day, a whole number (0 for none)')
178
+ out.cap = b.cap
179
+ }
180
+ return out
181
+ }
182
+
183
+ const routes = [
184
+ ['GET', /^\/v1\/me\/ai-vm$/, async (req) => {
185
+ const { u, vm } = await mine(req)
186
+ const wait = Number(new URL(req.url, 'http://x').searchParams.get('wait'))
187
+ // The app's long poll: held while nothing changed since the version it has.
188
+ if (Number.isFinite(wait) && wait > 0 && wait >= liveOf(u.userId).version) {
189
+ await hold(liveOf(u.userId).waiters, waitMs)
190
+ const again = await store.aiVmOf(u.userId)
191
+ if (!again) throw new HttpError(404, "You haven't connected a cloud AI.")
192
+ return view(again)
193
+ }
194
+ return view(vm)
195
+ }],
196
+
197
+ ['POST', /^\/v1\/me\/ai-vm$/, async (req, body) => {
198
+ const u = await person(req)
199
+ needFly()
200
+ limit(req)
201
+ if (!AI_VM_TOOLS.includes(body.tool)) throw new HttpError(400, `tool is one of ${AI_VM_TOOLS.join(', ')}`)
202
+ if (await store.aiVmOf(u.userId)) throw new HttpError(409, 'You already have a cloud AI. Disconnect it first.')
203
+ // The machine joins as the person's agent with a one-time link, so its keys are made there.
204
+ const token = newToken('qj_')
205
+ const invite = await store.createAgentInvite({ tokenHash: hashToken(token), ownerUserId: u.userId, createdBy: u.userId, expiresAt: now() + AGENT_INVITE_TTL_MS })
206
+ let vm = await store.createAiVm({ userId: u.userId, tool: body.tool, region: fly.region })
207
+ vm = await store.updateAiVm(u.userId, { inviteId: invite.id })
208
+ const short = crypto.createHash('sha256').update(u.userId).digest('hex').slice(0, 20)
209
+ let volume = null
210
+ try {
211
+ volume = await fly.createVolume({ name: `ai_${short}` })
212
+ const m = await fly.createMachine({
213
+ name: `ai-${short}`,
214
+ volumeId: volume.id,
215
+ env: { QUILT_API: apiUrl, QUILT_AI_VM_JOIN: `${apiUrl}/v1/join/${token}`, HOME: '/data/home' }
216
+ })
217
+ vm = await store.updateAiVm(u.userId, { machineId: m.id, volumeId: volume.id, region: m.region || volume.region || fly.region, state: 'starting' })
218
+ } catch (err) {
219
+ log(`cloud AI ${u.userId}: could not create it: ${err.message}`)
220
+ if (volume) await fly.destroyVolume(volume.id).catch(() => {})
221
+ await store.cancelAgentInvite(invite.id).catch(() => {})
222
+ await store.deleteAiVm(u.userId)
223
+ throw new HttpError(502, `Couldn't start your cloud AI. ${err.message}`)
224
+ }
225
+ log(`cloud AI ${u.userId}: created ${vm.machineId} (${vm.tool})`)
226
+ bump(u.userId)
227
+ return view(vm)
228
+ }],
229
+
230
+ ['PATCH', /^\/v1\/me\/ai-vm$/, async (req, body) => {
231
+ const { u, vm } = await mine(req)
232
+ const patch = cleanSettings(body, vm)
233
+ if (!Object.keys(patch).length) return view(vm)
234
+ let up = await store.updateAiVm(u.userId, patch)
235
+ bump(u.userId)
236
+ poke(u.userId)
237
+ up = await wake(up, 'settings')
238
+ return view(up)
239
+ }],
240
+
241
+ // Starts (or restarts) the tool's sign-in on the machine; with `code`, the code the tool's
242
+ // page showed, for the tool to take (Claude's). Held in memory until the machine collects it.
243
+ ['POST', /^\/v1\/me\/ai-vm\/sign-in$/, async (req, body) => {
244
+ const { u, vm } = await mine(req)
245
+ const f = liveOf(u.userId).forVm
246
+ if (body.code !== undefined) {
247
+ const code = typeof body.code === 'string' ? body.code.trim() : ''
248
+ if (!code || code.length > 2000 || /\s/.test(code)) throw new HttpError(400, 'paste the code the sign-in page showed')
249
+ f.code = code
250
+ } else {
251
+ f.signIn = true
252
+ liveOf(u.userId).signIn = { step: 'starting', url: '', code: '', needsCode: false, error: '' }
253
+ bump(u.userId)
254
+ }
255
+ poke(u.userId)
256
+ await wake(vm, 'sign-in')
257
+ return { ok: true }
258
+ }],
259
+
260
+ // The person's app hands over the link of a session they joined (or says they left one for
261
+ // good). Not kept here: the machine collects it, and keeps it on its own disk.
262
+ ['POST', /^\/v1\/me\/ai-vm\/follow$/, async (req, body) => {
263
+ const { u, vm } = await mine(req)
264
+ const f = liveOf(u.userId).forVm
265
+ if (body.leave === true) {
266
+ if (!ROOM.test(String(body.room || ''))) throw new HttpError(400, 'room must be a session name')
267
+ f.follow = f.follow.filter((l) => parseInvite(l, { allowRelay: () => true }).room !== body.room)
268
+ if (!f.leave.includes(body.room)) f.leave = [...f.leave, body.room].slice(-MAX_PENDING)
269
+ } else {
270
+ let invite
271
+ try { invite = parseInvite(String(body.link || ''), { allowRelay: (s) => isHostedRelay(s) || String(s).replace(/\/+$/, '') === ownRelay }) } catch { throw new HttpError(400, "link must be a Quilt session's invite link") }
272
+ if (!invite.secret) throw new HttpError(400, "link must be a Quilt session's invite link")
273
+ const link = buildInvite({ server: invite.relay || HOSTED_RELAY, room: invite.room, secret: invite.secret }, isHostedRelay)
274
+ f.leave = f.leave.filter((r) => r !== invite.room)
275
+ f.follow = [...f.follow.filter((l) => parseInvite(l, { allowRelay: () => true }).room !== invite.room), link].slice(-MAX_PENDING)
276
+ }
277
+ poke(u.userId)
278
+ await wake(vm, 'follow')
279
+ return { ok: true }
280
+ }],
281
+
282
+ ['POST', /^\/v1\/me\/ai-vm\/wake$/, async (req) => {
283
+ const { vm } = await mine(req)
284
+ needFly()
285
+ return view(await wake({ ...vm, lastReportAt: 0 }, 'asked'))
286
+ }],
287
+
288
+ ['DELETE', /^\/v1\/me\/ai-vm$/, async (req) => {
289
+ const { vm } = await mine(req)
290
+ try { await destroy(vm) } catch (err) {
291
+ log(`cloud AI ${vm.userId}: could not delete it: ${err.message}`)
292
+ throw new HttpError(502, `Couldn't delete your cloud AI machine; try again. ${err.message}`)
293
+ }
294
+ log(`cloud AI ${vm.userId}: disconnected`)
295
+ return { ok: true }
296
+ }],
297
+
298
+ // The machine: its settings, and what waits for it, each handed over once. `?wait=1` holds
299
+ // the request while nothing waits.
300
+ ['GET', /^\/v1\/ai-vm\/next$/, async (req) => {
301
+ const { vm } = await machine(req)
302
+ const f = liveOf(vm.userId).forVm
303
+ if (new URL(req.url, 'http://x').searchParams.get('wait') && !pendingFor(f)) await hold(f.waiters, waitMs)
304
+ const fresh = (await store.aiVmOf(vm.userId)) || vm
305
+ const p = await store.profile(vm.userId)
306
+ const out = {
307
+ config: { tool: fresh.tool, model: fresh.model, prompt: fresh.prompt, cap: fresh.cap, person: (p && p.name) || 'Quilt user' },
308
+ follow: f.follow,
309
+ leave: f.leave,
310
+ signIn: f.signIn,
311
+ code: f.code
312
+ }
313
+ Object.assign(f, { follow: [], leave: [], signIn: false, code: null })
314
+ return out
315
+ }],
316
+
317
+ ['POST', /^\/v1\/ai-vm\/report$/, async (req, body) => {
318
+ const { vm } = await machine(req)
319
+ const l = liveOf(vm.userId)
320
+ const patch = { state: 'running', error: '', lastReportAt: now() }
321
+ if (typeof body.signedIn === 'boolean') patch.signedIn = body.signedIn
322
+ if (body.usageToday !== undefined) patch.usageToday = cleanUsage(body.usageToday)
323
+ if (body.signIn !== undefined) l.signIn = cleanSignIn(body.signIn)
324
+ if (body.sessions !== undefined) l.sessions = cleanSessions(body.sessions)
325
+ await store.updateAiVm(vm.userId, patch)
326
+ bump(vm.userId)
327
+ return { ok: true }
328
+ }],
329
+
330
+ // The machine has nothing to do: suspend it (unless something came for it meanwhile).
331
+ ['POST', /^\/v1\/ai-vm\/idle$/, async (req) => {
332
+ const { vm } = await machine(req)
333
+ if (pendingFor(liveOf(vm.userId).forVm)) throw new HttpError(409, 'work is waiting for you')
334
+ needFly()
335
+ await store.updateAiVm(vm.userId, { state: 'suspended' })
336
+ bump(vm.userId)
337
+ try { await fly.suspend(vm.machineId) } catch (err) {
338
+ log(`cloud AI ${vm.userId}: could not suspend it: ${err.message}`)
339
+ await store.updateAiVm(vm.userId, { state: 'running' })
340
+ throw new HttpError(502, err.message)
341
+ }
342
+ return { ok: true }
343
+ }],
344
+
345
+ // The relay: a mention, a message or a task waits for a cloud AI that isn't connected.
346
+ ['POST', /^\/v1\/relay\/ai-vm\/wake$/, async (req, body) => {
347
+ if (!relaySecret) throw new HttpError(503, 'the relay is not set up on this server')
348
+ if (!crypto.timingSafeEqual(digest(bearer(req)), digest(relaySecret))) throw new HttpError(401, 'only the relay may do that')
349
+ const agentId = String(body.agent || '')
350
+ const agent = UUID.test(agentId) ? await store.agentById(agentId) : null
351
+ const vm = agent && !agent.revokedAt ? await cloudAiOf(store, agent) : null
352
+ if (!vm) throw new HttpError(404, 'no such cloud AI')
353
+ const last = relayWokeAt.get(agentId) || 0
354
+ if (now() - last < RELAY_WAKE_MS) return { ok: true, already: true }
355
+ relayWokeAt.set(agentId, now())
356
+ if (relayWokeAt.size > 10_000) relayWokeAt.delete(relayWokeAt.keys().next().value)
357
+ await wake(vm, `waiting in ${ROOM.test(String(body.room)) ? body.room : 'a session'}`)
358
+ return { ok: true }
359
+ }]
360
+ ]
361
+
362
+ /** For account deletion: the person's machine goes with them. */
363
+ async function destroyFor (userId) {
364
+ const vm = await store.aiVmOf(userId)
365
+ if (vm) await destroy(vm)
366
+ }
367
+
368
+ return { routes, destroyFor }
369
+ }