heyquilt 0.0.1 → 0.3.90

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