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.
- package/LICENSE +85 -0
- package/README.md +633 -2
- package/RELEASES.md +862 -0
- package/assets/logo.svg +1 -0
- package/bin/quilt.js +849 -0
- package/desktop/main.js +315 -0
- package/package.json +163 -7
- package/src/account.js +591 -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 +187 -0
- package/src/agents/common.js +21 -0
- package/src/agents/cursor-transcripts.js +177 -0
- package/src/agents/cursor.js +404 -0
- package/src/agents/index.js +69 -0
- package/src/ai-vm-signin.js +110 -0
- package/src/ai-vm.js +522 -0
- package/src/api/access.js +75 -0
- package/src/api/activity.js +181 -0
- package/src/api/agent-auth.js +136 -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 +133 -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 +1219 -0
- package/src/api/org-access.js +54 -0
- package/src/api/permissions.js +71 -0
- package/src/api/relay-cluster.js +545 -0
- package/src/api/relay-machines.js +92 -0
- package/src/api/releases.js +87 -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 +252 -0
- package/src/api/routes/ai-vm.js +495 -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 +179 -0
- package/src/api/routes/relay-cluster.js +90 -0
- package/src/api/routes/relay.js +76 -0
- package/src/api/routes/secrets.js +448 -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 +232 -0
- package/src/api/sandbox-keys.js +57 -0
- package/src/api/secrets-access.js +93 -0
- package/src/api/server.js +654 -0
- package/src/api/slugs.js +20 -0
- package/src/api/supabase-store.js +875 -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 +375 -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 +128 -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 +248 -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 +97 -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 +924 -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 +45 -0
- package/src/mcp-room.js +32 -0
- package/src/mcp.js +1258 -0
- package/src/merge3.js +240 -0
- package/src/merges.js +148 -0
- package/src/pass-source.js +183 -0
- package/src/passes.js +64 -0
- package/src/pathrules.js +134 -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 +214 -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 +166 -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 +1691 -0
- package/src/relay-metrics.js +140 -0
- package/src/relay-outbound.js +1273 -0
- package/src/relay-sync.js +716 -0
- package/src/relay-turn-sync.js +506 -0
- package/src/relay-upstream.js +804 -0
- package/src/relay-webhooks.js +111 -0
- package/src/releases.js +138 -0
- package/src/report.js +129 -0
- package/src/runner.js +307 -0
- package/src/same-edit.js +18 -0
- package/src/secret-import.js +55 -0
- package/src/secret-names.js +26 -0
- package/src/secret-run.js +80 -0
- package/src/secrets-client.js +56 -0
- package/src/server.js +4214 -0
- package/src/session-access.js +170 -0
- package/src/session-name.js +15 -0
- package/src/session.js +7446 -0
- package/src/settings.js +46 -0
- package/src/setup.js +184 -0
- package/src/shell-path.js +46 -0
- package/src/status.js +197 -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 +145 -0
- package/src/turn-sync.js +212 -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 +2494 -0
- package/src/ui/app.js +546 -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 +108 -0
- package/src/ui/cloud-ai.js +221 -0
- package/src/ui/common.js +433 -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 +150 -0
- package/src/ui/git-rule.js +60 -0
- package/src/ui/home.js +1322 -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 +365 -0
- package/src/ui/loom.js +786 -0
- package/src/ui/mark.js +104 -0
- package/src/ui/rail-model.js +75 -0
- package/src/ui/rail.js +173 -0
- package/src/ui/releases.js +182 -0
- package/src/ui/sandbox-keys-model.js +53 -0
- package/src/ui/sandbox-keys.js +132 -0
- package/src/ui/schedule.js +156 -0
- package/src/ui/secret-rules.js +255 -0
- package/src/ui/secrets-view.js +162 -0
- package/src/ui/secrets.js +283 -0
- package/src/ui/session.js +3292 -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 +281 -0
- package/src/ui/workspaces.js +547 -0
- package/src/ui-server.js +1381 -0
- package/src/update-check.js +67 -0
- package/src/upstream.js +192 -0
- package/src/vault/crypto.js +96 -0
- package/src/vault/kms.js +61 -0
- package/src/vault/main.js +33 -0
- package/src/vault/server.js +279 -0
- package/src/vault/sigv4.js +38 -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
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
|
+
}
|