heyquilt 0.0.1 → 0.3.106

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (221) hide show
  1. package/LICENSE +85 -0
  2. package/README.md +633 -2
  3. package/RELEASES.md +862 -0
  4. package/assets/logo.svg +1 -0
  5. package/bin/quilt.js +849 -0
  6. package/desktop/main.js +315 -0
  7. package/package.json +163 -7
  8. package/src/account.js +591 -0
  9. package/src/admit-policy.js +32 -0
  10. package/src/agent-join.js +370 -0
  11. package/src/agent-task-workflow.js +176 -0
  12. package/src/agents/actions.js +85 -0
  13. package/src/agents/claude-code.js +187 -0
  14. package/src/agents/common.js +21 -0
  15. package/src/agents/cursor-transcripts.js +177 -0
  16. package/src/agents/cursor.js +404 -0
  17. package/src/agents/index.js +69 -0
  18. package/src/ai-vm-signin.js +110 -0
  19. package/src/ai-vm.js +522 -0
  20. package/src/api/access.js +75 -0
  21. package/src/api/activity.js +181 -0
  22. package/src/api/agent-auth.js +136 -0
  23. package/src/api/agent-placement.js +157 -0
  24. package/src/api/audit.js +83 -0
  25. package/src/api/auth.js +17 -0
  26. package/src/api/domains.js +30 -0
  27. package/src/api/file-paths.js +40 -0
  28. package/src/api/file-store.js +99 -0
  29. package/src/api/fly-machines.js +133 -0
  30. package/src/api/github-app.js +172 -0
  31. package/src/api/http.js +34 -0
  32. package/src/api/invite-email.js +53 -0
  33. package/src/api/issues.js +110 -0
  34. package/src/api/join-text.js +78 -0
  35. package/src/api/mailer.js +47 -0
  36. package/src/api/memory-store.js +1219 -0
  37. package/src/api/org-access.js +54 -0
  38. package/src/api/permissions.js +71 -0
  39. package/src/api/relay-cluster.js +545 -0
  40. package/src/api/relay-machines.js +92 -0
  41. package/src/api/releases.js +87 -0
  42. package/src/api/routes/access-types.js +54 -0
  43. package/src/api/routes/agent-invites.js +115 -0
  44. package/src/api/routes/agents.js +252 -0
  45. package/src/api/routes/ai-vm.js +495 -0
  46. package/src/api/routes/github.js +380 -0
  47. package/src/api/routes/grants.js +70 -0
  48. package/src/api/routes/invites.js +188 -0
  49. package/src/api/routes/issues.js +47 -0
  50. package/src/api/routes/join.js +167 -0
  51. package/src/api/routes/members.js +110 -0
  52. package/src/api/routes/orgs.js +179 -0
  53. package/src/api/routes/relay-cluster.js +90 -0
  54. package/src/api/routes/relay.js +76 -0
  55. package/src/api/routes/secrets.js +448 -0
  56. package/src/api/routes/session-invites.js +119 -0
  57. package/src/api/routes/sessions.js +92 -0
  58. package/src/api/routes/teams.js +132 -0
  59. package/src/api/routes/workspace-agents.js +332 -0
  60. package/src/api/routes/workspace-files.js +277 -0
  61. package/src/api/routes/workspace-invites.js +196 -0
  62. package/src/api/routes/workspaces.js +232 -0
  63. package/src/api/sandbox-keys.js +57 -0
  64. package/src/api/secrets-access.js +93 -0
  65. package/src/api/server.js +654 -0
  66. package/src/api/slugs.js +20 -0
  67. package/src/api/supabase-store.js +875 -0
  68. package/src/api/team-access.js +30 -0
  69. package/src/api/tokens.js +31 -0
  70. package/src/api/workspace-access.js +62 -0
  71. package/src/api/workspace-reach.js +60 -0
  72. package/src/autowork-tools.js +166 -0
  73. package/src/autowork.js +375 -0
  74. package/src/blobstore.js +99 -0
  75. package/src/boxes.js +37 -0
  76. package/src/branchdocs.js +235 -0
  77. package/src/branches.js +194 -0
  78. package/src/catchup.js +111 -0
  79. package/src/chat-archive.js +85 -0
  80. package/src/chat-links.js +923 -0
  81. package/src/clash.js +128 -0
  82. package/src/cloud-ai.js +50 -0
  83. package/src/commit-request.js +59 -0
  84. package/src/commit.js +213 -0
  85. package/src/connection.js +569 -0
  86. package/src/control.js +248 -0
  87. package/src/conversation.js +125 -0
  88. package/src/doctor.js +156 -0
  89. package/src/duties.js +303 -0
  90. package/src/editors.js +228 -0
  91. package/src/fsutil.js +97 -0
  92. package/src/git-hook.js +48 -0
  93. package/src/git.js +147 -0
  94. package/src/github-commit.js +320 -0
  95. package/src/gitignore.js +49 -0
  96. package/src/gitstate.js +924 -0
  97. package/src/history.js +307 -0
  98. package/src/hooks.js +371 -0
  99. package/src/hosted-agents.js +24 -0
  100. package/src/hosted-client.js +112 -0
  101. package/src/identity.js +99 -0
  102. package/src/inbox.js +237 -0
  103. package/src/integrations.js +221 -0
  104. package/src/key-cli.js +115 -0
  105. package/src/largefiles.js +91 -0
  106. package/src/legacy.js +25 -0
  107. package/src/mcp-proxy.js +45 -0
  108. package/src/mcp-room.js +32 -0
  109. package/src/mcp.js +1258 -0
  110. package/src/merge3.js +240 -0
  111. package/src/merges.js +148 -0
  112. package/src/pass-source.js +183 -0
  113. package/src/passes.js +64 -0
  114. package/src/pathrules.js +134 -0
  115. package/src/persona.js +101 -0
  116. package/src/presence.js +360 -0
  117. package/src/private-file.js +41 -0
  118. package/src/procs.js +70 -0
  119. package/src/project-rules.js +178 -0
  120. package/src/protocol.js +92 -0
  121. package/src/quiet-warnings.js +9 -0
  122. package/src/relay-ai-vm.js +82 -0
  123. package/src/relay-ask.js +141 -0
  124. package/src/relay-ci.js +111 -0
  125. package/src/relay-clash.js +214 -0
  126. package/src/relay-cluster.js +454 -0
  127. package/src/relay-commit.js +887 -0
  128. package/src/relay-filekey.js +46 -0
  129. package/src/relay-git.js +274 -0
  130. package/src/relay-githttp.js +166 -0
  131. package/src/relay-github.js +109 -0
  132. package/src/relay-lease.js +137 -0
  133. package/src/relay-lfs.js +37 -0
  134. package/src/relay-mcp.js +1691 -0
  135. package/src/relay-metrics.js +140 -0
  136. package/src/relay-outbound.js +1273 -0
  137. package/src/relay-sync.js +716 -0
  138. package/src/relay-turn-sync.js +506 -0
  139. package/src/relay-upstream.js +804 -0
  140. package/src/relay-webhooks.js +111 -0
  141. package/src/releases.js +138 -0
  142. package/src/report.js +129 -0
  143. package/src/runner.js +307 -0
  144. package/src/same-edit.js +18 -0
  145. package/src/secret-import.js +55 -0
  146. package/src/secret-names.js +26 -0
  147. package/src/secret-run.js +80 -0
  148. package/src/secrets-client.js +56 -0
  149. package/src/server.js +4214 -0
  150. package/src/session-access.js +170 -0
  151. package/src/session-name.js +15 -0
  152. package/src/session.js +7446 -0
  153. package/src/settings.js +46 -0
  154. package/src/setup.js +184 -0
  155. package/src/shell-path.js +46 -0
  156. package/src/status.js +197 -0
  157. package/src/summarize.js +109 -0
  158. package/src/sync-words.js +42 -0
  159. package/src/task-comments.js +92 -0
  160. package/src/tasks.js +670 -0
  161. package/src/textdiff.js +20 -0
  162. package/src/turn-sync-plan.js +145 -0
  163. package/src/turn-sync.js +212 -0
  164. package/src/ui/access-form.js +37 -0
  165. package/src/ui/agent-env-key.js +76 -0
  166. package/src/ui/agent-guide.js +127 -0
  167. package/src/ui/agent-kinds.js +175 -0
  168. package/src/ui/agent-place.js +265 -0
  169. package/src/ui/app.css +2494 -0
  170. package/src/ui/app.js +546 -0
  171. package/src/ui/board.js +465 -0
  172. package/src/ui/boxes.js +76 -0
  173. package/src/ui/branches.js +111 -0
  174. package/src/ui/catchup.js +77 -0
  175. package/src/ui/changes.js +159 -0
  176. package/src/ui/chat.js +266 -0
  177. package/src/ui/cloud-ai-model.js +108 -0
  178. package/src/ui/cloud-ai.js +221 -0
  179. package/src/ui/common.js +433 -0
  180. package/src/ui/csv.js +21 -0
  181. package/src/ui/diffview.js +74 -0
  182. package/src/ui/feed-convs.js +39 -0
  183. package/src/ui/feed.js +174 -0
  184. package/src/ui/files.js +579 -0
  185. package/src/ui/fileview.js +150 -0
  186. package/src/ui/git-rule.js +60 -0
  187. package/src/ui/home.js +1322 -0
  188. package/src/ui/index.html +16 -0
  189. package/src/ui/invite.js +97 -0
  190. package/src/ui/invites.js +79 -0
  191. package/src/ui/loom-model.js +516 -0
  192. package/src/ui/loom.css +365 -0
  193. package/src/ui/loom.js +786 -0
  194. package/src/ui/mark.js +104 -0
  195. package/src/ui/rail-model.js +75 -0
  196. package/src/ui/rail.js +173 -0
  197. package/src/ui/releases.js +182 -0
  198. package/src/ui/sandbox-keys-model.js +53 -0
  199. package/src/ui/sandbox-keys.js +132 -0
  200. package/src/ui/schedule.js +156 -0
  201. package/src/ui/secret-rules.js +255 -0
  202. package/src/ui/secrets-view.js +162 -0
  203. package/src/ui/secrets.js +283 -0
  204. package/src/ui/session.js +3292 -0
  205. package/src/ui/signin.js +75 -0
  206. package/src/ui/sounds.js +110 -0
  207. package/src/ui/tool-logo.js +86 -0
  208. package/src/ui/tree.js +281 -0
  209. package/src/ui/workspaces.js +547 -0
  210. package/src/ui-server.js +1381 -0
  211. package/src/update-check.js +67 -0
  212. package/src/upstream.js +192 -0
  213. package/src/vault/crypto.js +96 -0
  214. package/src/vault/kms.js +61 -0
  215. package/src/vault/main.js +33 -0
  216. package/src/vault/server.js +279 -0
  217. package/src/vault/sigv4.js +38 -0
  218. package/src/webhooks.js +235 -0
  219. package/src/workspace-cli.js +62 -0
  220. package/src/workspace-tools.js +340 -0
  221. package/index.js +0 -2
package/src/account.js ADDED
@@ -0,0 +1,591 @@
1
+ // This computer's sign-in: the token the accounts API gave it when you approved
2
+ // it on heyquilt.com, kept in ~/.quilt/account.json (readable only by you).
3
+ import fs from 'node:fs'
4
+ import os from 'node:os'
5
+ import path from 'node:path'
6
+ import { quiltHome } from './legacy.js'
7
+ import { loadIdentity, signDeviceLink, signDeviceResume } from './identity.js'
8
+ import { isSymlink, writePrivateJson } from './private-file.js'
9
+
10
+ export const API_URL = 'https://api.heyquilt.com'
11
+ export const NOT_SIGNED_IN = 'Run quilt login first.'
12
+ export const SIGNED_OUT = 'This computer was signed out. Sign in again.'
13
+
14
+ /** The accounts API. QUILT_API_URL overrides it, for development and tests. */
15
+ export const apiUrl = () => String(process.env.QUILT_API_URL || API_URL).replace(/\/+$/, '')
16
+ export const accountFile = () => path.join(quiltHome(), 'account.json')
17
+
18
+ /** { token, account: { id, name, email }, signedInAt }, or null when signed out (or the file is a symlink or unreadable). */
19
+ export function readAccount (file = accountFile()) {
20
+ try {
21
+ if (isSymlink(file)) return null
22
+ const a = JSON.parse(fs.readFileSync(file, 'utf8'))
23
+ return a && typeof a.token === 'string' && a.token.startsWith('qd_') && a.account ? a : null
24
+ } catch {
25
+ return null
26
+ }
27
+ }
28
+
29
+ export function saveAccount (data, file = accountFile()) {
30
+ fs.mkdirSync(path.dirname(file), { recursive: true })
31
+ writePrivateJson(file, data)
32
+ fs.rmSync(signedOutFile(file), { force: true })
33
+ return data
34
+ }
35
+
36
+ // Left by Sign out, so this computer doesn't sign itself back in with its key (when the
37
+ // server couldn't be told, say) until someone chooses Sign in. Next to account.json.
38
+ const signedOutFile = (file) => path.join(path.dirname(file), 'signed-out')
39
+ export const signedOutOnPurpose = (file = accountFile()) => fs.existsSync(signedOutFile(file))
40
+
41
+ export function clearAccount (file = accountFile()) {
42
+ fs.rmSync(file, { force: true })
43
+ }
44
+
45
+ /**
46
+ * Forgets the sign-in only if it still holds `token`: another app on this computer may
47
+ * have signed back in since that token was turned away, and its new one must stay.
48
+ */
49
+ export function clearAccountIf (token, file = accountFile()) {
50
+ const a = readAccount(file)
51
+ if (a && a.token !== token) return false
52
+ clearAccount(file)
53
+ return true
54
+ }
55
+
56
+ const BAD_REPLY = 'The sign-in service sent an unexpected reply. Try again.'
57
+
58
+ /** { id, name, email }, from a profile the server sent back. Throws clearly rather than
59
+ * crashing when the server sent something unexpected (no profile at all). */
60
+ export const accountFromProfile = (p) => {
61
+ if (!p || typeof p !== 'object') throw new Error(BAD_REPLY)
62
+ return { id: p.id, name: p.name, email: p.email || '' }
63
+ }
64
+
65
+ async function call (fetchImpl, api, method, route, body, token, extra = {}) {
66
+ let res
67
+ try {
68
+ res = await fetchImpl(api + route, {
69
+ method,
70
+ headers: { ...(body ? { 'content-type': 'application/json' } : {}), ...(token ? { authorization: `Bearer ${token}` } : {}) },
71
+ body: body ? JSON.stringify(body) : undefined,
72
+ ...extra
73
+ })
74
+ } catch (err) {
75
+ throw new Error(`Couldn't reach Quilt (${err.cause?.code || err.message}).`)
76
+ }
77
+ const data = await res.json().catch(() => null)
78
+ if (!res.ok) throw Object.assign(new Error(data?.error || `Quilt answered ${res.status}.`), { status: res.status })
79
+ // A 2xx with a body that isn't JSON (or is `null`/not an object) isn't something callers
80
+ // can use: better a clear message than a crash on whatever field they read next.
81
+ if (!data || typeof data !== 'object') throw new Error(BAD_REPLY)
82
+ return data
83
+ }
84
+
85
+ /** Starts linking this computer to an account: { deviceCode, userCode, verificationUrl, interval, expiresIn }. */
86
+ export async function startLink ({ identity, api = apiUrl(), fetch: fetchImpl = globalThis.fetch } = {}) {
87
+ return call(fetchImpl, api, 'POST', '/v1/device/start', {
88
+ publicKey: identity.publicKey,
89
+ deviceName: os.hostname().replace(/\.local$/, ''),
90
+ platform: process.platform
91
+ })
92
+ }
93
+
94
+ /** One poll: { status: 'pending' } or { status: 'approved', token, profile }. Throws with .status 410 (expired) or 403 (declined). */
95
+ export async function pollLink ({ identity, deviceCode, api = apiUrl(), fetch: fetchImpl = globalThis.fetch } = {}) {
96
+ return call(fetchImpl, api, 'POST', '/v1/device/poll', { deviceCode, signature: signDeviceLink(identity, deviceCode) })
97
+ }
98
+
99
+ /**
100
+ * Polls at the link's interval until someone approves it on the website.
101
+ * Rejects with .expired, .denied, or .cancelled (once `stopped()` says so).
102
+ */
103
+ export async function waitForLink ({ identity, link, api, fetch, stopped = () => false, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), now = Date.now }) {
104
+ const until = now() + link.expiresIn * 1000
105
+ while (now() < until) {
106
+ await sleep(link.interval * 1000)
107
+ if (stopped()) throw Object.assign(new Error('Signing in was cancelled.'), { cancelled: true })
108
+ let r
109
+ try {
110
+ r = await pollLink({ identity, deviceCode: link.deviceCode, api, fetch })
111
+ } catch (err) {
112
+ if (err.status === 410) break
113
+ if (err.status === 403) throw Object.assign(new Error('Sign-in was declined in the browser.'), { denied: true })
114
+ // A network error, a transient 5xx, or 429 (rate limited): try again at the next
115
+ // interval rather than giving up on the sign-in. Anything else (400, 401, 404, …) is fatal.
116
+ if (err.status && !(err.status >= 500 || err.status === 429)) throw err
117
+ continue
118
+ }
119
+ if (r.status === 'approved') return r
120
+ }
121
+ throw Object.assign(new Error('The code expired.'), { expired: true })
122
+ }
123
+
124
+ /**
125
+ * Signs this computer back in with its key (~/.quilt/identity.json), no browser: the
126
+ * account is linked to the computer, not to one token. Saves and returns the new sign-in,
127
+ * or null when this computer isn't linked to an account (never was, or was unlinked).
128
+ * Throws when Quilt can't say (offline, or a server error): keep what's saved then.
129
+ * After Sign out it does nothing (null) unless `asked`: someone chose Sign in.
130
+ */
131
+ export async function resumeAccount ({ identity, api = apiUrl(), fetch: fetchImpl = globalThis.fetch, file = accountFile(), now = Date.now, asked = false } = {}) {
132
+ if (!asked && signedOutOnPurpose(file)) return null
133
+ const me = identity || loadIdentity()
134
+ const at = now()
135
+ let r
136
+ try {
137
+ r = await call(fetchImpl, api, 'POST', '/v1/device/resume', { publicKey: me.publicKey, at, signature: signDeviceResume(me, at) })
138
+ } catch (err) {
139
+ // 404: not linked. 400/401: a bad clock or key, which only the website can sort out.
140
+ if (err.status === 404 || err.status === 400 || err.status === 401) return null
141
+ throw err
142
+ }
143
+ if (typeof r.token !== 'string' || !r.token.startsWith('qd_')) throw new Error(BAD_REPLY)
144
+ return saveAccount({ token: r.token, account: accountFromProfile(r.profile), signedInAt: now() }, file)
145
+ }
146
+
147
+ /** Your profile ({ id, name, email, … }) from this computer's token. Throws with .status 401 once it's revoked. */
148
+ export async function fetchMe ({ token, api = apiUrl(), fetch: fetchImpl = globalThis.fetch }) {
149
+ return (await call(fetchImpl, api, 'GET', '/v1/me', null, token)).profile
150
+ }
151
+
152
+ /**
153
+ * The owner renames a session on heyquilt.com: { session: { room, name } }. Throws with
154
+ * .status: 404 until the relay has reported the session, 403 for anyone but the owner.
155
+ */
156
+ export async function renameSession ({ token, room, name, api = apiUrl(), fetch: fetchImpl = globalThis.fetch }) {
157
+ return call(fetchImpl, api, 'PUT', `/v1/me/sessions/${encodeURIComponent(room)}`, { name }, token)
158
+ }
159
+
160
+ /**
161
+ * Forgets this computer's sign-in right away, then tries to revoke the token on the
162
+ * server (best effort: it may be revoked already, or Quilt unreachable or slow). The
163
+ * file goes first so a stuck or slow server can never make `quilt logout` hang.
164
+ */
165
+ export async function signOut ({ token, api = apiUrl(), fetch: fetchImpl = globalThis.fetch, file = accountFile(), revokeTimeoutMs = 5000 } = {}) {
166
+ clearAccount(file)
167
+ try { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(signedOutFile(file), '') } catch {}
168
+ await revokeToken({ token, api, fetch: fetchImpl, timeoutMs: revokeTimeoutMs })
169
+ }
170
+
171
+ /**
172
+ * A one-time agent invite link for this computer's account: { link, id, expiresAt }. `global`
173
+ * (workspaces on) makes it a global agent's: in all your workspaces once it joins. Throws with
174
+ * .status 401 once the token is revoked.
175
+ */
176
+ export async function createAgentInvite ({ token, global = false, api = apiUrl(), fetch: fetchImpl = globalThis.fetch }) {
177
+ const r = await call(fetchImpl, api, 'POST', '/v1/agent-invites', global ? { global: true } : {}, token)
178
+ if (!r.link || !r.invite) throw new Error(BAD_REPLY)
179
+ return { link: r.link, id: r.invite.id, expiresAt: r.invite.expiresAt }
180
+ }
181
+
182
+ /** This account's agents (not revoked), as the API lists them. Throws with .status 401 once the token is revoked. */
183
+ export async function listAgents ({ token, api = apiUrl(), fetch: fetchImpl = globalThis.fetch }) {
184
+ const r = await call(fetchImpl, api, 'GET', '/v1/agents', null, token)
185
+ if (!Array.isArray(r.agents)) throw new Error(BAD_REPLY)
186
+ return r.agents
187
+ }
188
+
189
+ // Sandbox keys (QUILT_AGENT_KEY) on one of your agents: its Keys table in Settings › Agents.
190
+ const sandbox$ = (id, rest = '') => `/v1/agents/${encodeURIComponent(id)}/sandbox-keys${rest}`
191
+
192
+ /** An agent's sandbox keys ({ keys, sessions }): never the keys themselves. */
193
+ export async function listSandboxKeys ({ token, id, api = apiUrl(), fetch: fetchImpl = globalThis.fetch }) {
194
+ const r = await call(fetchImpl, api, 'GET', sandbox$(id), null, token)
195
+ if (!Array.isArray(r.keys) || !Array.isArray(r.sessions)) throw new Error(BAD_REPLY)
196
+ return r
197
+ }
198
+ /** A new sandbox key for a session (`session`, its invite link, or `room`, one a key already has): { envKey, key }, shown once. */
199
+ export async function createSandboxKey ({ token, id, name, session, room, sessionName, api = apiUrl(), fetch: fetchImpl = globalThis.fetch }) {
200
+ const r = await call(fetchImpl, api, 'POST', `/v1/agents/${encodeURIComponent(id)}/env-key`, { name, ...(session ? { session } : { room }), ...(sessionName ? { sessionName } : {}) }, token)
201
+ if (!r.envKey) throw new Error(BAD_REPLY)
202
+ return r
203
+ }
204
+ /** Rotate: the same key made again (shown once), the old one revoked. */
205
+ export async function rotateSandboxKey ({ token, id, keyId, api = apiUrl(), fetch: fetchImpl = globalThis.fetch }) {
206
+ const r = await call(fetchImpl, api, 'POST', sandbox$(id, `/${encodeURIComponent(keyId)}/rotate`), {}, token)
207
+ if (!r.envKey) throw new Error(BAD_REPLY)
208
+ return r
209
+ }
210
+ export async function revokeSandboxKey ({ token, id, keyId, api = apiUrl(), fetch: fetchImpl = globalThis.fetch }) {
211
+ return call(fetchImpl, api, 'POST', sandbox$(id, `/${encodeURIComponent(keyId)}/revoke`), {}, token)
212
+ }
213
+ export async function removeSandboxKey ({ token, id, keyId, api = apiUrl(), fetch: fetchImpl = globalThis.fetch }) {
214
+ return call(fetchImpl, api, 'DELETE', sandbox$(id, `/${encodeURIComponent(keyId)}`), null, token)
215
+ }
216
+
217
+ /** This account's GitHub connection (Quilt's GitHub App): { available, connected, login? }. */
218
+ export async function githubStatus ({ token, api = apiUrl(), fetch: fetchImpl = globalThis.fetch }) {
219
+ return call(fetchImpl, api, 'GET', '/v1/me/github', null, token)
220
+ }
221
+
222
+ /** Where to send this person to connect GitHub (install Quilt's app on their repositories). */
223
+ export async function githubConnectUrl ({ token, repo = '', scope = '', id = '', api = apiUrl(), fetch: fetchImpl = globalThis.fetch }) {
224
+ const r = await call(fetchImpl, api, 'POST', '/v1/github/connect', { ...(repo ? { repo } : {}), ...(scope ? { scope, id } : {}) }, token)
225
+ if (!r.url) throw new Error(BAD_REPLY)
226
+ return r.url
227
+ }
228
+
229
+ /** A workspace's GitHub (plan §2): its connections, access mode and repositories, and its org's switch. */
230
+ export async function workspaceGithub ({ token, id, api = apiUrl(), fetch: fetchImpl = globalThis.fetch }) {
231
+ return call(fetchImpl, api, 'GET', `/v1/workspaces/${encodeURIComponent(id)}/github`, null, token)
232
+ }
233
+ export async function setWorkspaceGithub ({ token, id, access, repos, agentCommits, api = apiUrl(), fetch: fetchImpl = globalThis.fetch }) {
234
+ // Only what is given changes: the access mode (with its repositories) and/or what agents may commit ('' for each session).
235
+ const body = { ...(access !== undefined ? { access, repos } : {}), ...(agentCommits !== undefined ? { agentCommits } : {}) }
236
+ return call(fetchImpl, api, 'PUT', `/v1/workspaces/${encodeURIComponent(id)}/github`, body, token)
237
+ }
238
+ export async function removeWorkspaceGithub ({ token, id, connectedBy = '', api = apiUrl(), fetch: fetchImpl = globalThis.fetch }) {
239
+ return call(fetchImpl, api, 'DELETE', `/v1/workspaces/${encodeURIComponent(id)}/github/connection`, connectedBy ? { connectedBy } : {}, token)
240
+ }
241
+ export async function setOrgGithub ({ token, slug, requireGithub, agentCommits, api = apiUrl(), fetch: fetchImpl = globalThis.fetch }) {
242
+ const body = { ...(requireGithub !== undefined ? { requireGithub: !!requireGithub } : {}), ...(agentCommits !== undefined ? { agentCommits } : {}) }
243
+ return call(fetchImpl, api, 'PUT', `/v1/orgs/${encodeURIComponent(slug)}/github`, body, token)
244
+ }
245
+
246
+ // Access types, grants and session invites, as this computer's account (see
247
+ // docs/superpowers/specs/2026-10-02-access-types-and-invites-design.md). Each throws with
248
+ // .status when the API says no (401 once the token is revoked).
249
+ const room$ = (room) => `/v1/sessions/${encodeURIComponent(room)}`
250
+
251
+ /** The built-in access types, then this account's own. */
252
+ export async function listAccessTypes ({ token, api = apiUrl(), fetch: fetchImpl = globalThis.fetch }) {
253
+ const r = await call(fetchImpl, api, 'GET', '/v1/access-types', null, token)
254
+ if (!Array.isArray(r.types)) throw new Error(BAD_REPLY)
255
+ return r.types
256
+ }
257
+
258
+ /** People and agents this account has worked with: [{ account, name, kind, lastTogetherAt }]. */
259
+ export async function listCollaborators ({ token, api = apiUrl(), fetch: fetchImpl = globalThis.fetch }) {
260
+ const r = await call(fetchImpl, api, 'GET', '/v1/me/collaborators', null, token)
261
+ if (!Array.isArray(r.collaborators)) throw new Error(BAD_REPLY)
262
+ return r.collaborators
263
+ }
264
+
265
+ /** The owner's grants in a session: [{ account, typeId, typeName, tighten, access }]. */
266
+ export async function listGrants ({ token, room, api = apiUrl(), fetch: fetchImpl = globalThis.fetch }) {
267
+ const r = await call(fetchImpl, api, 'GET', `${room$(room)}/grants`, null, token)
268
+ if (!Array.isArray(r.grants)) throw new Error(BAD_REPLY)
269
+ return r.grants
270
+ }
271
+
272
+ /** Gives `account` an access type in the owner's session, narrowed by `tighten`: the grant, with the access it comes to. */
273
+ export async function putGrant ({ token, room, account, typeId, tighten = {}, api = apiUrl(), fetch: fetchImpl = globalThis.fetch }) {
274
+ const r = await call(fetchImpl, api, 'PUT', `${room$(room)}/grants/${encodeURIComponent(account)}`, { typeId, tighten }, token)
275
+ if (!r.grant || !r.grant.access) throw new Error(BAD_REPLY)
276
+ return r.grant
277
+ }
278
+
279
+ export async function deleteGrant ({ token, room, account, api = apiUrl(), fetch: fetchImpl = globalThis.fetch }) {
280
+ await call(fetchImpl, api, 'DELETE', `${room$(room)}/grants/${encodeURIComponent(account)}`, null, token)
281
+ }
282
+
283
+ /** Invites `to` ({ email } or { account }) to the owner's session as `typeId`. `link` is the session's invite link, for the email. */
284
+ export async function inviteToSession ({ token, room, typeId, to, link, api = apiUrl(), fetch: fetchImpl = globalThis.fetch }) {
285
+ const r = await call(fetchImpl, api, 'POST', `${room$(room)}/invites`, { typeId, to, link }, token)
286
+ if (!r.invite) throw new Error(BAD_REPLY)
287
+ return r.invite
288
+ }
289
+
290
+ export async function listSessionInvites ({ token, room, api = apiUrl(), fetch: fetchImpl = globalThis.fetch }) {
291
+ const r = await call(fetchImpl, api, 'GET', `${room$(room)}/invites`, null, token)
292
+ if (!Array.isArray(r.invites)) throw new Error(BAD_REPLY)
293
+ return r.invites
294
+ }
295
+
296
+ export async function cancelSessionInvite ({ token, room, id, api = apiUrl(), fetch: fetchImpl = globalThis.fetch }) {
297
+ await call(fetchImpl, api, 'DELETE', `${room$(room)}/invites/${encodeURIComponent(id)}`, null, token)
298
+ }
299
+
300
+ // Workspaces (see docs/superpowers/specs/2026-10-03-workspaces-design.md). Each throws with
301
+ // .status when the API says no: 404 when the workspaces flag is off on the API.
302
+ const ws$ = (id) => `/v1/workspaces/${encodeURIComponent(id)}`
303
+
304
+ /** The orgs this account belongs to ({ slug, name, ... }), for "Where" a new workspace goes. */
305
+ export async function listOrgs ({ token, api = apiUrl(), fetch: fetchImpl = globalThis.fetch }) {
306
+ const r = await call(fetchImpl, api, 'GET', '/v1/orgs', null, token)
307
+ if (!Array.isArray(r.orgs)) throw new Error(BAD_REPLY)
308
+ return r.orgs
309
+ }
310
+
311
+ export async function listWorkspaces ({ token, api = apiUrl(), fetch: fetchImpl = globalThis.fetch }) {
312
+ const r = await call(fetchImpl, api, 'GET', '/v1/me/workspaces', null, token)
313
+ if (!Array.isArray(r.workspaces)) throw new Error(BAD_REPLY)
314
+ return r.workspaces
315
+ }
316
+ export async function createWorkspace ({ token, name, description = '', color = '', org, api = apiUrl(), fetch: fetchImpl = globalThis.fetch }) {
317
+ const r = await call(fetchImpl, api, 'POST', '/v1/workspaces', { name, description, color, ...(org ? { org } : {}) }, token)
318
+ if (!r.workspace) throw new Error(BAD_REPLY)
319
+ return r.workspace
320
+ }
321
+ export async function getWorkspace ({ token, id, api = apiUrl(), fetch: fetchImpl = globalThis.fetch }) {
322
+ const r = await call(fetchImpl, api, 'GET', ws$(id), null, token)
323
+ if (!r.workspace) throw new Error(BAD_REPLY)
324
+ return r
325
+ }
326
+ export async function updateWorkspace ({ token, id, patch, api = apiUrl(), fetch: fetchImpl = globalThis.fetch }) {
327
+ const r = await call(fetchImpl, api, 'PATCH', ws$(id), patch, token)
328
+ if (!r.workspace) throw new Error(BAD_REPLY)
329
+ return r.workspace
330
+ }
331
+ export async function deleteWorkspace ({ token, id, api = apiUrl(), fetch: fetchImpl = globalThis.fetch }) {
332
+ await call(fetchImpl, api, 'DELETE', ws$(id), null, token)
333
+ }
334
+ /** `sessions` ('all' or 'invited') is for an agent only: whether it joins every session here by itself. Left out, it is kept. */
335
+ export async function putWorkspaceMember ({ token, id, account, access, sessions, api = apiUrl(), fetch: fetchImpl = globalThis.fetch }) {
336
+ const r = await call(fetchImpl, api, 'PUT', `${ws$(id)}/members/${encodeURIComponent(account)}`, { access, ...(sessions !== undefined ? { sessions } : {}) }, token)
337
+ if (!r.member) throw new Error(BAD_REPLY)
338
+ return r.member
339
+ }
340
+ export async function removeWorkspaceMember ({ token, id, account, api = apiUrl(), fetch: fetchImpl = globalThis.fetch }) {
341
+ await call(fetchImpl, api, 'DELETE', `${ws$(id)}/members/${encodeURIComponent(account)}`, null, token)
342
+ }
343
+ export async function setSessionWorkspace ({ token, id, room, api = apiUrl(), fetch: fetchImpl = globalThis.fetch }) {
344
+ const r = await call(fetchImpl, api, 'POST', `${ws$(id)}/sessions`, { room }, token)
345
+ if (!r.session) throw new Error(BAD_REPLY)
346
+ return r.session
347
+ }
348
+ /** Moves a session into workspace `id`: { session, added (the people and agents it brought in), peopleNeedAdmin? }. */
349
+ export async function moveSessionToWorkspace ({ token, id, room, api = apiUrl(), fetch: fetchImpl = globalThis.fetch }) {
350
+ const r = await call(fetchImpl, api, 'POST', `${ws$(id)}/sessions`, { room }, token)
351
+ if (!r.session) throw new Error(BAD_REPLY)
352
+ return { session: r.session, added: Array.isArray(r.added) ? r.added : [], peopleNeedAdmin: !!r.peopleNeedAdmin }
353
+ }
354
+
355
+ // Workspace invites: an admin invites a person ({ account } or { email }); they accept in their Quilt.
356
+ export async function listWorkspaceInvites ({ token, id, api = apiUrl(), fetch: fetchImpl = globalThis.fetch }) {
357
+ const r = await call(fetchImpl, api, 'GET', `${ws$(id)}/invites`, null, token)
358
+ if (!Array.isArray(r.invites)) throw new Error(BAD_REPLY)
359
+ return r.invites
360
+ }
361
+ export async function inviteToWorkspace ({ token, id, to, access, api = apiUrl(), fetch: fetchImpl = globalThis.fetch }) {
362
+ const r = await call(fetchImpl, api, 'POST', `${ws$(id)}/invites`, { to, access }, token)
363
+ if (!r.invite) throw new Error(BAD_REPLY)
364
+ return r.invite
365
+ }
366
+ export async function cancelWorkspaceInvite ({ token, id, inviteId, api = apiUrl(), fetch: fetchImpl = globalThis.fetch }) {
367
+ await call(fetchImpl, api, 'DELETE', `${ws$(id)}/invites/${encodeURIComponent(inviteId)}`, null, token)
368
+ }
369
+
370
+ /** The invites waiting for this account: [{ kind: 'workspace' | 'session', id, from, ... }]. */
371
+ export async function listMyInvites ({ token, api = apiUrl(), fetch: fetchImpl = globalThis.fetch }) {
372
+ const r = await call(fetchImpl, api, 'GET', '/v1/me/invites', null, token)
373
+ if (!Array.isArray(r.invites)) throw new Error(BAD_REPLY)
374
+ return r.invites
375
+ }
376
+ /** `how` is 'accept' (a workspace invite) or 'decline' (either kind). */
377
+ export async function answerMyInvite ({ token, id, how, api = apiUrl(), fetch: fetchImpl = globalThis.fetch }) {
378
+ if (how !== 'accept' && how !== 'decline') throw new Error('accept or decline')
379
+ return call(fetchImpl, api, 'POST', `/v1/me/invites/${encodeURIComponent(id)}/${how}`, {}, token)
380
+ }
381
+
382
+ /**
383
+ * Tells the API the session in `room` (already in workspace `id`) has started, with its join
384
+ * link, so every agent that joins it by itself is sent the link at once. The API never keeps
385
+ * it. { notified, withoutWebhook } (agent ids); throws with .status (409: the relay has not
386
+ * said who owns the session yet).
387
+ */
388
+ export async function announceSessionStarted ({ token, id, room, link, agents, api = apiUrl(), fetch: fetchImpl = globalThis.fetch }) {
389
+ const r = await call(fetchImpl, api, 'POST', `${ws$(id)}/sessions/${encodeURIComponent(room)}/started`, agents ? { link, agents } : { link }, token)
390
+ if (!Array.isArray(r.notified)) throw new Error(BAD_REPLY)
391
+ return r
392
+ }
393
+
394
+ // Waits between tries while the API has not heard from the relay who owns a new session
395
+ // (it reports at once when the owner connects, and every minute after).
396
+ export const ANNOUNCE_DELAYS_MS = [1000, 2000, 4000, 8000, 15_000, 30_000, 30_000]
397
+ const idle = (ms) => new Promise((resolve) => { const t = setTimeout(resolve, ms); t.unref?.() })
398
+
399
+ /**
400
+ * Runs `announce` (an announceSessionStarted call) in the background of a session start: tries
401
+ * again after each delay while the API answers 409, stops once `alive()` says the session is
402
+ * gone, and logs the outcome. Resolves the API's answer or null; never throws.
403
+ */
404
+ export async function announceWhenReported (announce, { alive = () => true, log = () => {}, delays = ANNOUNCE_DELAYS_MS, sleep = idle } = {}) {
405
+ for (let i = 0; ; i++) {
406
+ if (!alive()) return null
407
+ try {
408
+ const r = await announce()
409
+ if (r.notified.length) log(`told ${r.notified.length === 1 ? '1 agent' : `${r.notified.length} agents`} in this workspace that the session started`)
410
+ return r
411
+ } catch (err) {
412
+ if (err.status !== 409 || i >= delays.length) {
413
+ log(`could not tell this workspace's agents the session started: ${err.message}`)
414
+ return null
415
+ }
416
+ }
417
+ await sleep(delays[i])
418
+ }
419
+ }
420
+
421
+ export async function unsetSessionWorkspace ({ token, id, room, api = apiUrl(), fetch: fetchImpl = globalThis.fetch }) {
422
+ await call(fetchImpl, api, 'DELETE', `${ws$(id)}/sessions/${encodeURIComponent(room)}`, null, token)
423
+ }
424
+
425
+ // Where agents work (phase 3). Each throws with .status when the API says no.
426
+ const agent$ = (id) => `/v1/me/agents/${encodeURIComponent(id)}/placement`
427
+
428
+ /** One of your agents' placement: { reach, workspaceIds, sessions, access, scopes }; 'manual' when it has none. */
429
+ export async function getAgentPlacement ({ token, id, api = apiUrl(), fetch: fetchImpl = globalThis.fetch }) {
430
+ const r = await call(fetchImpl, api, 'GET', agent$(id), null, token)
431
+ if (!r.placement) throw new Error(BAD_REPLY)
432
+ return r.placement
433
+ }
434
+ export async function putAgentPlacement ({ token, id, placement, api = apiUrl(), fetch: fetchImpl = globalThis.fetch }) {
435
+ const r = await call(fetchImpl, api, 'PUT', agent$(id), placement, token)
436
+ if (!r.placement) throw new Error(BAD_REPLY)
437
+ return r.placement
438
+ }
439
+ /** An org's agents ({ id, name, provider, type, hosted, placement }); needs Agents: Read in the org. */
440
+ export async function listOrgAgents ({ token, slug, api = apiUrl(), fetch: fetchImpl = globalThis.fetch }) {
441
+ const r = await call(fetchImpl, api, 'GET', `/v1/orgs/${encodeURIComponent(slug)}/agents`, null, token)
442
+ if (!Array.isArray(r.agents)) throw new Error(BAD_REPLY)
443
+ return r.agents
444
+ }
445
+ /** A workspace's say over an agent placed there: `sessions` ('all', 'invited' or null) and `excluded`; a field left out is kept. */
446
+ export async function putWorkspaceAgent ({ token, id, agentId, sessions, excluded, api = apiUrl(), fetch: fetchImpl = globalThis.fetch }) {
447
+ const body = { ...(sessions !== undefined ? { sessions } : {}), ...(excluded !== undefined ? { excluded } : {}) }
448
+ const r = await call(fetchImpl, api, 'PUT', `${ws$(id)}/agents/${encodeURIComponent(agentId)}`, body, token)
449
+ if (!r.override) throw new Error(BAD_REPLY)
450
+ return r.override
451
+ }
452
+ /** Drops the workspace's say over a placed agent: it follows its own placement again. */
453
+ export async function deleteWorkspaceAgent ({ token, id, agentId, api = apiUrl(), fetch: fetchImpl = globalThis.fetch }) {
454
+ await call(fetchImpl, api, 'DELETE', `${ws$(id)}/agents/${encodeURIComponent(agentId)}`, null, token)
455
+ }
456
+ /** A one-time link for a new agent that joins the workspace as a member: { link, invite }. */
457
+ export async function createWorkspaceAgentInvite ({ token, id, access = 'edit', sessions = 'invited', api = apiUrl(), fetch: fetchImpl = globalThis.fetch }) {
458
+ const r = await call(fetchImpl, api, 'POST', `${ws$(id)}/agent-invites`, { access, sessions }, token)
459
+ if (!r.link || !r.invite) throw new Error(BAD_REPLY)
460
+ return r
461
+ }
462
+ /** The session owner keeps an agent out of one session, even when its workspace would let it in. */
463
+ export async function excludeSessionAgent ({ token, room, agentId, api = apiUrl(), fetch: fetchImpl = globalThis.fetch }) {
464
+ await call(fetchImpl, api, 'PUT', `${room$(room)}/agents/${encodeURIComponent(agentId)}/exclude`, {}, token)
465
+ }
466
+
467
+ /** A session's agents for its owner: the ones its workspace invites and the kept out, [{ agentId, name, via, managedBy, excluded }]. */
468
+ export async function listSessionAgents ({ token, room, api = apiUrl(), fetch: fetchImpl = globalThis.fetch }) {
469
+ const r = await call(fetchImpl, api, 'GET', `${room$(room)}/agents`, null, token)
470
+ if (!Array.isArray(r.agents)) throw new Error(BAD_REPLY)
471
+ return r.agents
472
+ }
473
+
474
+ /** The agents the owner keeps out of one session: [{ agentId, name }]. */
475
+ export async function listExcludedSessionAgents ({ token, room, api = apiUrl(), fetch: fetchImpl = globalThis.fetch }) {
476
+ const r = await call(fetchImpl, api, 'GET', `${room$(room)}/agents/excluded`, null, token)
477
+ if (!Array.isArray(r.agents)) throw new Error(BAD_REPLY)
478
+ return r.agents
479
+ }
480
+ /** Lets an agent the owner kept out of one session back in (its workspace may let it join again). */
481
+ export async function includeSessionAgent ({ token, room, agentId, api = apiUrl(), fetch: fetchImpl = globalThis.fetch }) {
482
+ await call(fetchImpl, api, 'DELETE', `${room$(room)}/agents/${encodeURIComponent(agentId)}/exclude`, null, token)
483
+ }
484
+
485
+ // Workspace files (phase 2). Each throws with .status when the API says no.
486
+ const file$ = (id, fileId) => `${ws$(id)}/files/${encodeURIComponent(fileId)}`
487
+ export async function listWorkspaceFiles ({ token, id, folder, api = apiUrl(), fetch: fetchImpl = globalThis.fetch }) {
488
+ const r = await call(fetchImpl, api, 'GET', `${ws$(id)}/files${folder !== undefined ? `?folder=${encodeURIComponent(folder)}` : ''}`, null, token)
489
+ if (!Array.isArray(r.files)) throw new Error(BAD_REPLY)
490
+ return r.files
491
+ }
492
+ export async function createWorkspaceFile ({ token, id, path, size, mime = '', sha256 = '', note = '', api = apiUrl(), fetch: fetchImpl = globalThis.fetch }) {
493
+ const r = await call(fetchImpl, api, 'POST', `${ws$(id)}/files`, { path, size, mime, sha256, note }, token)
494
+ if (!r.file || !r.upload) throw new Error(BAD_REPLY)
495
+ return r
496
+ }
497
+ export async function confirmWorkspaceFile ({ token, id, fileId, api = apiUrl(), fetch: fetchImpl = globalThis.fetch }) {
498
+ const r = await call(fetchImpl, api, 'POST', `${file$(id, fileId)}/done`, {}, token)
499
+ if (!r.file) throw new Error(BAD_REPLY)
500
+ return r.file
501
+ }
502
+ export async function workspaceFileDownload ({ token, id, fileId, version, api = apiUrl(), fetch: fetchImpl = globalThis.fetch }) {
503
+ const r = await call(fetchImpl, api, 'GET', `${file$(id, fileId)}/download${version ? `?version=${encodeURIComponent(version)}` : ''}`, null, token)
504
+ if (!r.url) throw new Error(BAD_REPLY)
505
+ return r
506
+ }
507
+ export async function updateWorkspaceFile ({ token, id, fileId, patch, api = apiUrl(), fetch: fetchImpl = globalThis.fetch }) {
508
+ const r = await call(fetchImpl, api, 'PATCH', file$(id, fileId), patch, token)
509
+ if (!r.file) throw new Error(BAD_REPLY)
510
+ return r.file
511
+ }
512
+ export async function deleteWorkspaceFile ({ token, id, fileId, api = apiUrl(), fetch: fetchImpl = globalThis.fetch }) {
513
+ await call(fetchImpl, api, 'DELETE', file$(id, fileId), null, token)
514
+ }
515
+ export async function createWorkspaceFolder ({ token, id, path, api = apiUrl(), fetch: fetchImpl = globalThis.fetch }) {
516
+ const r = await call(fetchImpl, api, 'POST', `${ws$(id)}/folders`, { path }, token)
517
+ if (!r.file) throw new Error(BAD_REPLY)
518
+ return r.file
519
+ }
520
+ export async function listWorkspaceFileVersions ({ token, id, fileId, api = apiUrl(), fetch: fetchImpl = globalThis.fetch }) {
521
+ const r = await call(fetchImpl, api, 'GET', `${file$(id, fileId)}/versions`, null, token)
522
+ if (!Array.isArray(r.versions)) throw new Error(BAD_REPLY)
523
+ return r.versions
524
+ }
525
+
526
+ /** Revokes a token on the server, best effort, without touching account.json. */
527
+ export async function revokeToken ({ token, api = apiUrl(), fetch: fetchImpl = globalThis.fetch, timeoutMs = 5000 } = {}) {
528
+ if (token) await call(fetchImpl, api, 'POST', '/v1/me/signout', {}, token, { signal: AbortSignal.timeout(timeoutMs) }).catch(() => {})
529
+ }
530
+
531
+ // ------------------------------------------------- your AI on its own cloud machine --
532
+
533
+ /**
534
+ * Hands the person's cloud AI (routes/ai-vm.js) the link of a session they joined, so it joins
535
+ * too. Resolves to true when they have one, false when they don't (the API's 404).
536
+ */
537
+ export async function aiVmFollow ({ token, link, api = apiUrl(), fetch: fetchImpl = globalThis.fetch }) {
538
+ try {
539
+ await call(fetchImpl, api, 'POST', '/v1/me/ai-vm/follow', { link }, token)
540
+ return true
541
+ } catch (err) {
542
+ if (err.status === 404) return false
543
+ throw err
544
+ }
545
+ }
546
+
547
+ /** Tells the person's cloud AI they left a session for good: it leaves too. False when they have none. */
548
+ export async function aiVmUnfollow ({ token, room, api = apiUrl(), fetch: fetchImpl = globalThis.fetch }) {
549
+ try {
550
+ await call(fetchImpl, api, 'POST', '/v1/me/ai-vm/follow', { room, leave: true }, token)
551
+ return true
552
+ } catch (err) {
553
+ if (err.status === 404) return false
554
+ throw err
555
+ }
556
+ }
557
+
558
+ /**
559
+ * The person's cloud AI as Settings → Your AI shows it, or null when they have none. With `wait`
560
+ * (the version the app has), the API holds the answer until something changes, up to 25 s.
561
+ */
562
+ export async function aiVmStatus ({ token, wait, api = apiUrl(), fetch: fetchImpl = globalThis.fetch }) {
563
+ const v = Number(wait)
564
+ try {
565
+ return await call(fetchImpl, api, 'GET', `/v1/me/ai-vm${Number.isInteger(v) && v > 0 ? `?wait=${v}` : ''}`, null, token)
566
+ } catch (err) {
567
+ if (err.status === 404) return null
568
+ throw err
569
+ }
570
+ }
571
+
572
+ /** Connects a cloud AI running `tool` (Claude Code, Codex or Cursor): starts its machine. */
573
+ export async function aiVmConnect ({ token, tool, api = apiUrl(), fetch: fetchImpl = globalThis.fetch }) {
574
+ return call(fetchImpl, api, 'POST', '/v1/me/ai-vm', { tool }, token)
575
+ }
576
+ /** Changes the cloud AI's settings: `patch` is any of { tool, model, prompt, cap }. */
577
+ export async function aiVmUpdate ({ token, patch, api = apiUrl(), fetch: fetchImpl = globalThis.fetch }) {
578
+ return call(fetchImpl, api, 'PATCH', '/v1/me/ai-vm', patch, token)
579
+ }
580
+ /** Starts the tool's sign-in on the machine again or, with `code`, gives it the code its page showed. */
581
+ export async function aiVmSignIn ({ token, code, api = apiUrl(), fetch: fetchImpl = globalThis.fetch }) {
582
+ return call(fetchImpl, api, 'POST', '/v1/me/ai-vm/sign-in', code === undefined ? {} : { code }, token)
583
+ }
584
+ /** Resumes the cloud AI's machine now. */
585
+ export async function aiVmWake ({ token, api = apiUrl(), fetch: fetchImpl = globalThis.fetch }) {
586
+ return call(fetchImpl, api, 'POST', '/v1/me/ai-vm/wake', {}, token)
587
+ }
588
+ /** Disconnects the cloud AI: its machine and disk are deleted. */
589
+ export async function aiVmDisconnect ({ token, api = apiUrl(), fetch: fetchImpl = globalThis.fetch }) {
590
+ await call(fetchImpl, api, 'DELETE', '/v1/me/ai-vm', null, token)
591
+ }
@@ -0,0 +1,32 @@
1
+ // Who may let people into a controlled session: a per-room setting the owner picks.
2
+ // Pure and shared: the relay enforces it, the app shows it, tests check the maths.
3
+
4
+ export const ADMIT_BY = Object.freeze(['owner', 'editors', 'members'])
5
+ export const DEFAULT_ADMIT_BY = 'owner'
6
+
7
+ export const ADMIT_BY_LABELS = Object.freeze({
8
+ owner: 'Only the owner',
9
+ editors: 'Anyone who can edit',
10
+ members: 'Anyone in the session'
11
+ })
12
+
13
+ export const BAD_ADMIT_BY = 'Who can let people in must be only the owner, anyone who can edit, or anyone in the session.'
14
+
15
+ /** A known admit-by value, or null. */
16
+ export function cleanAdmitBy (raw) {
17
+ const v = String(raw || '').trim()
18
+ return ADMIT_BY.includes(v) ? v : null
19
+ }
20
+
21
+ /**
22
+ * May this approved person let people in, given the room's setting?
23
+ * `access` is relay shape: { owner, role } (viewers never admit unless they are the owner).
24
+ */
25
+ export function canAdmit (access, admitBy = DEFAULT_ADMIT_BY) {
26
+ if (!access) return false
27
+ if (access.owner) return true
28
+ const policy = cleanAdmitBy(admitBy) || DEFAULT_ADMIT_BY
29
+ if (policy === 'owner') return false
30
+ if (policy === 'editors') return access.role === 'editor'
31
+ return policy === 'members'
32
+ }