heyquilt 0.0.1 → 0.3.90
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +85 -0
- package/README.md +630 -2
- package/RELEASES.md +733 -0
- package/assets/logo.svg +1 -0
- package/bin/quilt.js +816 -0
- package/desktop/main.js +315 -0
- package/package.json +163 -7
- package/src/account.js +563 -0
- package/src/admit-policy.js +32 -0
- package/src/agent-join.js +370 -0
- package/src/agent-task-workflow.js +176 -0
- package/src/agents/actions.js +85 -0
- package/src/agents/claude-code.js +180 -0
- package/src/agents/common.js +21 -0
- package/src/agents/cursor-transcripts.js +170 -0
- package/src/agents/cursor.js +404 -0
- package/src/agents/index.js +69 -0
- package/src/ai-vm-signin.js +98 -0
- package/src/ai-vm.js +380 -0
- package/src/api/access.js +75 -0
- package/src/api/activity.js +181 -0
- package/src/api/agent-auth.js +130 -0
- package/src/api/agent-placement.js +157 -0
- package/src/api/audit.js +83 -0
- package/src/api/auth.js +17 -0
- package/src/api/domains.js +30 -0
- package/src/api/file-paths.js +40 -0
- package/src/api/file-store.js +99 -0
- package/src/api/fly-machines.js +76 -0
- package/src/api/github-app.js +172 -0
- package/src/api/http.js +34 -0
- package/src/api/invite-email.js +53 -0
- package/src/api/issues.js +110 -0
- package/src/api/join-text.js +78 -0
- package/src/api/mailer.js +47 -0
- package/src/api/memory-store.js +1083 -0
- package/src/api/org-access.js +54 -0
- package/src/api/permissions.js +71 -0
- package/src/api/relay-cluster.js +251 -0
- package/src/api/relay-machines.js +60 -0
- package/src/api/routes/access-types.js +54 -0
- package/src/api/routes/agent-invites.js +115 -0
- package/src/api/routes/agents.js +157 -0
- package/src/api/routes/ai-vm.js +369 -0
- package/src/api/routes/github.js +380 -0
- package/src/api/routes/grants.js +70 -0
- package/src/api/routes/invites.js +188 -0
- package/src/api/routes/issues.js +47 -0
- package/src/api/routes/join.js +167 -0
- package/src/api/routes/members.js +110 -0
- package/src/api/routes/orgs.js +177 -0
- package/src/api/routes/relay-cluster.js +90 -0
- package/src/api/routes/relay.js +76 -0
- package/src/api/routes/session-invites.js +119 -0
- package/src/api/routes/sessions.js +92 -0
- package/src/api/routes/teams.js +132 -0
- package/src/api/routes/workspace-agents.js +332 -0
- package/src/api/routes/workspace-files.js +277 -0
- package/src/api/routes/workspace-invites.js +196 -0
- package/src/api/routes/workspaces.js +225 -0
- package/src/api/server.js +623 -0
- package/src/api/slugs.js +20 -0
- package/src/api/supabase-store.js +765 -0
- package/src/api/team-access.js +30 -0
- package/src/api/tokens.js +31 -0
- package/src/api/workspace-access.js +62 -0
- package/src/api/workspace-reach.js +60 -0
- package/src/autowork-tools.js +166 -0
- package/src/autowork.js +387 -0
- package/src/blobstore.js +99 -0
- package/src/boxes.js +37 -0
- package/src/branchdocs.js +235 -0
- package/src/branches.js +194 -0
- package/src/catchup.js +111 -0
- package/src/chat-archive.js +85 -0
- package/src/chat-links.js +923 -0
- package/src/clash.js +140 -0
- package/src/cloud-ai.js +50 -0
- package/src/commit-request.js +59 -0
- package/src/commit.js +213 -0
- package/src/connection.js +569 -0
- package/src/control.js +245 -0
- package/src/conversation.js +125 -0
- package/src/doctor.js +156 -0
- package/src/duties.js +303 -0
- package/src/editors.js +228 -0
- package/src/fsutil.js +87 -0
- package/src/git-hook.js +48 -0
- package/src/git.js +147 -0
- package/src/github-commit.js +320 -0
- package/src/gitignore.js +49 -0
- package/src/gitstate.js +900 -0
- package/src/history.js +307 -0
- package/src/hooks.js +371 -0
- package/src/hosted-agents.js +24 -0
- package/src/hosted-client.js +112 -0
- package/src/identity.js +99 -0
- package/src/inbox.js +237 -0
- package/src/integrations.js +221 -0
- package/src/key-cli.js +115 -0
- package/src/largefiles.js +91 -0
- package/src/legacy.js +25 -0
- package/src/mcp-proxy.js +40 -0
- package/src/mcp-room.js +32 -0
- package/src/mcp.js +1261 -0
- package/src/merge3.js +158 -0
- package/src/merges.js +148 -0
- package/src/pass-source.js +176 -0
- package/src/passes.js +64 -0
- package/src/pathrules.js +127 -0
- package/src/persona.js +101 -0
- package/src/presence.js +360 -0
- package/src/private-file.js +41 -0
- package/src/procs.js +70 -0
- package/src/project-rules.js +178 -0
- package/src/protocol.js +92 -0
- package/src/quiet-warnings.js +9 -0
- package/src/relay-ai-vm.js +82 -0
- package/src/relay-ask.js +141 -0
- package/src/relay-ci.js +111 -0
- package/src/relay-clash.js +597 -0
- package/src/relay-cluster.js +454 -0
- package/src/relay-commit.js +887 -0
- package/src/relay-filekey.js +46 -0
- package/src/relay-git.js +274 -0
- package/src/relay-githttp.js +137 -0
- package/src/relay-github.js +109 -0
- package/src/relay-lease.js +137 -0
- package/src/relay-lfs.js +37 -0
- package/src/relay-mcp.js +1679 -0
- package/src/relay-metrics.js +75 -0
- package/src/relay-outbound.js +1144 -0
- package/src/relay-sync.js +719 -0
- package/src/relay-turn-sync.js +307 -0
- package/src/relay-upstream.js +934 -0
- package/src/relay-webhooks.js +111 -0
- package/src/releases.js +131 -0
- package/src/report.js +129 -0
- package/src/runner.js +306 -0
- package/src/same-edit.js +18 -0
- package/src/server.js +4187 -0
- package/src/session-access.js +170 -0
- package/src/session-name.js +15 -0
- package/src/session.js +7361 -0
- package/src/settings.js +46 -0
- package/src/setup.js +184 -0
- package/src/shell-path.js +46 -0
- package/src/status.js +199 -0
- package/src/summarize.js +109 -0
- package/src/sync-words.js +42 -0
- package/src/task-comments.js +92 -0
- package/src/tasks.js +670 -0
- package/src/textdiff.js +20 -0
- package/src/turn-sync-plan.js +142 -0
- package/src/turn-sync.js +191 -0
- package/src/ui/access-form.js +37 -0
- package/src/ui/agent-env-key.js +76 -0
- package/src/ui/agent-guide.js +127 -0
- package/src/ui/agent-kinds.js +175 -0
- package/src/ui/agent-place.js +265 -0
- package/src/ui/app.css +2309 -0
- package/src/ui/app.js +542 -0
- package/src/ui/board.js +465 -0
- package/src/ui/boxes.js +76 -0
- package/src/ui/branches.js +111 -0
- package/src/ui/catchup.js +77 -0
- package/src/ui/changes.js +159 -0
- package/src/ui/chat.js +266 -0
- package/src/ui/cloud-ai-model.js +107 -0
- package/src/ui/cloud-ai.js +221 -0
- package/src/ui/common.js +431 -0
- package/src/ui/csv.js +21 -0
- package/src/ui/diffview.js +74 -0
- package/src/ui/feed-convs.js +39 -0
- package/src/ui/feed.js +174 -0
- package/src/ui/files.js +579 -0
- package/src/ui/fileview.js +123 -0
- package/src/ui/git-rule.js +60 -0
- package/src/ui/home.js +1323 -0
- package/src/ui/index.html +16 -0
- package/src/ui/invite.js +97 -0
- package/src/ui/invites.js +79 -0
- package/src/ui/loom-model.js +516 -0
- package/src/ui/loom.css +349 -0
- package/src/ui/loom.js +753 -0
- package/src/ui/mark.js +104 -0
- package/src/ui/merges.js +158 -0
- package/src/ui/rail-model.js +78 -0
- package/src/ui/rail.js +173 -0
- package/src/ui/releases.js +182 -0
- package/src/ui/schedule.js +156 -0
- package/src/ui/session.js +3394 -0
- package/src/ui/signin.js +75 -0
- package/src/ui/sounds.js +110 -0
- package/src/ui/tool-logo.js +86 -0
- package/src/ui/tree.js +276 -0
- package/src/ui/workspaces.js +542 -0
- package/src/ui-server.js +1272 -0
- package/src/update-check.js +67 -0
- package/src/upstream.js +155 -0
- package/src/webhooks.js +235 -0
- package/src/workspace-cli.js +62 -0
- package/src/workspace-tools.js +340 -0
- package/index.js +0 -2
package/src/ui-server.js
ADDED
|
@@ -0,0 +1,1272 @@
|
|
|
1
|
+
// Local web UI: start/join sessions, chat, share files, see collaborators.
|
|
2
|
+
// Listens on 127.0.0.1 only and requires a per-launch token, so web pages you
|
|
3
|
+
// visit can't drive it.
|
|
4
|
+
import http from 'node:http'
|
|
5
|
+
import fs from 'node:fs'
|
|
6
|
+
import os from 'node:os'
|
|
7
|
+
import path from 'node:path'
|
|
8
|
+
import crypto from 'node:crypto'
|
|
9
|
+
import { fileURLToPath } from 'node:url'
|
|
10
|
+
import { createRequire } from 'node:module'
|
|
11
|
+
import { Readable } from 'node:stream'
|
|
12
|
+
import { pipeline } from 'node:stream/promises'
|
|
13
|
+
import { runSession, decodeInvite, newConn, readConfig, recentSessions, forgetRecent, rememberWorkspace, forgetWorkspace } from './runner.js'
|
|
14
|
+
import { MAX_SHARED_FILE_BYTES } from './protocol.js'
|
|
15
|
+
import { getSettings, saveSettings, unsupportedRelay, relayUrl } from './settings.js'
|
|
16
|
+
import { PICKUP_MODES, pickupMode } from './tasks.js'
|
|
17
|
+
import { AGENT_COMMITS } from './github-commit.js'
|
|
18
|
+
import * as gitops from './git.js'
|
|
19
|
+
import { execFile } from 'node:child_process'
|
|
20
|
+
import { installedEditors, openIn, copyToClipboard } from './editors.js'
|
|
21
|
+
import { autowork, autoConfig, publicConfig, AUTO_MODES } from './autowork.js'
|
|
22
|
+
import { migrateDir } from './legacy.js'
|
|
23
|
+
import { writePrivateJson } from './private-file.js'
|
|
24
|
+
import { readAccount, saveAccount, workspaceGithub, setWorkspaceGithub, removeWorkspaceGithub, setOrgGithub, clearAccount, clearAccountIf, resumeAccount, startLink, waitForLink, fetchMe, signOut, revokeToken, accountFromProfile, renameSession, createAgentInvite, listAgents, listAccessTypes, listCollaborators, listGrants, putGrant, deleteGrant, inviteToSession, listSessionInvites, cancelSessionInvite, listWorkspaces, listOrgs, createWorkspace, getWorkspace, updateWorkspace, deleteWorkspace, putWorkspaceMember, removeWorkspaceMember, getAgentPlacement, putAgentPlacement, listOrgAgents, putWorkspaceAgent, deleteWorkspaceAgent, createWorkspaceAgentInvite, excludeSessionAgent, listExcludedSessionAgents, listSessionAgents, includeSessionAgent, setSessionWorkspace, moveSessionToWorkspace, listWorkspaceInvites, inviteToWorkspace, cancelWorkspaceInvite, listMyInvites, answerMyInvite, announceSessionStarted, announceWhenReported, listWorkspaceFiles, createWorkspaceFile, confirmWorkspaceFile, workspaceFileDownload, updateWorkspaceFile, deleteWorkspaceFile, createWorkspaceFolder, listWorkspaceFileVersions, githubStatus, githubConnectUrl, aiVmFollow, aiVmUnfollow, aiVmStatus, aiVmConnect, aiVmUpdate, aiVmSignIn, aiVmWake, aiVmDisconnect } from './account.js'
|
|
25
|
+
import { personaName } from './persona.js'
|
|
26
|
+
import { CLOUD_LABEL } from './cloud-ai.js'
|
|
27
|
+
import { effectiveAccess, builtinType } from './session-access.js'
|
|
28
|
+
import { cleanSessionName, BAD_SESSION_NAME, SESSION_NAME_MAX } from './session-name.js'
|
|
29
|
+
import { personPasses } from './pass-source.js'
|
|
30
|
+
import { INVALID_INVITE } from './ui/invite.js'
|
|
31
|
+
import { loadIdentity } from './identity.js'
|
|
32
|
+
import { currentVersion, localReleases, latestRelease, compareVersions, downloadUrl, seenVersion, markSeen, hiddenUpdate, hideUpdate } from './releases.js'
|
|
33
|
+
import { createReporter } from './report.js'
|
|
34
|
+
import { queryHistory } from './history.js'
|
|
35
|
+
import { contentDisposition } from './api/file-paths.js'
|
|
36
|
+
|
|
37
|
+
const TOOL_NAMES = ['Claude Code', 'Cursor', 'Codex', 'xAI', 'Windsurf', 'GitHub Copilot', 'Zed', 'Aider', 'Other']
|
|
38
|
+
const MAX_WS_FILE_BYTES = 500 * 1024 * 1024
|
|
39
|
+
const COLOR_RE = /^#[0-9a-f]{6}$/i
|
|
40
|
+
const THEMES = ['light', 'dark', 'system']
|
|
41
|
+
const SAVE_FAILED = "Quilt couldn't save your sign-in on this computer."
|
|
42
|
+
const SIGNED_OUT_MESSAGE = 'This computer was signed out. Sign in again.'
|
|
43
|
+
const LOCAL_RELAY_GONE = "This session ran on your computer's own relay, which Quilt no longer supports. Your files are untouched."
|
|
44
|
+
// Until this computer is signed in, only these answer.
|
|
45
|
+
const OPEN_ROUTES = new Set(['GET /api/account', 'POST /api/account/start', 'POST /api/account/cancel', 'POST /api/account/signout', 'GET /api/events', 'POST /api/shutdown', 'POST /api/report'])
|
|
46
|
+
|
|
47
|
+
// The sound switches in Settings, one per event (SOUND_EVENTS in src/ui/sounds.js).
|
|
48
|
+
const SOUND_SETTINGS = ['soundMentions', 'soundLetIn', 'soundTasks']
|
|
49
|
+
|
|
50
|
+
/** Your profile and preferences, from ~/.quilt/settings.json with sensible defaults. */
|
|
51
|
+
function profile () {
|
|
52
|
+
const s = getSettings()
|
|
53
|
+
const account = readAccount()
|
|
54
|
+
return {
|
|
55
|
+
name: account ? account.account.name : os.userInfo().username,
|
|
56
|
+
tool: s.tool || detectTool(),
|
|
57
|
+
color: s.color || null,
|
|
58
|
+
joinDir: s.joinDir || '~/quilt',
|
|
59
|
+
shareAgent: s.shareAgent !== false,
|
|
60
|
+
summarize: !!s.summarize,
|
|
61
|
+
preferLocal: !!s.preferLocal,
|
|
62
|
+
// Quilt pushes your local commits for you on branches that sync with GitHub: off unless you turn it on.
|
|
63
|
+
pushLocalCommits: s.pushLocalCommits === true,
|
|
64
|
+
theme: THEMES.includes(s.theme) ? s.theme : 'dark', // dark unless you pick another
|
|
65
|
+
report: s.report !== false,
|
|
66
|
+
aiTasks: pickupMode(s.aiTasks),
|
|
67
|
+
// What agents may commit in sessions you start ('off', 'branches', 'any'); each session's owner can change it there.
|
|
68
|
+
agentCommits: AGENT_COMMITS.includes(s.agentCommits) ? s.agentCommits : 'branches',
|
|
69
|
+
// When work comes to your AI (autowork.js): 'chat', 'open' or 'auto', with its model, files and daily cap.
|
|
70
|
+
auto: publicConfig(autoConfig(s)),
|
|
71
|
+
// Sounds (src/ui/sounds.js): each on unless switched off.
|
|
72
|
+
...Object.fromEntries(SOUND_SETTINGS.map((k) => [k, s[k] !== false]))
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Checks and saves profile/preference changes. Returns the new profile. */
|
|
77
|
+
function updateProfile (b) {
|
|
78
|
+
const patch = {}
|
|
79
|
+
// Your name is your account's: it's changed on the website.
|
|
80
|
+
if ('name' in b) throw httpError(400, 'Change your name on heyquilt.com.')
|
|
81
|
+
if ('tool' in b) {
|
|
82
|
+
if (!TOOL_NAMES.includes(b.tool)) throw httpError(400, 'Pick an AI tool from the list.')
|
|
83
|
+
patch.tool = b.tool
|
|
84
|
+
}
|
|
85
|
+
if ('color' in b) {
|
|
86
|
+
if (b.color && !COLOR_RE.test(b.color)) throw httpError(400, 'That color isn\'t valid.')
|
|
87
|
+
patch.color = b.color || undefined
|
|
88
|
+
}
|
|
89
|
+
if ('joinDir' in b) patch.joinDir = String(b.joinDir || '').trim() || undefined
|
|
90
|
+
if ('shareAgent' in b) patch.shareAgent = b.shareAgent ? undefined : false
|
|
91
|
+
if ('summarize' in b) patch.summarize = b.summarize ? true : undefined
|
|
92
|
+
if ('preferLocal' in b) patch.preferLocal = b.preferLocal ? true : undefined
|
|
93
|
+
if ('pushLocalCommits' in b) patch.pushLocalCommits = b.pushLocalCommits ? true : undefined
|
|
94
|
+
if ('theme' in b) {
|
|
95
|
+
if (!THEMES.includes(b.theme)) throw httpError(400, 'Pick Light, Dark or System.')
|
|
96
|
+
patch.theme = b.theme === 'dark' ? undefined : b.theme
|
|
97
|
+
}
|
|
98
|
+
if ('report' in b) patch.report = b.report ? undefined : false
|
|
99
|
+
if ('aiTasks' in b) {
|
|
100
|
+
if (!PICKUP_MODES.includes(b.aiTasks)) throw httpError(400, 'Pick Off, Assigned to it, or Assigned and unassigned.')
|
|
101
|
+
patch.aiTasks = b.aiTasks === 'off' ? undefined : b.aiTasks
|
|
102
|
+
}
|
|
103
|
+
if ('agentCommits' in b) {
|
|
104
|
+
if (!AGENT_COMMITS.includes(b.agentCommits)) throw httpError(400, 'Pick Not at all, To branches of their own, or To any branch.')
|
|
105
|
+
patch.agentCommits = b.agentCommits === 'branches' ? undefined : b.agentCommits
|
|
106
|
+
}
|
|
107
|
+
if ('autoWork' in b) {
|
|
108
|
+
if (!AUTO_MODES.includes(b.autoWork)) throw httpError(400, 'Pick Leave it for my chat, Open it in my tool, or Work on its own.')
|
|
109
|
+
patch.autoWork = b.autoWork === 'chat' ? undefined : b.autoWork
|
|
110
|
+
}
|
|
111
|
+
if ('autoModel' in b) {
|
|
112
|
+
const m = String(b.autoModel || '').trim()
|
|
113
|
+
if (m && !/^[\w.:/-]{1,60}$/.test(m)) throw httpError(400, 'That model name isn\'t valid.')
|
|
114
|
+
patch.autoModel = m || undefined
|
|
115
|
+
}
|
|
116
|
+
if ('autoFiles' in b) patch.autoFiles = b.autoFiles ? true : undefined
|
|
117
|
+
if ('autoCap' in b) {
|
|
118
|
+
const n = b.autoCap === '' || b.autoCap === null ? 0 : Number(b.autoCap)
|
|
119
|
+
if (!Number.isInteger(n) || n < 0 || n > 1e9) throw httpError(400, 'The daily limit is a whole number of tokens (0 for none).')
|
|
120
|
+
patch.autoCap = n || undefined
|
|
121
|
+
}
|
|
122
|
+
for (const k of SOUND_SETTINGS) if (k in b) patch[k] = b[k] ? undefined : false
|
|
123
|
+
saveSettings(patch) // undefined values clear a setting
|
|
124
|
+
return profile()
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const UI_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), 'ui')
|
|
128
|
+
const LOGO = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'assets', 'logo.svg')
|
|
129
|
+
// Fonts are bundled from npm so the app works offline and never calls a font CDN.
|
|
130
|
+
const FONT_PACKAGES = ['poppins', 'ibm-plex-mono']
|
|
131
|
+
const require = createRequire(import.meta.url)
|
|
132
|
+
const fontFile = (pkg, file) => {
|
|
133
|
+
if (!FONT_PACKAGES.includes(pkg) || !/^[a-z0-9-]+\.woff2$/.test(file)) return null
|
|
134
|
+
const f = path.join(path.dirname(require.resolve(`@fontsource/${pkg}/package.json`)), 'files', file)
|
|
135
|
+
return fs.existsSync(f) ? f : null
|
|
136
|
+
}
|
|
137
|
+
// Every module src/ui/*.js imports must be listed here, or the page fails to load (test/ui-static-allowlist.test.js).
|
|
138
|
+
export const STATIC = {
|
|
139
|
+
'/': ['index.html', 'text/html; charset=utf-8'],
|
|
140
|
+
'/app.js': ['app.js', 'text/javascript; charset=utf-8'],
|
|
141
|
+
'/app.css': ['app.css', 'text/css; charset=utf-8'],
|
|
142
|
+
'/common.js': ['common.js', 'text/javascript; charset=utf-8'],
|
|
143
|
+
'/mark.js': ['mark.js', 'text/javascript; charset=utf-8'],
|
|
144
|
+
'/invite.js': ['invite.js', 'text/javascript; charset=utf-8'],
|
|
145
|
+
'/agent-guide.js': ['agent-guide.js', 'text/javascript; charset=utf-8'],
|
|
146
|
+
'/access-form.js': ['access-form.js', 'text/javascript; charset=utf-8'],
|
|
147
|
+
'/session.js': ['session.js', 'text/javascript; charset=utf-8'],
|
|
148
|
+
'/chat.js': ['chat.js', 'text/javascript; charset=utf-8'],
|
|
149
|
+
'/feed.js': ['feed.js', 'text/javascript; charset=utf-8'],
|
|
150
|
+
'/tool-logo.js': ['tool-logo.js', 'text/javascript; charset=utf-8'],
|
|
151
|
+
'/tree.js': ['tree.js', 'text/javascript; charset=utf-8'],
|
|
152
|
+
'/fileview.js': ['fileview.js', 'text/javascript; charset=utf-8'],
|
|
153
|
+
'/merges.js': ['merges.js', 'text/javascript; charset=utf-8'],
|
|
154
|
+
'/catchup.js': ['catchup.js', 'text/javascript; charset=utf-8'],
|
|
155
|
+
'/home.js': ['home.js', 'text/javascript; charset=utf-8'],
|
|
156
|
+
'/signin.js': ['signin.js', 'text/javascript; charset=utf-8'],
|
|
157
|
+
'/changes.js': ['changes.js', 'text/javascript; charset=utf-8'],
|
|
158
|
+
'/diffview.js': ['diffview.js', 'text/javascript; charset=utf-8'],
|
|
159
|
+
'/boxes.js': ['boxes.js', 'text/javascript; charset=utf-8'],
|
|
160
|
+
'/releases.js': ['releases.js', 'text/javascript; charset=utf-8'],
|
|
161
|
+
'/feed-convs.js': ['feed-convs.js', 'text/javascript; charset=utf-8'],
|
|
162
|
+
'/loom.js': ['loom.js', 'text/javascript; charset=utf-8'],
|
|
163
|
+
'/loom-model.js': ['loom-model.js', 'text/javascript; charset=utf-8'],
|
|
164
|
+
'/rail.js': ['rail.js', 'text/javascript; charset=utf-8'],
|
|
165
|
+
'/rail-model.js': ['rail-model.js', 'text/javascript; charset=utf-8'],
|
|
166
|
+
'/loom.css': ['loom.css', 'text/css; charset=utf-8'],
|
|
167
|
+
'/board.js': ['board.js', 'text/javascript; charset=utf-8'],
|
|
168
|
+
'/schedule.js': ['schedule.js', 'text/javascript; charset=utf-8'],
|
|
169
|
+
'/branches.js': ['branches.js', 'text/javascript; charset=utf-8'],
|
|
170
|
+
'/workspaces.js': ['workspaces.js', 'text/javascript; charset=utf-8'],
|
|
171
|
+
'/invites.js': ['invites.js', 'text/javascript; charset=utf-8'],
|
|
172
|
+
'/agent-place.js': ['agent-place.js', 'text/javascript; charset=utf-8'],
|
|
173
|
+
'/agent-kinds.js': ['agent-kinds.js', 'text/javascript; charset=utf-8'],
|
|
174
|
+
'/files.js': ['files.js', 'text/javascript; charset=utf-8'],
|
|
175
|
+
'/csv.js': ['csv.js', 'text/javascript; charset=utf-8'],
|
|
176
|
+
'/sounds.js': ['sounds.js', 'text/javascript; charset=utf-8'],
|
|
177
|
+
'/git-rule.js': ['git-rule.js', 'text/javascript; charset=utf-8'],
|
|
178
|
+
'/cloud-ai.js': ['cloud-ai.js', 'text/javascript; charset=utf-8'],
|
|
179
|
+
'/cloud-ai-model.js': ['cloud-ai-model.js', 'text/javascript; charset=utf-8']
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// The page's Content-Security-Policy: scripts only from our own files (no inline script or
|
|
183
|
+
// event handlers, the second line of defence against injected markup), fonts and the event
|
|
184
|
+
// stream from this server, inline style attributes allowed since the UI sets them, and
|
|
185
|
+
// Google's favicon service (plus its gstatic.com redirect hosts) for provider logos,
|
|
186
|
+
// and https images so ticket notes can show linked screenshots.
|
|
187
|
+
const CSP = [
|
|
188
|
+
"default-src 'self'",
|
|
189
|
+
"script-src 'self'",
|
|
190
|
+
"style-src 'self' 'unsafe-inline'",
|
|
191
|
+
"img-src 'self' data: https: https://www.google.com https://*.gstatic.com",
|
|
192
|
+
"font-src 'self'",
|
|
193
|
+
"connect-src 'self'",
|
|
194
|
+
"object-src 'none'",
|
|
195
|
+
"base-uri 'none'",
|
|
196
|
+
"form-action 'self'",
|
|
197
|
+
"frame-ancestors 'none'"
|
|
198
|
+
].join('; ')
|
|
199
|
+
|
|
200
|
+
// preview: for development only (`quilt ui --preview`). Opening the bare address hands out the
|
|
201
|
+
// link, so a dev preview pane can show the app. Any local page could then open it too.
|
|
202
|
+
export async function startUi ({ port = 7420, onShutdown, preview = false, reporter, slowMs = 3000, keepOutMs = 3000 } = {}) {
|
|
203
|
+
// What this app tells Quilt about itself (see report.js). Off with the "report" setting.
|
|
204
|
+
reporter = reporter || createReporter({ token: () => readAccount()?.token || null, enabled: () => getSettings().report !== false })
|
|
205
|
+
// Body fields worth keeping with a route's outcome: which editor, which kind of start. Never free text.
|
|
206
|
+
const CONTEXT_FIELDS = { 'POST /api/sessions/:id/open-in': ['app'], 'POST /api/sessions': ['mode', 'tool', 'prefer'] }
|
|
207
|
+
const contextFor = (key, body) => Object.fromEntries((CONTEXT_FIELDS[key] || []).filter((f) => typeof body?.[f] === 'string').map((f) => [f, body[f].slice(0, 40)]))
|
|
208
|
+
// Not recorded: reports about reports. (The event stream never reaches the dispatch block
|
|
209
|
+
// below — it returns earlier — so it needs no entry here.)
|
|
210
|
+
const UNRECORDED = new Set(['POST /api/report'])
|
|
211
|
+
function recordRoute (key, { startedAt, status, body, error }) {
|
|
212
|
+
// The reporter's own `enabled()` is the single gate on the "report" setting.
|
|
213
|
+
if (UNRECORDED.has(key)) return
|
|
214
|
+
const durationMs = Date.now() - startedAt
|
|
215
|
+
const outcome = error ? 'error' : durationMs > slowMs ? 'slow' : 'ok'
|
|
216
|
+
// A successful read (GET) isn't worth a row: the renderer polls and refreshes
|
|
217
|
+
// constantly, and an `ok` there would just be a usage log nobody was told about.
|
|
218
|
+
// Every non-GET outcome, and every slow or failed GET, is still kept.
|
|
219
|
+
if (outcome === 'ok' && key.startsWith('GET ')) return
|
|
220
|
+
reporter.record({ kind: 'action', name: key, outcome, status, durationMs, message: error ? error.message : '', context: contextFor(key, body) })
|
|
221
|
+
}
|
|
222
|
+
const token = crypto.randomBytes(18).toString('base64url')
|
|
223
|
+
const runs = new Map() // id -> { run, logs: [] }
|
|
224
|
+
const clients = new Set() // SSE responses
|
|
225
|
+
let passes = null // this computer's passes, shared by all its sessions
|
|
226
|
+
let link = null // signing in: what startLink returned, plus { state, error }
|
|
227
|
+
let signedOutReason = null // 'revoked' once the API turned this computer's token away
|
|
228
|
+
let checkedToken = false // asked the API about the saved token since the app started
|
|
229
|
+
let resumeTried = false // tried signing back in with this computer's key since the app started
|
|
230
|
+
let workspacesOn = false // cached result of the last GET /api/workspaces probe
|
|
231
|
+
|
|
232
|
+
const accountPasses = () => {
|
|
233
|
+
if (passes) return passes
|
|
234
|
+
const account = readAccount()
|
|
235
|
+
if (!account) throw Object.assign(httpError(401, 'Sign in to Quilt first.'), { signedOut: true })
|
|
236
|
+
passes = personPasses({ token: account.token })
|
|
237
|
+
return passes
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Forgets this computer's sign-in and stops its sessions. 'revoked': this computer isn't
|
|
242
|
+
* linked any more. `token`: the one turned away; a newer sign-in saved since is kept.
|
|
243
|
+
*/
|
|
244
|
+
async function signedOut (reason, token) {
|
|
245
|
+
// Forget the sign-in first, so a start can't pick it up again while sessions stop.
|
|
246
|
+
passes = null
|
|
247
|
+
if (token) clearAccountIf(token)
|
|
248
|
+
else clearAccount()
|
|
249
|
+
for (const id of [...runs.keys()]) await stop(id)
|
|
250
|
+
signedOutReason = reason
|
|
251
|
+
broadcast('signed-out', { reason })
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* This computer's token was turned away (or it has none): sign back in with its key. The
|
|
256
|
+
* new sign-in, or null once the API says this computer isn't linked. Offline, the saved
|
|
257
|
+
* sign-in stands (`fallback`).
|
|
258
|
+
*/
|
|
259
|
+
async function resume (fallback = null) {
|
|
260
|
+
try {
|
|
261
|
+
const back = await resumeAccount()
|
|
262
|
+
if (back) { passes = null; signedOutReason = null }
|
|
263
|
+
return back
|
|
264
|
+
} catch {
|
|
265
|
+
return fallback
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async function accountState () {
|
|
270
|
+
let account = readAccount()
|
|
271
|
+
// No sign-in saved (lost, or another app's sign-out): a linked computer just carries on.
|
|
272
|
+
if (!account && !resumeTried) {
|
|
273
|
+
resumeTried = true
|
|
274
|
+
account = await resume()
|
|
275
|
+
if (account) checkedToken = true
|
|
276
|
+
}
|
|
277
|
+
if (account && !checkedToken) {
|
|
278
|
+
checkedToken = true
|
|
279
|
+
try {
|
|
280
|
+
// Picks up a name changed on heyquilt.com, and notices a computer unlinked there.
|
|
281
|
+
const fresh = { ...account, account: accountFromProfile(await fetchMe({ token: account.token })) }
|
|
282
|
+
saveAccount(fresh)
|
|
283
|
+
account = fresh
|
|
284
|
+
} catch (err) {
|
|
285
|
+
if (err.status === 401) {
|
|
286
|
+
const back = await resume(account)
|
|
287
|
+
if (!back) { await signedOut('revoked', account.token); account = null } else account = back
|
|
288
|
+
}
|
|
289
|
+
// Anything else (offline): keep the saved sign-in.
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return {
|
|
293
|
+
signedIn: !!account,
|
|
294
|
+
account: account ? account.account : null,
|
|
295
|
+
reason: account ? null : signedOutReason,
|
|
296
|
+
link: link ? { state: link.state, userCode: link.userCode, verificationUrl: link.verificationUrl, error: link.error } : null
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Starts linking this computer; the website approves it, and we collect the token in the background. */
|
|
301
|
+
async function beginLink () {
|
|
302
|
+
if (readAccount()) throw httpError(409, 'Already signed in.')
|
|
303
|
+
// A computer linked before signs straight back in: no browser, no approving it again.
|
|
304
|
+
try {
|
|
305
|
+
if (await resumeAccount({ asked: true })) {
|
|
306
|
+
link = null
|
|
307
|
+
passes = null
|
|
308
|
+
signedOutReason = null
|
|
309
|
+
checkedToken = true
|
|
310
|
+
broadcast('signed-in', {})
|
|
311
|
+
return accountState()
|
|
312
|
+
}
|
|
313
|
+
} catch {}
|
|
314
|
+
const identity = loadIdentity()
|
|
315
|
+
const mine = { ...await startLink({ identity }), state: 'waiting', error: null }
|
|
316
|
+
link = mine
|
|
317
|
+
waitForLink({ identity, link: mine, stopped: () => link !== mine }).then((r) => {
|
|
318
|
+
// Approved after it was cancelled or replaced: don't leave that token live on the server.
|
|
319
|
+
if (link !== mine) return revokeToken({ token: r.token })
|
|
320
|
+
// Not saved (or an odd reply), so this computer isn't signed in: don't leave the token live either.
|
|
321
|
+
const failed = (message) => {
|
|
322
|
+
revokeToken({ token: r.token })
|
|
323
|
+
mine.state = 'failed'
|
|
324
|
+
mine.error = message
|
|
325
|
+
}
|
|
326
|
+
let account
|
|
327
|
+
try { account = accountFromProfile(r.profile) } catch (err) { return failed(err.message) }
|
|
328
|
+
try {
|
|
329
|
+
saveAccount({ token: r.token, account, signedInAt: Date.now() })
|
|
330
|
+
} catch {
|
|
331
|
+
return failed(SAVE_FAILED)
|
|
332
|
+
}
|
|
333
|
+
link = null
|
|
334
|
+
passes = null
|
|
335
|
+
signedOutReason = null
|
|
336
|
+
checkedToken = true
|
|
337
|
+
broadcast('signed-in', {})
|
|
338
|
+
}, (err) => {
|
|
339
|
+
if (link !== mine) return
|
|
340
|
+
mine.state = err.expired ? 'expired' : err.denied ? 'denied' : 'failed'
|
|
341
|
+
mine.error = err.message
|
|
342
|
+
})
|
|
343
|
+
return accountState()
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const idFor = (dir) => crypto.createHash('sha1').update(path.resolve(dir)).digest('hex').slice(0, 10)
|
|
347
|
+
// Recent sessions not open now. Ones that ran on another relay are marked: they can't reopen.
|
|
348
|
+
const recentList = () => recentSessions().filter((r) => !runs.has(idFor(r.dir))).map((r) => ({ ...r, unsupported: unsupportedRelay(r.server) }))
|
|
349
|
+
const broadcast = (type, data) => {
|
|
350
|
+
const frame = `event: ${type}\ndata: ${JSON.stringify(data)}\n\n`
|
|
351
|
+
for (const res of clients) res.write(frame)
|
|
352
|
+
}
|
|
353
|
+
const summary = (id) => {
|
|
354
|
+
const r = runs.get(id)
|
|
355
|
+
return { id, dir: r.run.dir, invite: r.run.invite, viewInvite: r.run.viewInvite, status: r.run.session.status(), logs: r.logs.slice(-80), workspace: readConfig(r.run.dir)?.workspace || '' }
|
|
356
|
+
}
|
|
357
|
+
const pushStatus = (id) => runs.has(id) && broadcast('session', summary(id))
|
|
358
|
+
|
|
359
|
+
async function start ({ mode, dir, tool, invite, prefer, repo, branch, newBranch, base, workspace }) {
|
|
360
|
+
const me = profile()
|
|
361
|
+
// Every session signs in to the relay as this computer's account.
|
|
362
|
+
const sessionPasses = accountPasses()
|
|
363
|
+
// Signing out (or in again) replaces `passes`, so a start that outlives the sign-in it began under can tell.
|
|
364
|
+
const current = () => passes === sessionPasses
|
|
365
|
+
const outlived = () => readAccount()
|
|
366
|
+
? httpError(409, 'Your sign-in changed while this session was starting. Start it again.')
|
|
367
|
+
: Object.assign(httpError(401, 'Sign in to Quilt first.'), { signedOut: true })
|
|
368
|
+
if (mode === 'github') {
|
|
369
|
+
// Clone first, then start a normal session on the clone.
|
|
370
|
+
const repoName = String(repo || '').split('/').pop()
|
|
371
|
+
dir = path.resolve(expandHome(dir || underJoinDir(me.joinDir, repoName || 'repo', 'That repository name is not valid.')))
|
|
372
|
+
if (runs.has(idFor(dir))) throw httpError(400, 'A session is already running in that folder.')
|
|
373
|
+
await gitops.cloneRepo({ repo, dir, branch, newBranch, base })
|
|
374
|
+
if (!current()) throw outlived()
|
|
375
|
+
mode = 'create'
|
|
376
|
+
}
|
|
377
|
+
tool = tool || me.tool
|
|
378
|
+
prefer = prefer || (me.preferLocal ? 'local' : 'remote')
|
|
379
|
+
if (mode === 'join' && !dir) {
|
|
380
|
+
const inv = decodeInvite(invite || '')
|
|
381
|
+
dir = underJoinDir(me.joinDir, inv.room)
|
|
382
|
+
}
|
|
383
|
+
if (!dir) throw new Error('Choose a project folder.')
|
|
384
|
+
dir = path.resolve(expandHome(dir))
|
|
385
|
+
const id = idFor(dir)
|
|
386
|
+
if (runs.has(id)) return summary(id)
|
|
387
|
+
|
|
388
|
+
let conn
|
|
389
|
+
let inviteServer
|
|
390
|
+
if (mode === 'join') {
|
|
391
|
+
conn = decodeInvite(invite || '')
|
|
392
|
+
} else if (mode === 'rejoin') {
|
|
393
|
+
const saved = readConfig(dir)
|
|
394
|
+
if (!saved) throw new Error('No previous session in that folder.')
|
|
395
|
+
if (unsupportedRelay(saved.server)) throw httpError(400, LOCAL_RELAY_GONE)
|
|
396
|
+
conn = { server: saved.server, room: saved.room, secret: saved.secret, ...(saved.viewSecret ? { viewSecret: saved.viewSecret } : {}) }
|
|
397
|
+
inviteServer = unsupportedRelay(saved.inviteServer) ? undefined : saved.inviteServer
|
|
398
|
+
tool = tool || saved.tool
|
|
399
|
+
} else {
|
|
400
|
+
conn = newConn()
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const entry = { logs: [] }
|
|
404
|
+
const log = (line) => {
|
|
405
|
+
entry.logs.push({ ts: Date.now(), line })
|
|
406
|
+
if (entry.logs.length > 200) entry.logs.shift()
|
|
407
|
+
broadcast('log', { id, ts: Date.now(), line })
|
|
408
|
+
}
|
|
409
|
+
entry.log = log
|
|
410
|
+
try {
|
|
411
|
+
entry.run = await runSession({
|
|
412
|
+
dir,
|
|
413
|
+
conn,
|
|
414
|
+
// The account's name until the first pass names us (the same name, from the API).
|
|
415
|
+
name: me.name,
|
|
416
|
+
tool,
|
|
417
|
+
color: me.color,
|
|
418
|
+
shareByDefault: me.shareAgent,
|
|
419
|
+
summarizeByDefault: me.summarize,
|
|
420
|
+
joined: mode === 'join',
|
|
421
|
+
prefer: prefer === 'local' ? 'local' : 'remote',
|
|
422
|
+
inviteServer,
|
|
423
|
+
passes: sessionPasses,
|
|
424
|
+
// A new session is named after its folder (the owner can rename it later).
|
|
425
|
+
startName: mode === 'create' ? cleanSessionName([...path.basename(dir)].slice(0, SESSION_NAME_MAX).join('')) || '' : '',
|
|
426
|
+
// Your default for what agents may commit, set on the new room once the relay makes you its owner.
|
|
427
|
+
startAgentCommits: mode === 'create' ? me.agentCommits : '',
|
|
428
|
+
workspace: workspace || '',
|
|
429
|
+
onLog: log,
|
|
430
|
+
onFatal: async (err) => {
|
|
431
|
+
log(`stopped: ${err.message}`)
|
|
432
|
+
await stop(id)
|
|
433
|
+
// Only this sign-in's passes may sign it out: a stale session's 401 is about a token already gone.
|
|
434
|
+
if (err.signedOut && current()) await signedOut('revoked')
|
|
435
|
+
}
|
|
436
|
+
})
|
|
437
|
+
} catch (err) {
|
|
438
|
+
if (!current()) throw outlived()
|
|
439
|
+
if (err.signedOut) {
|
|
440
|
+
await signedOut('revoked')
|
|
441
|
+
throw Object.assign(httpError(401, err.message), { signedOut: true })
|
|
442
|
+
}
|
|
443
|
+
throw err
|
|
444
|
+
}
|
|
445
|
+
if (!current()) {
|
|
446
|
+
// Signed out while it was starting: it must not keep running on the old sign-in.
|
|
447
|
+
await entry.run.stop().catch(() => {})
|
|
448
|
+
throw outlived()
|
|
449
|
+
}
|
|
450
|
+
runs.set(id, entry)
|
|
451
|
+
const s = entry.run.session
|
|
452
|
+
s.on('status-changed', () => pushStatus(id))
|
|
453
|
+
s.on('access', () => pushStatus(id))
|
|
454
|
+
s.on('message', (m) => broadcast('message', { id, message: m }))
|
|
455
|
+
s.on('agent-feed', (entries) => broadcast('feed', { id, entries }))
|
|
456
|
+
s.on('file-changed', (e) => broadcast('file-changed', { id, ...e }))
|
|
457
|
+
// Presence changes (e.g. focus, recently edited files) also refresh the view.
|
|
458
|
+
s.conn.awareness.on('change', () => pushStatus(id))
|
|
459
|
+
// The person's cloud AI, when they connected one, joins the sessions they join.
|
|
460
|
+
followWithCloudAi(entry.run.invite, log)
|
|
461
|
+
if (workspace && mode === 'create') {
|
|
462
|
+
// Put the new room in its workspace before anyone else connects. A failure is logged, never
|
|
463
|
+
// fatal, and the session is outside any workspace here too, as it is on the API.
|
|
464
|
+
// mode was reassigned to 'create' for github clones above, so this covers both.
|
|
465
|
+
const linked = await asAccount((token) => setSessionWorkspace({ token, id: workspace, room: conn.room })).then(() => true, (err) => {
|
|
466
|
+
log(`could not add this session to its workspace: ${err.message}`)
|
|
467
|
+
forgetWorkspace(entry.run.dir)
|
|
468
|
+
return false
|
|
469
|
+
})
|
|
470
|
+
// Then hand its link to the agents that join it by themselves, in the background: the
|
|
471
|
+
// session never waits for this, and a failure is only logged.
|
|
472
|
+
if (linked) {
|
|
473
|
+
const link = entry.run.invite
|
|
474
|
+
announceWhenReported(() => asAccount((token) => announceSessionStarted({ token, id: workspace, room: conn.room, link })), {
|
|
475
|
+
alive: () => runs.get(id) === entry && current(),
|
|
476
|
+
log
|
|
477
|
+
}).catch(() => {})
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
return summary(id)
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
async function stop (id) {
|
|
484
|
+
const r = runs.get(id)
|
|
485
|
+
if (!r) return
|
|
486
|
+
runs.delete(id)
|
|
487
|
+
await r.run.stop()
|
|
488
|
+
broadcast('stopped', { id })
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const get = (id) => {
|
|
492
|
+
const r = runs.get(id)
|
|
493
|
+
if (!r) throw httpError(404, 'That session is not running.')
|
|
494
|
+
return r.run.session
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* The owner renames a session: on the relay, so everyone in it sees the new name, then on
|
|
499
|
+
* heyquilt.com. Until the relay has reported a new session there (within a minute) the
|
|
500
|
+
* website answers 404, and the relay's own report carries the new name instead.
|
|
501
|
+
*/
|
|
502
|
+
async function rename (id, raw) {
|
|
503
|
+
const s = get(id)
|
|
504
|
+
if (!s.isOwner) throw httpError(403, 'Only the session owner can rename it.')
|
|
505
|
+
const name = cleanSessionName(raw)
|
|
506
|
+
if (!name) throw httpError(400, BAD_SESSION_NAME)
|
|
507
|
+
await s.rename(name)
|
|
508
|
+
try {
|
|
509
|
+
await renameSession({ token: readAccount()?.token, room: s.room, name })
|
|
510
|
+
} catch (err) {
|
|
511
|
+
if (err.status !== 404) throw httpError(502, `Renamed here, but heyquilt.com didn't take it: ${err.message}`)
|
|
512
|
+
}
|
|
513
|
+
return { name }
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Whether this person has a cloud AI (routes/ai-vm.js on the API), as the API last said: work for
|
|
517
|
+
// their AI is then its, not this computer's (autowork.js). Learnt from each hand-over below.
|
|
518
|
+
let hasCloudAi = false
|
|
519
|
+
autowork().cloudAi = () => hasCloudAi
|
|
520
|
+
/** Hands a session's link to the person's cloud AI in the background; a failure is only logged. */
|
|
521
|
+
const followWithCloudAi = (link, log = () => {}) => {
|
|
522
|
+
if (!readAccount()) return
|
|
523
|
+
asAccount((token) => aiVmFollow({ token, link })).then((has) => {
|
|
524
|
+
hasCloudAi = has
|
|
525
|
+
if (has) log('☁️ your cloud AI is joining this session too')
|
|
526
|
+
}, (err) => log(`could not tell your cloud AI about this session: ${err.message}`))
|
|
527
|
+
}
|
|
528
|
+
// Settings → Your AI: the person's cloud AI, as this computer's account. `vm` is null without one.
|
|
529
|
+
const CLOUD_TOOLS = ['Claude Code', 'Codex', 'Cursor']
|
|
530
|
+
const cloudView = (vm) => {
|
|
531
|
+
hasCloudAi = !!vm
|
|
532
|
+
if (!vm) return { vm: null }
|
|
533
|
+
const person = readAccount()?.account?.name || profile().name || 'You'
|
|
534
|
+
return { vm: { ...vm, name: personaName(person, CLOUD_LABEL) } }
|
|
535
|
+
}
|
|
536
|
+
const cloudNone = (err) => { if (err.status === 404) return cloudView(null); throw err }
|
|
537
|
+
/** What Settings may change: the tool, model, instructions and daily cap (blank is no cap). */
|
|
538
|
+
const cloudPatch = (b) => {
|
|
539
|
+
const out = {}
|
|
540
|
+
if (b.tool !== undefined) out.tool = String(b.tool)
|
|
541
|
+
if (b.model !== undefined) out.model = String(b.model).trim()
|
|
542
|
+
if (b.prompt !== undefined) out.prompt = String(b.prompt)
|
|
543
|
+
if (b.cap !== undefined) {
|
|
544
|
+
const cap = String(b.cap).trim() === '' ? 0 : Number(b.cap)
|
|
545
|
+
if (!Number.isInteger(cap) || cap < 0) throw httpError(400, 'The daily cap is a whole number of tokens, or blank for none.')
|
|
546
|
+
out.cap = cap
|
|
547
|
+
}
|
|
548
|
+
return out
|
|
549
|
+
}
|
|
550
|
+
const cloudRoutes = {
|
|
551
|
+
'GET /api/cloud-ai': (b, id, url) => asAccount(async (token) => cloudView(await aiVmStatus({ token, wait: url.searchParams.get('wait') }))),
|
|
552
|
+
'POST /api/cloud-ai/connect': (b) => {
|
|
553
|
+
if (!CLOUD_TOOLS.includes(b && b.tool)) throw httpError(400, 'Pick Claude Code, Codex or Cursor.')
|
|
554
|
+
return asAccount(async (token) => cloudView(await aiVmConnect({ token, tool: b.tool })))
|
|
555
|
+
},
|
|
556
|
+
'POST /api/cloud-ai/settings': (b) => { const patch = cloudPatch(b || {}); return asAccount(async (token) => cloudView(await aiVmUpdate({ token, patch }))) },
|
|
557
|
+
'POST /api/cloud-ai/sign-in': (b) => asAccount(async (token) => aiVmSignIn({ token, ...(b && b.code !== undefined ? { code: String(b.code).trim() } : {}) })),
|
|
558
|
+
'POST /api/cloud-ai/wake': () => asAccount(async (token) => cloudView(await aiVmWake({ token }))),
|
|
559
|
+
'POST /api/cloud-ai/disconnect': () => asAccount(async (token) => { await aiVmDisconnect({ token }).catch(cloudNone); hasCloudAi = false; return { ok: true } })
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/** The person left a session for good: their cloud AI leaves it too. */
|
|
563
|
+
const unfollowWithCloudAi = (room) => {
|
|
564
|
+
if (!room || !readAccount() || !hasCloudAi) return
|
|
565
|
+
asAccount((token) => aiVmUnfollow({ token, room })).catch(() => {})
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// Agent invites and the list of your agents come from the accounts API, as this computer's account.
|
|
569
|
+
// A 401 there is checked against /v1/me before it counts: only a token the API no longer knows
|
|
570
|
+
// signs the app out (an older API that doesn't take the app's token for these yet just errors).
|
|
571
|
+
const asAccount = async (fn) => {
|
|
572
|
+
const account = readAccount()
|
|
573
|
+
if (!account) throw Object.assign(httpError(401, 'Sign in to Quilt first.'), { signedOut: true })
|
|
574
|
+
try {
|
|
575
|
+
return await fn(account.token)
|
|
576
|
+
} catch (err) {
|
|
577
|
+
if (err.status !== 401) throw err
|
|
578
|
+
let revoked = false
|
|
579
|
+
try { await fetchMe({ token: account.token }) } catch (e) { revoked = e.status === 401 }
|
|
580
|
+
if (!revoked) throw httpError(502, `Quilt's accounts service turned this down: ${err.message}`)
|
|
581
|
+
// The token is gone, maybe not the link: sign back in with this computer's key and go again.
|
|
582
|
+
let back
|
|
583
|
+
try { back = await resumeAccount() } catch (e) { throw httpError(502, `Couldn't sign this computer back in: ${e.message}`) }
|
|
584
|
+
if (back) { passes = null; signedOutReason = null; return fn(back.token) }
|
|
585
|
+
await signedOut('revoked', account.token)
|
|
586
|
+
throw Object.assign(httpError(401, SIGNED_OUT_MESSAGE), { signedOut: true })
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// Workspaces live on the accounts API behind a flag: a 404 means off, and the app shows today's home.
|
|
591
|
+
const workspaceList = () => asAccount(async (token) => {
|
|
592
|
+
try {
|
|
593
|
+
const workspaces = await listWorkspaces({ token })
|
|
594
|
+
workspacesOn = true
|
|
595
|
+
return { on: true, workspaces }
|
|
596
|
+
} catch (err) {
|
|
597
|
+
if (err.status === 404) { workspacesOn = false; return { on: false, workspaces: [] } }
|
|
598
|
+
throw err
|
|
599
|
+
}
|
|
600
|
+
})
|
|
601
|
+
const needInviteId = (id) => { if (!/^[0-9a-f-]{36}$/i.test(String(id || ''))) throw httpError(400, 'Which invite?'); return String(id) }
|
|
602
|
+
const needWorkspaceId = (id) => { if (!/^[0-9a-f-]{36}$/i.test(String(id || ''))) throw httpError(400, 'Which workspace?'); return id }
|
|
603
|
+
const needAgentId = (id) => { if (!/^[0-9a-f-]{36}$/i.test(String(id || ''))) throw httpError(400, 'Which agent?'); return id }
|
|
604
|
+
const needOrgSlug = (slug) => { if (!/^[a-z0-9][a-z0-9-]{0,63}$/i.test(String(slug || ''))) throw httpError(400, 'Which org?'); return slug }
|
|
605
|
+
const ofWorkspace = (id) => ({
|
|
606
|
+
running: [...runs.keys()].filter((k) => (readConfig(runs.get(k).run.dir) || {}).workspace === id),
|
|
607
|
+
recent: recentList().filter((r) => r.workspace === id)
|
|
608
|
+
})
|
|
609
|
+
|
|
610
|
+
// Access types and invites (the owner's): the API keeps grants, the relay applies them.
|
|
611
|
+
const ACCOUNT = /^(person|agent):[A-Za-z0-9_-]{1,64}$/
|
|
612
|
+
const owned = (id) => {
|
|
613
|
+
const s = get(id)
|
|
614
|
+
if (!s.isOwner) throw httpError(403, 'Only the session owner can do that.')
|
|
615
|
+
return s
|
|
616
|
+
}
|
|
617
|
+
const admitter = (id) => {
|
|
618
|
+
const s = get(id)
|
|
619
|
+
if (!s.canAdmit) throw httpError(403, 'You cannot let people into this session.')
|
|
620
|
+
return s
|
|
621
|
+
}
|
|
622
|
+
const typeById = async (token, typeId) => {
|
|
623
|
+
// The built-ins are known here, so letting someone in as one works while the API is down.
|
|
624
|
+
const type = builtinType(typeId) || (await listAccessTypes({ token })).find((t) => t.id === typeId)
|
|
625
|
+
if (!type) throw httpError(400, 'Pick an access type.')
|
|
626
|
+
return type
|
|
627
|
+
}
|
|
628
|
+
/** Runs `fn` against the API; anything but a 401 (which signs out) becomes a warning instead of an error. */
|
|
629
|
+
const tryApi = async (fn) => {
|
|
630
|
+
try { await fn(); return '' } catch (err) {
|
|
631
|
+
if (err.status === 401) throw err
|
|
632
|
+
return err.message
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* Lets someone in as an access type: the grant goes to the API first (so their next pass
|
|
638
|
+
* carries it), then the relay lets them in with the access it comes to. Someone who isn't
|
|
639
|
+
* an account (an older app) is still let in, with a warning that nothing was saved.
|
|
640
|
+
*/
|
|
641
|
+
async function approveAs (id, { key, typeId }) {
|
|
642
|
+
const s = owned(id)
|
|
643
|
+
return asAccount(async (token) => {
|
|
644
|
+
const type = await typeById(token, typeId)
|
|
645
|
+
let access = effectiveAccess(type, {})
|
|
646
|
+
let warning = ''
|
|
647
|
+
if (ACCOUNT.test(String(key))) warning = await tryApi(async () => { access = (await putGrant({ token, room: s.room, account: key, typeId })).access })
|
|
648
|
+
await s.approve(key, { typeId, access })
|
|
649
|
+
return warning ? { ok: true, warning: `Let in, but their access wasn't saved on heyquilt.com: ${warning}` } : { ok: true }
|
|
650
|
+
})
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/** Changes someone's access type and how it's narrowed: the API, then the relay at once. */
|
|
654
|
+
async function setAccess (id, { key, typeId, tighten }) {
|
|
655
|
+
const s = owned(id)
|
|
656
|
+
if (!ACCOUNT.test(String(key))) throw httpError(400, 'They joined before access types. Change their role instead.')
|
|
657
|
+
return asAccount(async (token) => {
|
|
658
|
+
const grant = await putGrant({ token, room: s.room, account: key, typeId, tighten })
|
|
659
|
+
await s.setMember(key, { access: grant.access })
|
|
660
|
+
return { grant }
|
|
661
|
+
})
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* Ends keeping an agent out of a session, and, in a session this computer put in a workspace,
|
|
666
|
+
* sends that agent the session's link now (when it has a webhook): { ok, notified }. The link
|
|
667
|
+
* is best effort: the keep-out is gone either way, and a failure is only logged.
|
|
668
|
+
*/
|
|
669
|
+
async function inviteAgentAgain (id, agentId) {
|
|
670
|
+
const s = owned(id)
|
|
671
|
+
await asAccount((token) => includeSessionAgent({ token, room: s.room, agentId }))
|
|
672
|
+
const r = runs.get(id)
|
|
673
|
+
const workspace = r && readConfig(r.run.dir)?.workspace
|
|
674
|
+
if (!workspacesOn || !workspace) return { ok: true, notified: false }
|
|
675
|
+
const sent = await asAccount((token) => announceSessionStarted({ token, id: workspace, room: s.room, link: r.run.invite, agents: [agentId] }))
|
|
676
|
+
.catch((err) => { r.log?.(`could not send that agent this session's link: ${err.message}`); return null })
|
|
677
|
+
return { ok: true, notified: !!sent?.notified?.includes(agentId) }
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/**
|
|
681
|
+
* Removes someone, and their grant, so they wait for the owner if they come back. The relay
|
|
682
|
+
* removes them either way; if the grant couldn't be deleted, their next pass would let them
|
|
683
|
+
* straight back in, so the owner is told.
|
|
684
|
+
*/
|
|
685
|
+
async function removeMember (id, key) {
|
|
686
|
+
const s = owned(id)
|
|
687
|
+
// With workspaces on, an agent removed from a session in a workspace is also kept out of
|
|
688
|
+
// this session, so its workspace (or its placement) doesn't let it straight back in. Written
|
|
689
|
+
// first, so no pass is issued between the removal and the keep-out; never fatal, and given
|
|
690
|
+
// up after keepOutMs: the removal goes ahead either way, and a failure is only logged.
|
|
691
|
+
const agent = /^agent:([0-9a-f-]{36})$/i.exec(String(key))
|
|
692
|
+
const r = runs.get(id)
|
|
693
|
+
if (workspacesOn && agent && r && readConfig(r.run.dir)?.workspace) {
|
|
694
|
+
let timer
|
|
695
|
+
const late = new Promise((resolve, reject) => { timer = setTimeout(() => reject(new Error('Quilt took too long to answer')), keepOutMs) })
|
|
696
|
+
await Promise.race([asAccount((token) => excludeSessionAgent({ token, room: s.room, agentId: agent[1] })), late])
|
|
697
|
+
.catch((err) => r.log?.(`could not keep that agent out of this session: ${err.message}`))
|
|
698
|
+
.finally(() => clearTimeout(timer))
|
|
699
|
+
}
|
|
700
|
+
let warning = ''
|
|
701
|
+
if (ACCOUNT.test(String(key))) {
|
|
702
|
+
try {
|
|
703
|
+
warning = await asAccount((token) => tryApi(() => deleteGrant({ token, room: s.room, account: key })))
|
|
704
|
+
} catch (err) {
|
|
705
|
+
warning = err.message
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
await s.removeMember(key)
|
|
709
|
+
return warning ? { ok: true, warning: `Removed, but their access is still saved on heyquilt.com, so they can get back in: ${warning}` } : { ok: true }
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/** Invites someone by email or account. The email carries the session's link: the view link for a view-only type. */
|
|
713
|
+
async function invite (id, { typeId, to }) {
|
|
714
|
+
const s = owned(id)
|
|
715
|
+
const run = runs.get(id).run
|
|
716
|
+
return asAccount(async (token) => {
|
|
717
|
+
const type = await typeById(token, typeId)
|
|
718
|
+
const link = type.files === 'view' && run.viewInvite ? run.viewInvite : run.invite
|
|
719
|
+
return { invite: await inviteToSession({ token, room: s.room, typeId, to, link }) }
|
|
720
|
+
})
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
const api = {
|
|
724
|
+
'GET /api/account': () => accountState(),
|
|
725
|
+
'GET /api/access-types': () => asAccount(async (token) => ({ types: await listAccessTypes({ token }) })),
|
|
726
|
+
// Quilt's GitHub App: whether this account connected GitHub, and the link that connects it (opened in the browser).
|
|
727
|
+
...cloudRoutes,
|
|
728
|
+
'GET /api/github': () => asAccount(async (token) => githubStatus({ token })),
|
|
729
|
+
'POST /api/github/connect': (b) => asAccount(async (token) => ({ url: await githubConnectUrl({ token, repo: b && b.repo, ...(b && ['workspace', 'org'].includes(b.scope) ? { scope: b.scope, id: String(b.id || '') } : {}) }) })),
|
|
730
|
+
// GitHub for a workspace (plan §2): its connections, access mode, and its org's Require GitHub.
|
|
731
|
+
'GET /api/workspaces/:id/github': (b, id) => asAccount(async (token) => workspaceGithub({ token, id: needWorkspaceId(id) })),
|
|
732
|
+
'POST /api/workspaces/:id/github': (b, id) => asAccount(async (token) => setWorkspaceGithub({ token, id: needWorkspaceId(id), ...('access' in b ? { access: String(b.access || ''), repos: Array.isArray(b.repos) ? b.repos.map(String) : [] } : {}), ...('agentCommits' in b ? { agentCommits: String(b.agentCommits || '') } : {}) })),
|
|
733
|
+
'POST /api/workspaces/:id/github/disconnect': (b, id) => asAccount(async (token) => removeWorkspaceGithub({ token, id: needWorkspaceId(id), connectedBy: b && b.connectedBy ? String(b.connectedBy) : '' })),
|
|
734
|
+
'POST /api/orgs/:slug/github': (b, slug) => asAccount(async (token) => setOrgGithub({ token, slug: String(slug || ''), ...(b && 'requireGithub' in b ? { requireGithub: b.requireGithub === true } : {}), ...(b && 'agentCommits' in b ? { agentCommits: String(b.agentCommits || '') } : {}) })),
|
|
735
|
+
'GET /api/collaborators': () => asAccount(async (token) => ({ collaborators: await listCollaborators({ token }) })),
|
|
736
|
+
'GET /api/sessions/:id/grants': (b, id) => { const s = owned(id); return asAccount(async (token) => ({ grants: await listGrants({ token, room: s.room }) })) },
|
|
737
|
+
'POST /api/sessions/:id/members/access': (b, id) => setAccess(id, b),
|
|
738
|
+
'GET /api/sessions/:id/invites': (b, id) => { const s = owned(id); return asAccount(async (token) => ({ invites: await listSessionInvites({ token, room: s.room }) })) },
|
|
739
|
+
'POST /api/sessions/:id/invites': (b, id) => invite(id, b),
|
|
740
|
+
'POST /api/sessions/:id/invites/cancel': (b, id) => {
|
|
741
|
+
const s = owned(id)
|
|
742
|
+
if (!b.inviteId) throw httpError(400, 'Which invite?')
|
|
743
|
+
return asAccount(async (token) => { await cancelSessionInvite({ token, room: s.room, id: String(b.inviteId) }); return { ok: true } })
|
|
744
|
+
},
|
|
745
|
+
'GET /api/agents': () => asAccount(async (token) => ({ agents: await listAgents({ token }) })),
|
|
746
|
+
// `global` (workspaces on): a global agent's invite. Without it, the invite as it always was.
|
|
747
|
+
'POST /api/agent-invites': (b) => asAccount((token) => createAgentInvite({ token, global: workspacesOn && b?.global === true })),
|
|
748
|
+
'GET /api/workspaces': () => workspaceList(),
|
|
749
|
+
'GET /api/orgs': () => asAccount(async (token) => ({ orgs: await listOrgs({ token }) })),
|
|
750
|
+
'POST /api/workspaces': (b) => asAccount(async (token) => ({ workspace: await createWorkspace({ token, name: String(b.name || ''), description: String(b.description || ''), color: String(b.color || ''), org: b.org ? String(b.org) : undefined }) })),
|
|
751
|
+
'GET /api/workspaces/:id': (b, id) => asAccount(async (token) => ({ ...(await getWorkspace({ token, id: needWorkspaceId(id) })), ...ofWorkspace(id) })),
|
|
752
|
+
'POST /api/workspaces/:id/update': (b, id) => asAccount(async (token) => ({ workspace: await updateWorkspace({ token, id: needWorkspaceId(id), patch: { name: b.name, description: b.description, color: b.color, archived: b.archived } }) })),
|
|
753
|
+
'POST /api/workspaces/:id/delete': (b, id) => asAccount(async (token) => { await deleteWorkspace({ token, id: needWorkspaceId(id) }); return { ok: true } }),
|
|
754
|
+
'POST /api/workspaces/:id/members': (b, id) => asAccount(async (token) => ({ member: await putWorkspaceMember({ token, id: needWorkspaceId(id), account: String(b.account || ''), access: String(b.access || ''), sessions: b.sessions === undefined ? undefined : String(b.sessions) }) })),
|
|
755
|
+
// Where agents work: an agent's placement (yours), an org's agents, a workspace's say over
|
|
756
|
+
// an agent placed there, and a link for a new agent that joins the workspace.
|
|
757
|
+
'GET /api/agents/:id/placement': (b, id) => asAccount(async (token) => ({ placement: await getAgentPlacement({ token, id: needAgentId(id) }) })),
|
|
758
|
+
'POST /api/agents/:id/placement': (b, id) => asAccount(async (token) => ({ placement: await putAgentPlacement({ token, id: needAgentId(id), placement: { reach: b.reach, workspaceIds: b.workspaceIds, sessions: b.sessions, access: b.access, scopes: b.scopes } }) })),
|
|
759
|
+
'GET /api/orgs/:slug/agents': (b, slug) => asAccount(async (token) => ({ agents: await listOrgAgents({ token, slug: needOrgSlug(slug) }) })),
|
|
760
|
+
'POST /api/workspaces/:id/agents/:aid': (b, id, url, aid) => asAccount(async (token) => ({ override: await putWorkspaceAgent({ token, id: needWorkspaceId(id), agentId: needAgentId(aid), sessions: b.sessions, excluded: b.excluded }) })),
|
|
761
|
+
'POST /api/workspaces/:id/agents/:aid/remove': (b, id, url, aid) => asAccount(async (token) => { await deleteWorkspaceAgent({ token, id: needWorkspaceId(id), agentId: needAgentId(aid) }); return { ok: true } }),
|
|
762
|
+
'POST /api/workspaces/:id/agent-invites': (b, id) => asAccount(async (token) => ({ link: (await createWorkspaceAgentInvite({ token, id: needWorkspaceId(id), access: String(b.access || 'edit'), sessions: String(b.sessions || 'invited') })).link })),
|
|
763
|
+
'POST /api/workspaces/:id/members/remove': (b, id) => asAccount(async (token) => { await removeWorkspaceMember({ token, id: needWorkspaceId(id), account: String(b.account || '') }); return { ok: true } }),
|
|
764
|
+
// Moves a session (a running one by `session`, or any by its folder, `dir`) into the
|
|
765
|
+
// workspace. Its people and agents come along when you manage the workspace (`added`).
|
|
766
|
+
'POST /api/workspaces/:id/sessions/move': (b, id) => asAccount(async (token) => {
|
|
767
|
+
const live = b.session ? runs.get(String(b.session)) : null
|
|
768
|
+
if (b.session && !live) throw httpError(404, 'That session is not open.')
|
|
769
|
+
const dir = live ? live.run.dir : path.resolve(expandHome(String(b.dir || '')))
|
|
770
|
+
const saved = readConfig(dir)
|
|
771
|
+
if (!saved) throw httpError(404, 'No session in that folder.')
|
|
772
|
+
const r = await moveSessionToWorkspace({ token, id: needWorkspaceId(id), room: saved.room })
|
|
773
|
+
writePrivateJson(path.join(dir, '.quilt', 'config.json'), { ...readConfig(dir), workspace: id })
|
|
774
|
+
rememberWorkspace(dir, id)
|
|
775
|
+
if (live) { live.run.session.setWorkspace(id); pushStatus(String(b.session)) }
|
|
776
|
+
return { ok: true, added: r.added, peopleNeedAdmin: r.peopleNeedAdmin }
|
|
777
|
+
}),
|
|
778
|
+
'GET /api/workspaces/:id/invites': (b, id) => asAccount(async (token) => ({ invites: await listWorkspaceInvites({ token, id: needWorkspaceId(id) }) })),
|
|
779
|
+
'POST /api/workspaces/:id/invites': (b, id) => asAccount(async (token) => {
|
|
780
|
+
const to = b.to && typeof b.to === 'object' ? (b.to.email !== undefined ? { email: String(b.to.email) } : { account: String(b.to.account || '') }) : {}
|
|
781
|
+
return { invite: await inviteToWorkspace({ token, id: needWorkspaceId(id), to, access: String(b.access || 'edit') }) }
|
|
782
|
+
}),
|
|
783
|
+
'POST /api/workspaces/:id/invites/:iid/cancel': (b, id, url, iid) => asAccount(async (token) => { await cancelWorkspaceInvite({ token, id: needWorkspaceId(id), inviteId: needInviteId(iid) }); return { ok: true } }),
|
|
784
|
+
// Invites waiting for you (workspaces, and sessions with their link); accept or decline them.
|
|
785
|
+
'GET /api/invites': () => asAccount(async (token) => ({ invites: await listMyInvites({ token }) })),
|
|
786
|
+
'POST /api/invites/:id/accept': (b, id) => asAccount(async (token) => answerMyInvite({ token, id: needInviteId(id), how: 'accept' })),
|
|
787
|
+
'POST /api/invites/:id/decline': (b, id) => asAccount(async (token) => answerMyInvite({ token, id: needInviteId(id), how: 'decline' })),
|
|
788
|
+
'GET /api/workspaces/:id/files': (b, id, url) => asAccount(async (token) => ({ files: await listWorkspaceFiles({ token, id: needWorkspaceId(id), folder: url.searchParams.get('folder') ?? undefined }) })),
|
|
789
|
+
'GET /api/workspaces/:id/files/:fid/versions': (b, id, url, fid) => asAccount(async (token) => ({ versions: await listWorkspaceFileVersions({ token, id: needWorkspaceId(id), fileId: fid }) })),
|
|
790
|
+
'POST /api/workspaces/:id/files/:fid/update': (b, id, url, fid) => asAccount(async (token) => ({ file: await updateWorkspaceFile({ token, id: needWorkspaceId(id), fileId: fid, patch: { path: b.path, note: b.note } }) })),
|
|
791
|
+
'POST /api/workspaces/:id/files/:fid/delete': (b, id, url, fid) => asAccount(async (token) => { await deleteWorkspaceFile({ token, id: needWorkspaceId(id), fileId: fid }); return { ok: true } }),
|
|
792
|
+
'POST /api/workspaces/:id/folders': (b, id) => asAccount(async (token) => ({ file: await createWorkspaceFolder({ token, id: needWorkspaceId(id), path: String(b.path || '') }) })),
|
|
793
|
+
'POST /api/account/start': () => beginLink(),
|
|
794
|
+
'POST /api/account/cancel': () => { link = null; return accountState() },
|
|
795
|
+
'POST /api/account/signout': async () => {
|
|
796
|
+
const account = readAccount()
|
|
797
|
+
// Forget the sign-in first, so a start can't pick it up again while sessions stop.
|
|
798
|
+
passes = null
|
|
799
|
+
link = null
|
|
800
|
+
clearAccount()
|
|
801
|
+
for (const id of [...runs.keys()]) await stop(id)
|
|
802
|
+
await signOut({ token: account?.token })
|
|
803
|
+
signedOutReason = null
|
|
804
|
+
return { ok: true }
|
|
805
|
+
},
|
|
806
|
+
'GET /api/state': () => ({
|
|
807
|
+
sessions: [...runs.keys()].map(summary),
|
|
808
|
+
recent: recentList(),
|
|
809
|
+
defaults: { home: os.homedir(), cwd: process.cwd(), tools: TOOL_NAMES, editors: installedEditors(), relay: relayUrl() },
|
|
810
|
+
profile: profile(),
|
|
811
|
+
maxFileBytes: MAX_SHARED_FILE_BYTES,
|
|
812
|
+
workspacesOn
|
|
813
|
+
}),
|
|
814
|
+
'POST /api/sessions': (b) => start(b),
|
|
815
|
+
'POST /api/sessions/:id/open-in': async (b, id) => { await openIn(String(b.app || ''), get(id).root); return { ok: true } },
|
|
816
|
+
'GET /api/sessions/:id/merges': (b, id) => ({ merges: get(id).mergeList() }),
|
|
817
|
+
// Gathering the project's AI rules into AGENTS.md (project-rules.js): look first, then apply what was shown.
|
|
818
|
+
'GET /api/sessions/:id/rules/gather': (b, id) => get(id).rulesGatherPlan(),
|
|
819
|
+
'POST /api/sessions/:id/rules/gather': (b, id) => get(id).applyRulesGather(String(b.key || '')),
|
|
820
|
+
'POST /api/sessions/:id/merges/resolve': async (b, id) => { const r = await get(id).settleMerge(String(b.id || ''), { how: b.how }); pushStatus(id); return r },
|
|
821
|
+
'POST /api/sessions/:id/merges/send': async (b, id) => {
|
|
822
|
+
const s = get(id)
|
|
823
|
+
const mergeId = String(b.id || '')
|
|
824
|
+
const { prompt } = await s.sendMerge(mergeId)
|
|
825
|
+
pushStatus(id)
|
|
826
|
+
const app = String(b.app || '')
|
|
827
|
+
// Your AI, working on its own (autowork.js): the run says how it went in chat.
|
|
828
|
+
if (app === 'auto') {
|
|
829
|
+
const rec = s.mergeList().find((m) => m.id === mergeId)
|
|
830
|
+
return { ...autowork().sendMerge(s, { id: mergeId, path: rec ? rec.path : '', prompt }), app }
|
|
831
|
+
}
|
|
832
|
+
const { copied, filled } = await openIn(app, s.root, { prompt })
|
|
833
|
+
return { copied, filled: !!filled, app }
|
|
834
|
+
},
|
|
835
|
+
'POST /api/sessions/:id/stop': (b, id) => stop(id).then(() => ({ ok: true })),
|
|
836
|
+
'POST /api/sessions/:id/say': (b, id) => get(id).say(b.text, { to: b.to || null }),
|
|
837
|
+
'POST /api/sessions/:id/focus': (b, id) => { get(id).setFocus(b.text); return { ok: true } },
|
|
838
|
+
// The composer says while someone types (and stops when they clear it or send).
|
|
839
|
+
'POST /api/sessions/:id/typing': (b, id) => get(id).setTyping(b.on !== false, { to: b.to || null }),
|
|
840
|
+
'POST /api/sessions/:id/claim': (b, id) => get(id).claim(b.pattern, b.note),
|
|
841
|
+
'POST /api/sessions/:id/release': async (b, id) => ({ released: await get(id).release(b.pattern) }),
|
|
842
|
+
'POST /api/sessions/:id/clear-claims': async (b, id) => ({ released: await get(id).clearInactiveClaims() }),
|
|
843
|
+
'POST /api/sessions/:id/request-file': (b, id) => get(id).requestFile(b.path, { title: b.title, description: b.description }),
|
|
844
|
+
// Boxes (boxes.js): each one's change, for the people panel; and opening your own early (quilt_unpack).
|
|
845
|
+
'GET /api/sessions/:id/boxes': (b, id) => get(id).boxes(),
|
|
846
|
+
'POST /api/sessions/:id/unpack': async (b, id) => { const r = await get(id).unpack(Array.isArray(b.paths) ? b.paths : [b.path]); pushStatus(id); return r },
|
|
847
|
+
'POST /api/sessions/:id/withdraw-request': async (b, id) => ({ withdrawn: await get(id).withdrawRequest(b.request) }),
|
|
848
|
+
'POST /api/sessions/:id/handoff': (b, id) => get(id).handoff(b.path, { to: b.to, context: b.context }),
|
|
849
|
+
'POST /api/sessions/:id/attach-from-workspace': async (b, id) => {
|
|
850
|
+
const s = get(id)
|
|
851
|
+
const workspace = (readConfig(s.root) || {}).workspace
|
|
852
|
+
if (!workspace) throw httpError(400, 'This session is not in a workspace.')
|
|
853
|
+
const info = await asAccount((token) => workspaceFileDownload({ token, id: workspace, fileId: String(b.fileId || '') }))
|
|
854
|
+
if (info.size > MAX_SHARED_FILE_BYTES) throw httpError(413, 'That file is too big to send in chat (100 MB at most).')
|
|
855
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'quilt-wsattach-'))
|
|
856
|
+
try {
|
|
857
|
+
const file = path.join(dir, path.basename(info.name) || 'file')
|
|
858
|
+
const r = await fetch(info.url)
|
|
859
|
+
if (!r.ok) throw httpError(502, `The file could not be fetched (${r.status}).`)
|
|
860
|
+
// pipeline, not .pipe: a download that breaks off rejects here instead of crashing the app.
|
|
861
|
+
await pipeline(Readable.fromWeb(r.body), fs.createWriteStream(file)).catch((err) => { throw httpError(502, `The file could not be fetched (${err.message}).`) })
|
|
862
|
+
return await s.sendFile(file, { to: b.to || null, text: String(b.text || '') })
|
|
863
|
+
} finally { fs.rmSync(dir, { recursive: true, force: true }) }
|
|
864
|
+
},
|
|
865
|
+
'POST /api/sessions/:id/read': (b, id) => { get(id).messages({ limit: 500 }); pushStatus(id); return { ok: true } },
|
|
866
|
+
'GET /api/sessions/:id/messages': (b, id) => ({ messages: get(id).messages({ limit: 200, markRead: false }) }),
|
|
867
|
+
'GET /api/sessions/:id/tasks': (b, id) => ({ tasks: get(id).taskList() }),
|
|
868
|
+
'POST /api/sessions/:id/tasks': (b, id) => ({ task: get(id).addTask(b), tasks: get(id).taskList() }),
|
|
869
|
+
'POST /api/sessions/:id/tasks/update': async (b, id) => {
|
|
870
|
+
const s = get(id)
|
|
871
|
+
const before = s.taskList().find((t) => t.id === b.id) || null
|
|
872
|
+
// Done merges the task's pull request first (relay-commit.js): refused with the reason when it can't be.
|
|
873
|
+
const finished = b.column === 'done' && before && before.column !== 'done' ? await s.finishTask(b.id) : null
|
|
874
|
+
return { task: s.updateTask(b), tasks: s.taskList(), ...(finished && finished.merged ? { merged: finished.merged } : {}) }
|
|
875
|
+
},
|
|
876
|
+
'POST /api/sessions/:id/tasks/delete': (b, id) => { get(id).deleteTask(b.id); return { tasks: get(id).taskList() } },
|
|
877
|
+
'POST /api/sessions/:id/tasks/comment': (b, id) => ({ ...get(id).commentTask(b), tasks: get(id).taskList() }),
|
|
878
|
+
'GET /api/sessions/:id/feed': (b, id, url) => {
|
|
879
|
+
const s = get(id)
|
|
880
|
+
return { entries: s.agentFeedFor(url.searchParams.get('who') || s.name) }
|
|
881
|
+
},
|
|
882
|
+
'GET /api/sessions/:id/tree': (b, id) => get(id).tree(),
|
|
883
|
+
'GET /api/sessions/:id/changes': (b, id) => get(id).changes(),
|
|
884
|
+
'POST /api/sessions/:id/catch-up/dismiss': (b, id) => { const r = get(id).dismissCatchUp(); pushStatus(id); return r },
|
|
885
|
+
// One file's changes, newest first, each with its diff (the Changes panel's "What changed").
|
|
886
|
+
'GET /api/sessions/:id/history': (b, id, url) => {
|
|
887
|
+
const file = String(url.searchParams.get('path') || '')
|
|
888
|
+
const entries = get(id).history.entries().filter((e) => e.path === file)
|
|
889
|
+
return { entries: queryHistory(entries, { limit: 100 }).reverse() }
|
|
890
|
+
},
|
|
891
|
+
// Commits pushed or merged elsewhere: fetch and bring them in now (they also come in by themselves).
|
|
892
|
+
'POST /api/sessions/:id/branches/sync': async (b, id) => { const r = await get(id).syncBranchNow(); pushStatus(id); return r },
|
|
893
|
+
'GET /api/sessions/:id/file': (b, id, url) => {
|
|
894
|
+
const f = get(id).readShared(url.searchParams.get('path'))
|
|
895
|
+
if (!f) throw httpError(404, 'That file is not in this session.')
|
|
896
|
+
return f
|
|
897
|
+
},
|
|
898
|
+
'POST /api/sessions/:id/members/approve': async (b, id) => {
|
|
899
|
+
if (b.typeId) return approveAs(id, b) // access types: owner only (their types / grants)
|
|
900
|
+
await admitter(id).approve(b.key, { role: b.role, scopes: b.scopes })
|
|
901
|
+
return { ok: true }
|
|
902
|
+
},
|
|
903
|
+
'POST /api/sessions/:id/members/deny': async (b, id) => (await admitter(id).deny(b.key), { ok: true }),
|
|
904
|
+
'POST /api/sessions/:id/admit-by': async (b, id) => (await owned(id).setAdmitBy(b.admitBy), { ok: true, admitBy: b.admitBy }),
|
|
905
|
+
// Owner only: the read-only GitHub token the relay brings commits in with while everyone's offline ('' clears it).
|
|
906
|
+
'POST /api/sessions/:id/github-token': (b, id) => owned(id).setGithubToken(b.token),
|
|
907
|
+
'POST /api/sessions/:id/members/set': async (b, id) => (await get(id).setMember(b.key, { role: b.role, scopes: b.scopes }), { ok: true }),
|
|
908
|
+
'POST /api/sessions/:id/members/remove': (b, id) => removeMember(id, b.key),
|
|
909
|
+
// Owner only: a link a chat-only AI (ChatGPT, claude.ai, Grok…) works through (chat-links.js).
|
|
910
|
+
'POST /api/sessions/:id/chat-link': (b, id) => get(id).createChatLink({ name: b.name || 'Chat AI', minutes: b.minutes }),
|
|
911
|
+
'POST /api/sessions/:id/chat-link/extend': (b, id) => get(id).extendChatLink(String(b.key || ''), b.minutes),
|
|
912
|
+
// Agents kept out of this session (removing one in a workspace does it), and letting one back in.
|
|
913
|
+
'GET /api/sessions/:id/agents/excluded': (b, id) => { const s = owned(id); return asAccount(async (token) => ({ agents: await listExcludedSessionAgents({ token, room: s.room }) })) },
|
|
914
|
+
// The session's People (workspaces on): the agents its workspace invites, Don't invite (a
|
|
915
|
+
// keep-out) and Invite again, which also hands that agent the session's link now.
|
|
916
|
+
'GET /api/sessions/:id/agents': (b, id) => { const s = owned(id); return asAccount(async (token) => ({ agents: await listSessionAgents({ token, room: s.room }) })) },
|
|
917
|
+
'POST /api/sessions/:id/agents/exclude': (b, id) => { const s = owned(id); return asAccount(async (token) => { await excludeSessionAgent({ token, room: s.room, agentId: needAgentId(b.agentId) }); return { ok: true } }) },
|
|
918
|
+
'POST /api/sessions/:id/agents/include': (b, id) => inviteAgentAgain(id, needAgentId(b.agentId)),
|
|
919
|
+
'POST /api/sessions/:id/rename': (b, id) => rename(id, b.name),
|
|
920
|
+
'POST /api/sessions/:id/end': async (b, id) => { const room = get(id).room; await get(id).endForEveryone(); await stop(id); unfollowWithCloudAi(room); return { ok: true } },
|
|
921
|
+
'POST /api/sessions/:id/summarize': (b, id) => {
|
|
922
|
+
const r = runs.get(id)
|
|
923
|
+
if (!r) throw httpError(404, 'That session is not running.')
|
|
924
|
+
return { on: r.run.session.setSummarize(b.on ? r.run.summarizer() : null) }
|
|
925
|
+
},
|
|
926
|
+
'POST /api/sessions/:id/sharing': (b, id) => ({ on: get(id).setAgentSharing(b.on !== false) }),
|
|
927
|
+
'POST /api/recent/forget': (b) => {
|
|
928
|
+
const dir = path.resolve(expandHome(String(b.dir || '')))
|
|
929
|
+
const room = readConfig(dir)?.room
|
|
930
|
+
forgetRecent(dir)
|
|
931
|
+
// Left for good: the person's cloud AI leaves it too.
|
|
932
|
+
unfollowWithCloudAi(room)
|
|
933
|
+
return { recent: recentList() }
|
|
934
|
+
},
|
|
935
|
+
// Release notes and the update check. The newest local notes are "unseen" until the app shows them.
|
|
936
|
+
'GET /api/version': async () => {
|
|
937
|
+
const version = currentVersion()
|
|
938
|
+
const latest = await latestRelease()
|
|
939
|
+
return {
|
|
940
|
+
version,
|
|
941
|
+
releases: localReleases(),
|
|
942
|
+
unseen: compareVersions(seenVersion(), version) < 0,
|
|
943
|
+
latest,
|
|
944
|
+
outOfDate: !!latest && compareVersions(latest.version, version) > 0,
|
|
945
|
+
barHidden: !!latest && hiddenUpdate() === latest.version, // "Don't show this again" for this release
|
|
946
|
+
downloadUrl: downloadUrl()
|
|
947
|
+
}
|
|
948
|
+
},
|
|
949
|
+
'POST /api/version/seen': () => { markSeen(currentVersion()); return { ok: true } },
|
|
950
|
+
'POST /api/version/hide-bar': (b) => {
|
|
951
|
+
try { hideUpdate(b.version) } catch (err) { throw httpError(400, err.message) }
|
|
952
|
+
return { ok: true }
|
|
953
|
+
},
|
|
954
|
+
'GET /api/settings': () => profile(),
|
|
955
|
+
// Work on its own: today's runs and tokens; carrying a run's chat on in your tool.
|
|
956
|
+
'GET /api/autowork': () => autowork().usage(),
|
|
957
|
+
'POST /api/autowork/take-over': async (b) => {
|
|
958
|
+
const how = autowork().takeOver(String(b.run || ''))
|
|
959
|
+
if (!how) throw httpError(404, 'That run has no chat to carry on.')
|
|
960
|
+
if (how.link) {
|
|
961
|
+
await new Promise((resolve, reject) => execFile(process.platform === 'win32' ? 'cmd' : 'open', process.platform === 'win32' ? ['/c', 'start', '""', how.link] : [how.link], (err) => err ? reject(err) : resolve()))
|
|
962
|
+
return { opened: true }
|
|
963
|
+
}
|
|
964
|
+
return { copied: await copyToClipboard(how.command), command: how.command }
|
|
965
|
+
},
|
|
966
|
+
'POST /api/settings': (b) => updateProfile(b),
|
|
967
|
+
'POST /api/report': (b) => {
|
|
968
|
+
reporter.record({ kind: 'error', name: String(b.name || 'renderer').slice(0, 80), outcome: 'error', message: String(b.message || '').slice(0, 500), context: typeof b.context === 'object' && b.context ? b.context : {} })
|
|
969
|
+
return { ok: true }
|
|
970
|
+
},
|
|
971
|
+
'GET /api/github/status': () => gitops.ghStatus(),
|
|
972
|
+
'GET /api/github/repos': async (b, id, url) => ({ repos: await gitops.listRepos({ limit: url.searchParams.get('limit') || 100 }) }),
|
|
973
|
+
'GET /api/github/branches': (b, id, url) => gitops.listBranches(url.searchParams.get('repo')),
|
|
974
|
+
'POST /api/sessions/:id/commit-request': (b, id) => { const r = get(id).requestCommit(b.message, { files: Array.isArray(b.files) ? b.files : null, task: b.task || null, by: b.by ? String(b.by).slice(0, 80) : null }); pushStatus(id); return r },
|
|
975
|
+
// Commits a request in this folder (commit.js), and what is not committed here yet, by who made it.
|
|
976
|
+
'POST /api/sessions/:id/commit-session': async (b, id) => { try { return await get(id).commitSessionWork(String(b.message || '')) } finally { pushStatus(id) } },
|
|
977
|
+
'POST /api/sessions/:id/commit-request/commit': async (b, id) => { try { return await get(id).commitRequest(String(b.id || '')) } finally { pushStatus(id) } },
|
|
978
|
+
'POST /api/sessions/:id/agent-commits': async (b, id) => { try { return await get(id).setAgentCommits(b.mode) } finally { pushStatus(id) } },
|
|
979
|
+
'POST /api/sessions/:id/github-sync': async (b, id) => { try { return await get(id).setGithubSync(b.on !== false) } finally { pushStatus(id) } },
|
|
980
|
+
'POST /api/sessions/:id/large-to-relay': async (b, id) => { try { return await get(id).setLargeToRelay(b.on === true) } finally { pushStatus(id) } },
|
|
981
|
+
'POST /api/sessions/:id/github/recheck': async (b, id) => { try { return { github: await get(id).githubRecheck() } } finally { pushStatus(id) } },
|
|
982
|
+
'GET /api/sessions/:id/uncommitted': (b, id) => get(id).uncommittedWork(),
|
|
983
|
+
'POST /api/sessions/:id/commit-request/done': (b, id) => { const done = get(id).resolveCommitRequests({ ids: b.id ? [String(b.id)] : null }); pushStatus(id); return { done } },
|
|
984
|
+
'GET /api/fs': (b, id, url) => listDir(url.searchParams.get('path') || os.homedir()),
|
|
985
|
+
// Reply first, then shut down, so the page hears back before we exit.
|
|
986
|
+
'POST /api/shutdown': () => {
|
|
987
|
+
if (!onShutdown) throw httpError(501, 'Shut down is not available here.')
|
|
988
|
+
setTimeout(onShutdown, 100)
|
|
989
|
+
return { ok: true }
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
const server = http.createServer(async (req, res) => {
|
|
994
|
+
const url = new URL(req.url, 'http://x')
|
|
995
|
+
const json = (code, body) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(body)) }
|
|
996
|
+
|
|
997
|
+
// DNS-rebinding guard: only answer requests addressed to localhost.
|
|
998
|
+
const host = (req.headers.host || '').replace(/:\d+$/, '')
|
|
999
|
+
if (!['127.0.0.1', 'localhost', '[::1]'].includes(host)) return json(403, { error: 'forbidden host' })
|
|
1000
|
+
|
|
1001
|
+
if (preview && req.method === 'GET' && url.pathname === '/' && !url.searchParams.get('t')) {
|
|
1002
|
+
res.writeHead(302, { location: `/?t=${token}`, 'cache-control': 'no-store' })
|
|
1003
|
+
return res.end()
|
|
1004
|
+
}
|
|
1005
|
+
if (req.method === 'GET' && STATIC[url.pathname]) {
|
|
1006
|
+
const [file, type] = STATIC[url.pathname]
|
|
1007
|
+
const headers = { 'content-type': type, 'cache-control': 'no-store' }
|
|
1008
|
+
if (url.pathname === '/') headers['content-security-policy'] = CSP
|
|
1009
|
+
res.writeHead(200, headers)
|
|
1010
|
+
const body = fs.readFileSync(path.join(UI_DIR, file))
|
|
1011
|
+
// The theme goes on <html> before anything paints, so dark mode never flashes light.
|
|
1012
|
+
if (url.pathname === '/') return res.end(String(body).replace('<html lang="en">', `<html lang="en" data-theme="${profile().theme}">`))
|
|
1013
|
+
return res.end(body)
|
|
1014
|
+
}
|
|
1015
|
+
const font = req.method === 'GET' && url.pathname.match(/^\/fonts\/([a-z-]+)\/([^/]+)$/)
|
|
1016
|
+
if (font) {
|
|
1017
|
+
const f = fontFile(font[1], font[2])
|
|
1018
|
+
if (!f) return json(404, { error: 'not found' })
|
|
1019
|
+
res.writeHead(200, { 'content-type': 'font/woff2', 'cache-control': 'max-age=31536000, immutable' })
|
|
1020
|
+
return res.end(fs.readFileSync(f))
|
|
1021
|
+
}
|
|
1022
|
+
if (req.method === 'GET' && (url.pathname === '/logo.svg' || url.pathname === '/favicon.svg')) {
|
|
1023
|
+
res.writeHead(200, { 'content-type': 'image/svg+xml' })
|
|
1024
|
+
return res.end(fs.readFileSync(LOGO))
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
const supplied = req.headers['x-quilt-token'] || url.searchParams.get('t')
|
|
1028
|
+
if (supplied !== token) return json(401, { error: 'Open quilt from the link printed by `quilt ui`.' })
|
|
1029
|
+
|
|
1030
|
+
try {
|
|
1031
|
+
if (!OPEN_ROUTES.has(`${req.method} ${url.pathname}`) && !readAccount()) return json(401, { error: 'Sign in to Quilt first.', signedOut: true })
|
|
1032
|
+
if (req.method === 'GET' && url.pathname === '/api/events') return events(req, res)
|
|
1033
|
+
|
|
1034
|
+
let m = url.pathname.match(/^\/api\/sessions\/([a-f0-9]+)\/send$/)
|
|
1035
|
+
if (req.method === 'POST' && m) return json(200, await receiveUpload(req, get(m[1])))
|
|
1036
|
+
m = url.pathname.match(/^\/api\/sessions\/([a-f0-9]+)\/files\/([a-f0-9]+)$/)
|
|
1037
|
+
if (req.method === 'GET' && m) return await serveFile(res, get(m[1]), m[2])
|
|
1038
|
+
const wu = url.pathname.match(/^\/api\/workspaces\/([^/]+)\/upload$/)
|
|
1039
|
+
if (req.method === 'PUT' && wu) return json(200, await receiveWorkspaceUpload(req, wu[1]))
|
|
1040
|
+
const wd = url.pathname.match(/^\/api\/workspaces\/([^/]+)\/files\/([^/]+)\/data$/)
|
|
1041
|
+
if (req.method === 'GET' && wd) return await streamWorkspaceFile(req, res, wd[1], wd[2])
|
|
1042
|
+
|
|
1043
|
+
let pathKey = url.pathname
|
|
1044
|
+
.replace(/^\/api\/sessions\/[a-f0-9]+/, '/api/sessions/:id')
|
|
1045
|
+
.replace(/^\/api\/workspaces\/[^/]+/, '/api/workspaces/:id')
|
|
1046
|
+
.replace(/^\/api\/agents\/[^/]+\//, '/api/agents/:id/')
|
|
1047
|
+
.replace(/^\/api\/orgs\/[^/]+\//, '/api/orgs/:slug/')
|
|
1048
|
+
.replace(/^\/api\/invites\/[^/]+\//, '/api/invites/:id/')
|
|
1049
|
+
const sid = (url.pathname.match(/^\/api\/sessions\/([a-f0-9]+)/) || url.pathname.match(/^\/api\/workspaces\/([^/]+)/) || url.pathname.match(/^\/api\/(?:agents|orgs|invites)\/([^/]+)\//) || [])[1]
|
|
1050
|
+
const fid = (url.pathname.match(/^\/api\/workspaces\/[^/]+\/files\/([^/]+)/) || [])[1]
|
|
1051
|
+
if (fid) pathKey = pathKey.replace(/\/files\/[^/]+/, '/files/:fid')
|
|
1052
|
+
// A workspace's agent (its override): passed where a file id would be.
|
|
1053
|
+
const aid = (url.pathname.match(/^\/api\/workspaces\/[^/]+\/agents\/([^/]+)/) || [])[1]
|
|
1054
|
+
if (aid) pathKey = pathKey.replace(/^(\/api\/workspaces\/:id\/agents)\/[^/]+/, '$1/:aid')
|
|
1055
|
+
// A workspace's invite, the same way.
|
|
1056
|
+
const iid = (url.pathname.match(/^\/api\/workspaces\/[^/]+\/invites\/([^/]+)\//) || [])[1]
|
|
1057
|
+
if (iid) pathKey = pathKey.replace(/^(\/api\/workspaces\/:id\/invites)\/[^/]+/, '$1/:iid')
|
|
1058
|
+
const key = `${req.method} ${pathKey}`
|
|
1059
|
+
const handler = api[key]
|
|
1060
|
+
if (!handler) {
|
|
1061
|
+
reporter.record({ kind: 'http404', name: key.slice(0, 80), outcome: 'error', status: 404 })
|
|
1062
|
+
return json(404, { error: 'not found' })
|
|
1063
|
+
}
|
|
1064
|
+
let raw = ''
|
|
1065
|
+
for await (const chunk of req) raw += chunk
|
|
1066
|
+
const body = raw ? JSON.parse(raw) : {}
|
|
1067
|
+
const startedAt = Date.now()
|
|
1068
|
+
try {
|
|
1069
|
+
const out = await handler(body, sid, url, fid || aid || iid)
|
|
1070
|
+
json(200, out)
|
|
1071
|
+
return recordRoute(key, { startedAt, status: 200, body })
|
|
1072
|
+
} catch (err) {
|
|
1073
|
+
recordRoute(key, { startedAt, status: err.status || 400, body, error: err })
|
|
1074
|
+
throw err
|
|
1075
|
+
}
|
|
1076
|
+
} catch (err) {
|
|
1077
|
+
return json(err.status || 400, { error: err.message, ...(err.signedOut ? { signedOut: true } : {}) })
|
|
1078
|
+
}
|
|
1079
|
+
})
|
|
1080
|
+
|
|
1081
|
+
function events (req, res) {
|
|
1082
|
+
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' })
|
|
1083
|
+
res.write(': hi\n\n')
|
|
1084
|
+
clients.add(res)
|
|
1085
|
+
const ping = setInterval(() => res.write(': ping\n\n'), 15000)
|
|
1086
|
+
req.on('close', () => { clearInterval(ping); clients.delete(res) })
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
/** A write stream to a temp file whose failures (disk full, permissions) reject the
|
|
1090
|
+
* write or end that meets them, rather than going unhandled and crashing the app. */
|
|
1091
|
+
function tempWriter (file) {
|
|
1092
|
+
const out = fs.createWriteStream(file)
|
|
1093
|
+
let failed = null
|
|
1094
|
+
out.on('error', (err) => { failed = failed || err })
|
|
1095
|
+
const check = () => { if (failed) throw failed }
|
|
1096
|
+
return {
|
|
1097
|
+
async write (chunk) {
|
|
1098
|
+
check()
|
|
1099
|
+
if (!out.write(chunk)) {
|
|
1100
|
+
// Wait for drain, or for the stream to close on an error; whichever fires, drop the other.
|
|
1101
|
+
await new Promise((resolve) => {
|
|
1102
|
+
const done = () => { out.off('drain', done); out.off('close', done); resolve() }
|
|
1103
|
+
out.once('drain', done); out.once('close', done)
|
|
1104
|
+
})
|
|
1105
|
+
}
|
|
1106
|
+
check()
|
|
1107
|
+
},
|
|
1108
|
+
async end () {
|
|
1109
|
+
check()
|
|
1110
|
+
await new Promise((resolve, reject) => out.end((err) => (err || failed ? reject(err || failed) : resolve())))
|
|
1111
|
+
},
|
|
1112
|
+
destroy () { out.destroy() }
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
async function receiveUpload (req, session) {
|
|
1117
|
+
const name = path.basename(decodeURIComponent(req.headers['x-filename'] || 'file')) || 'file'
|
|
1118
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'quilt-up-'))
|
|
1119
|
+
const file = path.join(dir, name)
|
|
1120
|
+
try {
|
|
1121
|
+
let size = 0
|
|
1122
|
+
const out = tempWriter(file)
|
|
1123
|
+
for await (const chunk of req) {
|
|
1124
|
+
size += chunk.length
|
|
1125
|
+
if (size > MAX_SHARED_FILE_BYTES) { out.destroy(); throw httpError(413, 'File is too large.') }
|
|
1126
|
+
await out.write(chunk)
|
|
1127
|
+
}
|
|
1128
|
+
await out.end()
|
|
1129
|
+
const to = req.headers['x-to'] ? decodeURIComponent(req.headers['x-to']) : null
|
|
1130
|
+
const text = req.headers['x-text'] ? decodeURIComponent(req.headers['x-text']) : ''
|
|
1131
|
+
return await session.sendFile(file, { to, text })
|
|
1132
|
+
} finally {
|
|
1133
|
+
fs.rmSync(dir, { recursive: true, force: true })
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
async function serveFile (res, session, msgId) {
|
|
1138
|
+
const msg = session.messages({ limit: 500, markRead: false }).find((m) => m.id === msgId)
|
|
1139
|
+
if (!msg || !msg.file) throw httpError(404, 'No such file.')
|
|
1140
|
+
const local = msg.file.localPath ? path.join(session.root, msg.file.localPath) : await session.fetchFile(msgId)
|
|
1141
|
+
res.writeHead(200, {
|
|
1142
|
+
'content-type': 'application/octet-stream',
|
|
1143
|
+
'content-disposition': `attachment; filename="${msg.file.name.replace(/[^\w.\- ]/g, '_')}"`
|
|
1144
|
+
})
|
|
1145
|
+
fs.createReadStream(local).pipe(res)
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
// A workspace upload from the page: the bytes go to a temp file, the API hands out a signed
|
|
1149
|
+
// link, Node streams the file there, and the API confirms it. The page never talks to storage.
|
|
1150
|
+
async function receiveWorkspaceUpload (req, id) {
|
|
1151
|
+
const account = readAccount()
|
|
1152
|
+
if (!account) throw Object.assign(httpError(401, 'Sign in to Quilt first.'), { signedOut: true })
|
|
1153
|
+
if (Number(req.headers['content-length']) > MAX_WS_FILE_BYTES) throw httpError(413, 'File is too large (500 MB at most).')
|
|
1154
|
+
const filePath = decodeURIComponent(req.headers['x-path'] || '')
|
|
1155
|
+
const note = req.headers['x-note'] ? decodeURIComponent(req.headers['x-note']) : ''
|
|
1156
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'quilt-wsup-'))
|
|
1157
|
+
const tmp = path.join(dir, 'upload')
|
|
1158
|
+
try {
|
|
1159
|
+
const hash = crypto.createHash('sha256')
|
|
1160
|
+
let size = 0
|
|
1161
|
+
const out = tempWriter(tmp)
|
|
1162
|
+
for await (const chunk of req) {
|
|
1163
|
+
size += chunk.length
|
|
1164
|
+
if (size > MAX_WS_FILE_BYTES) { out.destroy(); throw httpError(413, 'File is too large (500 MB at most).') }
|
|
1165
|
+
hash.update(chunk)
|
|
1166
|
+
await out.write(chunk)
|
|
1167
|
+
}
|
|
1168
|
+
await out.end()
|
|
1169
|
+
if (!size) throw httpError(400, 'The file is empty.')
|
|
1170
|
+
const mime = String(req.headers['content-type'] || '').split(';')[0] || ''
|
|
1171
|
+
return await asAccount(async (token) => {
|
|
1172
|
+
const { file, upload } = await createWorkspaceFile({ token, id: needWorkspaceId(id), path: filePath, size, mime: mime === 'application/octet-stream' ? '' : mime, sha256: hash.digest('hex'), note })
|
|
1173
|
+
const put = await fetch(upload.url, { method: upload.method, headers: { 'content-type': file.mime || 'application/octet-stream', 'content-length': String(size), ...(upload.headers || {}) }, body: fs.createReadStream(tmp), duplex: 'half' })
|
|
1174
|
+
if (!put.ok) throw httpError(502, `The file could not be stored (${put.status}).`)
|
|
1175
|
+
return { file: await confirmWorkspaceFile({ token, id, fileId: file.id }) }
|
|
1176
|
+
})
|
|
1177
|
+
} finally { fs.rmSync(dir, { recursive: true, force: true }) }
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
/** Streams a workspace file to the page, so previews stay same-origin. */
|
|
1181
|
+
async function streamWorkspaceFile (req, res, id, fileId) {
|
|
1182
|
+
const url = new URL(req.url, 'http://x')
|
|
1183
|
+
const info = await asAccount((token) => workspaceFileDownload({ token, id: needWorkspaceId(id), fileId, version: url.searchParams.get('version') || undefined }))
|
|
1184
|
+
const r = await fetch(info.url)
|
|
1185
|
+
if (!r.ok) { res.writeHead(502, { 'content-type': 'application/json' }); return res.end(JSON.stringify({ error: `The file could not be fetched (${r.status}).` })) }
|
|
1186
|
+
const disposition = url.searchParams.get('download') ? 'attachment' : 'inline'
|
|
1187
|
+
const type = info.mime || 'application/octet-stream'
|
|
1188
|
+
// A stored HTML or SVG opened directly must not run on the app's origin (its URL carries
|
|
1189
|
+
// the token): no sniffing, and a sandbox for every type but PDF, which Chromium won't
|
|
1190
|
+
// render sandboxed. <img>, <video> and <audio> ignore the header.
|
|
1191
|
+
const guard = { 'x-content-type-options': 'nosniff', ...(type.split(';')[0].trim().toLowerCase() === 'application/pdf' ? {} : { 'content-security-policy': 'sandbox' }) }
|
|
1192
|
+
res.writeHead(200, { ...guard, 'content-type': type, ...(info.size ? { 'content-length': String(info.size) } : {}), 'content-disposition': contentDisposition(disposition, info.name), 'cache-control': 'private, max-age=60' })
|
|
1193
|
+
// The headers are out, so a download that breaks off can only cut the response short
|
|
1194
|
+
// (the page sees a failed load); pipeline makes sure that is all it does.
|
|
1195
|
+
await pipeline(Readable.fromWeb(r.body), res).catch(() => res.destroy())
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
const listen = (p) => new Promise((resolve, reject) => {
|
|
1199
|
+
server.once('error', reject)
|
|
1200
|
+
server.listen(p, '127.0.0.1', () => { server.off('error', reject); resolve() })
|
|
1201
|
+
})
|
|
1202
|
+
try { await listen(port) } catch (err) {
|
|
1203
|
+
if (err.code !== 'EADDRINUSE') throw err
|
|
1204
|
+
await listen(0)
|
|
1205
|
+
}
|
|
1206
|
+
const actualPort = server.address().port
|
|
1207
|
+
|
|
1208
|
+
return {
|
|
1209
|
+
url: `http://127.0.0.1:${actualPort}/?t=${token}`,
|
|
1210
|
+
port: actualPort,
|
|
1211
|
+
token,
|
|
1212
|
+
report: (event) => reporter.record(event),
|
|
1213
|
+
flushReports: () => reporter.flush(),
|
|
1214
|
+
close: async () => {
|
|
1215
|
+
link = null
|
|
1216
|
+
for (const id of [...runs.keys()]) await stop(id)
|
|
1217
|
+
for (const res of clients) res.end()
|
|
1218
|
+
await reporter.close()
|
|
1219
|
+
await new Promise((r) => server.close(r))
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
function listDir (p) {
|
|
1225
|
+
const dir = path.resolve(expandHome(p))
|
|
1226
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true })
|
|
1227
|
+
const dirs = entries
|
|
1228
|
+
.filter((e) => e.isDirectory() && !e.name.startsWith('.') && e.name !== 'node_modules')
|
|
1229
|
+
.map((e) => e.name)
|
|
1230
|
+
.sort((a, b) => a.localeCompare(b))
|
|
1231
|
+
return {
|
|
1232
|
+
path: dir,
|
|
1233
|
+
parent: path.dirname(dir) !== dir ? path.dirname(dir) : null,
|
|
1234
|
+
dirs,
|
|
1235
|
+
hasSession: fs.existsSync(path.join(migrateDir(dir), 'config.json')),
|
|
1236
|
+
isEmpty: entries.filter((e) => e.name !== '.DS_Store').length === 0
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
/** The AI coding tool this person most likely uses, from what it has left in their home folder. */
|
|
1241
|
+
function detectTool () {
|
|
1242
|
+
const home = os.homedir()
|
|
1243
|
+
// Prefer real usage markers. ~/.claude alone is not enough: Quilt's hooks write
|
|
1244
|
+
// settings there for every session, which made Cursor / xAI / Grok users look like
|
|
1245
|
+
// Claude Code. Transcripts live under .claude/projects.
|
|
1246
|
+
const found = [['.claude/projects', 'Claude Code'], ['.cursor', 'Cursor'], ['.codex', 'Codex'], ['.codeium/windsurf', 'Windsurf']]
|
|
1247
|
+
.map(([dir, tool]) => {
|
|
1248
|
+
try { return { tool, used: fs.statSync(path.join(home, dir)).mtimeMs } } catch { return null }
|
|
1249
|
+
})
|
|
1250
|
+
.filter(Boolean)
|
|
1251
|
+
.sort((a, b) => b.used - a.used)
|
|
1252
|
+
return found[0]?.tool || 'Claude Code'
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
function expandHome (p) {
|
|
1256
|
+
return p.startsWith('~') ? path.join(os.homedir(), p.slice(1)) : p
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
/**
|
|
1260
|
+
* The folder for `name` (a room or repo name) inside the join folder. Invites only carry plain
|
|
1261
|
+
* room names, but a name that would land anywhere else (`..`, a path) is refused all the same.
|
|
1262
|
+
*/
|
|
1263
|
+
export function underJoinDir (joinDir, name, message = INVALID_INVITE) {
|
|
1264
|
+
const root = path.resolve(expandHome(joinDir))
|
|
1265
|
+
const dir = path.resolve(root, String(name))
|
|
1266
|
+
if (path.dirname(dir) !== root) throw httpError(400, message)
|
|
1267
|
+
return dir
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
function httpError (status, message) {
|
|
1271
|
+
return Object.assign(new Error(message), { status })
|
|
1272
|
+
}
|