heyquilt 0.0.1 → 0.3.106

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