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.
- package/LICENSE +85 -0
- package/README.md +630 -2
- package/RELEASES.md +733 -0
- package/assets/logo.svg +1 -0
- package/bin/quilt.js +816 -0
- package/desktop/main.js +315 -0
- package/package.json +163 -7
- package/src/account.js +563 -0
- package/src/admit-policy.js +32 -0
- package/src/agent-join.js +370 -0
- package/src/agent-task-workflow.js +176 -0
- package/src/agents/actions.js +85 -0
- package/src/agents/claude-code.js +180 -0
- package/src/agents/common.js +21 -0
- package/src/agents/cursor-transcripts.js +170 -0
- package/src/agents/cursor.js +404 -0
- package/src/agents/index.js +69 -0
- package/src/ai-vm-signin.js +98 -0
- package/src/ai-vm.js +380 -0
- package/src/api/access.js +75 -0
- package/src/api/activity.js +181 -0
- package/src/api/agent-auth.js +130 -0
- package/src/api/agent-placement.js +157 -0
- package/src/api/audit.js +83 -0
- package/src/api/auth.js +17 -0
- package/src/api/domains.js +30 -0
- package/src/api/file-paths.js +40 -0
- package/src/api/file-store.js +99 -0
- package/src/api/fly-machines.js +76 -0
- package/src/api/github-app.js +172 -0
- package/src/api/http.js +34 -0
- package/src/api/invite-email.js +53 -0
- package/src/api/issues.js +110 -0
- package/src/api/join-text.js +78 -0
- package/src/api/mailer.js +47 -0
- package/src/api/memory-store.js +1083 -0
- package/src/api/org-access.js +54 -0
- package/src/api/permissions.js +71 -0
- package/src/api/relay-cluster.js +251 -0
- package/src/api/relay-machines.js +60 -0
- package/src/api/routes/access-types.js +54 -0
- package/src/api/routes/agent-invites.js +115 -0
- package/src/api/routes/agents.js +157 -0
- package/src/api/routes/ai-vm.js +369 -0
- package/src/api/routes/github.js +380 -0
- package/src/api/routes/grants.js +70 -0
- package/src/api/routes/invites.js +188 -0
- package/src/api/routes/issues.js +47 -0
- package/src/api/routes/join.js +167 -0
- package/src/api/routes/members.js +110 -0
- package/src/api/routes/orgs.js +177 -0
- package/src/api/routes/relay-cluster.js +90 -0
- package/src/api/routes/relay.js +76 -0
- package/src/api/routes/session-invites.js +119 -0
- package/src/api/routes/sessions.js +92 -0
- package/src/api/routes/teams.js +132 -0
- package/src/api/routes/workspace-agents.js +332 -0
- package/src/api/routes/workspace-files.js +277 -0
- package/src/api/routes/workspace-invites.js +196 -0
- package/src/api/routes/workspaces.js +225 -0
- package/src/api/server.js +623 -0
- package/src/api/slugs.js +20 -0
- package/src/api/supabase-store.js +765 -0
- package/src/api/team-access.js +30 -0
- package/src/api/tokens.js +31 -0
- package/src/api/workspace-access.js +62 -0
- package/src/api/workspace-reach.js +60 -0
- package/src/autowork-tools.js +166 -0
- package/src/autowork.js +387 -0
- package/src/blobstore.js +99 -0
- package/src/boxes.js +37 -0
- package/src/branchdocs.js +235 -0
- package/src/branches.js +194 -0
- package/src/catchup.js +111 -0
- package/src/chat-archive.js +85 -0
- package/src/chat-links.js +923 -0
- package/src/clash.js +140 -0
- package/src/cloud-ai.js +50 -0
- package/src/commit-request.js +59 -0
- package/src/commit.js +213 -0
- package/src/connection.js +569 -0
- package/src/control.js +245 -0
- package/src/conversation.js +125 -0
- package/src/doctor.js +156 -0
- package/src/duties.js +303 -0
- package/src/editors.js +228 -0
- package/src/fsutil.js +87 -0
- package/src/git-hook.js +48 -0
- package/src/git.js +147 -0
- package/src/github-commit.js +320 -0
- package/src/gitignore.js +49 -0
- package/src/gitstate.js +900 -0
- package/src/history.js +307 -0
- package/src/hooks.js +371 -0
- package/src/hosted-agents.js +24 -0
- package/src/hosted-client.js +112 -0
- package/src/identity.js +99 -0
- package/src/inbox.js +237 -0
- package/src/integrations.js +221 -0
- package/src/key-cli.js +115 -0
- package/src/largefiles.js +91 -0
- package/src/legacy.js +25 -0
- package/src/mcp-proxy.js +40 -0
- package/src/mcp-room.js +32 -0
- package/src/mcp.js +1261 -0
- package/src/merge3.js +158 -0
- package/src/merges.js +148 -0
- package/src/pass-source.js +176 -0
- package/src/passes.js +64 -0
- package/src/pathrules.js +127 -0
- package/src/persona.js +101 -0
- package/src/presence.js +360 -0
- package/src/private-file.js +41 -0
- package/src/procs.js +70 -0
- package/src/project-rules.js +178 -0
- package/src/protocol.js +92 -0
- package/src/quiet-warnings.js +9 -0
- package/src/relay-ai-vm.js +82 -0
- package/src/relay-ask.js +141 -0
- package/src/relay-ci.js +111 -0
- package/src/relay-clash.js +597 -0
- package/src/relay-cluster.js +454 -0
- package/src/relay-commit.js +887 -0
- package/src/relay-filekey.js +46 -0
- package/src/relay-git.js +274 -0
- package/src/relay-githttp.js +137 -0
- package/src/relay-github.js +109 -0
- package/src/relay-lease.js +137 -0
- package/src/relay-lfs.js +37 -0
- package/src/relay-mcp.js +1679 -0
- package/src/relay-metrics.js +75 -0
- package/src/relay-outbound.js +1144 -0
- package/src/relay-sync.js +719 -0
- package/src/relay-turn-sync.js +307 -0
- package/src/relay-upstream.js +934 -0
- package/src/relay-webhooks.js +111 -0
- package/src/releases.js +131 -0
- package/src/report.js +129 -0
- package/src/runner.js +306 -0
- package/src/same-edit.js +18 -0
- package/src/server.js +4187 -0
- package/src/session-access.js +170 -0
- package/src/session-name.js +15 -0
- package/src/session.js +7361 -0
- package/src/settings.js +46 -0
- package/src/setup.js +184 -0
- package/src/shell-path.js +46 -0
- package/src/status.js +199 -0
- package/src/summarize.js +109 -0
- package/src/sync-words.js +42 -0
- package/src/task-comments.js +92 -0
- package/src/tasks.js +670 -0
- package/src/textdiff.js +20 -0
- package/src/turn-sync-plan.js +142 -0
- package/src/turn-sync.js +191 -0
- package/src/ui/access-form.js +37 -0
- package/src/ui/agent-env-key.js +76 -0
- package/src/ui/agent-guide.js +127 -0
- package/src/ui/agent-kinds.js +175 -0
- package/src/ui/agent-place.js +265 -0
- package/src/ui/app.css +2309 -0
- package/src/ui/app.js +542 -0
- package/src/ui/board.js +465 -0
- package/src/ui/boxes.js +76 -0
- package/src/ui/branches.js +111 -0
- package/src/ui/catchup.js +77 -0
- package/src/ui/changes.js +159 -0
- package/src/ui/chat.js +266 -0
- package/src/ui/cloud-ai-model.js +107 -0
- package/src/ui/cloud-ai.js +221 -0
- package/src/ui/common.js +431 -0
- package/src/ui/csv.js +21 -0
- package/src/ui/diffview.js +74 -0
- package/src/ui/feed-convs.js +39 -0
- package/src/ui/feed.js +174 -0
- package/src/ui/files.js +579 -0
- package/src/ui/fileview.js +123 -0
- package/src/ui/git-rule.js +60 -0
- package/src/ui/home.js +1323 -0
- package/src/ui/index.html +16 -0
- package/src/ui/invite.js +97 -0
- package/src/ui/invites.js +79 -0
- package/src/ui/loom-model.js +516 -0
- package/src/ui/loom.css +349 -0
- package/src/ui/loom.js +753 -0
- package/src/ui/mark.js +104 -0
- package/src/ui/merges.js +158 -0
- package/src/ui/rail-model.js +78 -0
- package/src/ui/rail.js +173 -0
- package/src/ui/releases.js +182 -0
- package/src/ui/schedule.js +156 -0
- package/src/ui/session.js +3394 -0
- package/src/ui/signin.js +75 -0
- package/src/ui/sounds.js +110 -0
- package/src/ui/tool-logo.js +86 -0
- package/src/ui/tree.js +276 -0
- package/src/ui/workspaces.js +542 -0
- package/src/ui-server.js +1272 -0
- package/src/update-check.js +67 -0
- package/src/upstream.js +155 -0
- package/src/webhooks.js +235 -0
- package/src/workspace-cli.js +62 -0
- package/src/workspace-tools.js +340 -0
- 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
|
+
}
|