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
package/src/server.js ADDED
@@ -0,0 +1,4187 @@
1
+ // Relay server: holds one shared Yjs document per room, relays updates and
2
+ // presence between clients, stores files shared in chat, and persists rooms
3
+ // to disk. It checks who each client is (see identity.js) and owns the
4
+ // room's claims, so only the person who made a claim can release it (with
5
+ // sign-in on, the account that made it, so two people who share a display
6
+ // name can't release each other's claims). Safe to
7
+ // run on the public internet: rooms need their secret, creating rooms can
8
+ // require a relay key, and rooms have size quotas.
9
+ //
10
+ // Access: a room created with a view-only secret has an owner (the first
11
+ // person to sign in). By default only the owner lets people in; they can
12
+ // open that to editors or anyone in the session. People who may admit
13
+ // approve joiners and give them a role:
14
+ // editors change files, viewers only watch and chat, and agents can be
15
+ // limited to some folders. The relay enforces it by undoing file changes a
16
+ // member isn't allowed to make, before anyone else sees them.
17
+ import http from 'node:http'
18
+ import { makeChatLink, extendChatLink, pruneChatLinks, handleChatLink, fetchPublicFile } from './chat-links.js'
19
+ import fs from 'node:fs'
20
+ import path from 'node:path'
21
+ import crypto from 'node:crypto'
22
+ import os from 'node:os'
23
+ import { fileURLToPath } from 'node:url'
24
+ import { WebSocketServer } from 'ws'
25
+ import * as Y from 'yjs'
26
+ import { handleAgentMcp, handleHostedMcp, watchFeatures } from './relay-mcp.js'
27
+ import { dropCheckpoints } from './relay-turn-sync.js'
28
+ import { UpdateCheck } from './update-check.js'
29
+ import {
30
+ MSG_SYNC, MSG_AWARENESS, MSG_QUERY_AWARENESS, MSG_AUTH, MSG_CLAIM, MSG_CLAIMS, MAX_SHARED_FILE_BYTES,
31
+ MSG_ACCESS, MSG_ADMIN, MSG_MEMBERS, MSG_PASS, MSG_BRANCH, MSG_BRANCHES,
32
+ CLOSE_AUTH_FAILED, CLOSE_NAME_TAKEN, CLOSE_ROOM_FULL, CLOSE_DENIED, CLOSE_ENDED, CLOSE_NEEDS_UPDATE, CLOSE_PASS_EXPIRED,
33
+ encoding, decoding, syncProtocol, awarenessProtocol,
34
+ ROOM_DOC, syncHeader,
35
+ syncStep1Message, updateMessage, awarenessMessage, bytesMessage, jsonMessage
36
+ } from './protocol.js'
37
+ import { BranchStore, DEFAULT_KEY, BRANCH_IDLE_MS, BRANCH_TTL_MS, DETACHED_TTL_MS, MAX_BRANCHES, validBranchKey, covers, splitLegacyDoc } from './branchdocs.js'
38
+ import { parsePublicKey, verifyChallenge } from './identity.js'
39
+ import { verifyPass, PASS_TTL_MS } from './passes.js'
40
+ import { cleanAccess, narrowAccess, relayAccess, fromRelay, sameAccess, mayChange, TALK_REFUSED } from './session-access.js'
41
+ import { canAdmit, cleanAdmitBy, DEFAULT_ADMIT_BY, BAD_ADMIT_BY } from './admit-policy.js'
42
+ import { patternsOverlap } from './fsutil.js'
43
+ import { globMatcher } from './pathrules.js'
44
+ import { personaName, firstName, PERSONA_SEP } from './persona.js'
45
+ import { adoptLegacyEnv } from './legacy.js'
46
+ import { makeStore, DiskStore } from './blobstore.js'
47
+ import { JOIN_HOST } from './ui/invite.js'
48
+ import { PresenceReporter, PRESENCE_FILE } from './presence.js'
49
+ import { cleanSessionName, BAD_SESSION_NAME } from './session-name.js'
50
+ import { hostedWebhooks } from './relay-webhooks.js'
51
+ import { noteCloudAi, cloudAiWaker } from './relay-ai-vm.js'
52
+ import { normalizeHosted, hostedIn, newHosted } from './hosted-agents.js'
53
+ import { ChatArchive } from './chat-archive.js'
54
+ import { asksRelay, relayAnswer } from './relay-ask.js'
55
+ import { RELAY_BY, isRelay } from './branches.js'
56
+ import { commitForMember, agentCommitsOf, POLICY_RANK, ruleSetter, agentsCanCommit, pullRequestsFor, mergeForMember, unpublishedFor, remindUnpublished, notePushedFor, repoRecordFor, finishTaskFor, reviewPulls } from './relay-commit.js'
57
+ import { appCredentials, revokeGitHubToken, noteMemberLogins } from './relay-github.js'
58
+ import { mirrorManager, syncStateOf, runGit } from './relay-git.js'
59
+ import { leaseKeeper, LEASE_HEARTBEAT_MS } from './relay-lease.js'
60
+ import { bringInBranch, BACKSTOP_MS, ACTIVE_BACKSTOP_MS, syncStatus, markFolderBrought } from './relay-sync.js'
61
+ import { noteCiResult } from './relay-ci.js'
62
+ import { outboundFor, replayBundle, liveSyncOn, adoptBranch, receiveAgentPush } from './relay-outbound.js'
63
+ import { ensureView, syncViewRefs, serveGit, parseUpdates, viewRepoOf, viewDir } from './relay-githttp.js'
64
+ import { relayFileKey } from './relay-filekey.js'
65
+ import { RoomMeter, roomDiskBytes } from './relay-metrics.js'
66
+ import { clusterConfig, clusterProblems, Cluster, NOT_ROOMS, roomOfPath, replaySource, canReplay, displaceRoom, packRoom, unpackRoom, placeStaged, removeLocalRoom, MOVING, ELSEWHERE } from './relay-cluster.js'
67
+ import { joiningRoom, sessionArg } from './mcp-room.js'
68
+ import { clashAction, heldForClash, reviewClashes, noteMergeChecks, openOfflineClash } from './relay-clash.js'
69
+ import { AGENT_COMMITS } from './github-commit.js'
70
+ import { UPSTREAM_CHECK_MS, SYNC_EVERY_MS, SYNC_EVERY_GIT_MS, upstreamsOf, noteFolders, relayEntry, relayLooks, dueBranches, checkBranch, folderOn, connectedOn, hostedOn, validToken, folderAble, catcherAble, noteHolds, connectedAs, describeRelayCheck, loadBranch, parseRepo, configureGithubBudget, CLASH_IDLE_MS, noteConnection } from './relay-upstream.js'
71
+
72
+ const ROOM_RE = /^[A-Za-z0-9_-]{1,64}$/
73
+ const MAX_NAME = 64
74
+ // A hosted agent counts as online this long after its last tool call.
75
+ // Agents over HTTP have no connection to watch: each tool call is a check-in, good for this long.
76
+ export const HOSTED_ONLINE_MS = 30 * 60 * 1000
77
+ // An agent that works in turns (its machine is cleaned up after every run, so it connects for a turn and
78
+ // is gone between them) shows as away, not offline, for this long after its last turn.
79
+ export const AWAY_MS = 7 * 24 * 60 * 60 * 1000
80
+ // A room's renames: each one saves the room and queues a presence report, so they're rationed.
81
+ const RENAME_MS = 2000
82
+ const MAX_PATTERN = 500
83
+ // eslint-disable-next-line no-control-regex
84
+ const CONTROL_CHARS = /[\u0000-\u001f\u007f]/ // never in a claim: branch claims are keyed `<branch>\0<pattern>`
85
+ // A room's documents together (the room's and every branch's) may take this many times the size limit before new branches are refused.
86
+ const MAX_TOTAL_FACTOR = 10
87
+ const newEpoch = () => crypto.randomBytes(8).toString('hex')
88
+ // The file queue: requests for a claimed file (a title, and a summary of the plan), and handoffs.
89
+ const MAX_REQUEST_TITLE = 120
90
+ const MAX_REQUEST_TEXT = 300
91
+ const MAX_HANDOFF_TEXT = 2000
92
+ const MAX_QUEUE = 20
93
+ const MAX_SCOPES = 20
94
+ // Removed accounts remembered per room, so an older pass's grant can't bring them back.
95
+ const MAX_REMOVED = 200
96
+ const ROLES = ['editor', 'viewer']
97
+ const MB = 1024 * 1024
98
+ const DAY = 24 * 60 * 60 * 1000
99
+ const BOX_REMIND_MS = DAY // a box whose holder is away: one reminder a day (sweepBoxes)
100
+ // How often a branch's "last used" time (see Room.noteBranchSeen) is saved while it is busy.
101
+ const BRANCH_SEEN_SAVE_MS = 60 * 60 * 1000
102
+ // A message to the relay older than this when it reaches the relay gets no answer (answerRelay).
103
+ const RELAY_ASK_MS = 10 * 60 * 1000
104
+ const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..')
105
+ const LOGO = path.join(ROOT, 'assets', 'logo.svg')
106
+ const hash = (s) => crypto.createHash('sha256').update(String(s)).digest()
107
+ const sameSecret = (a, b) => a.length === b.length && crypto.timingSafeEqual(a, b)
108
+
109
+ /** Relay settings, from options or environment variables. */
110
+ // How often a room asks the accounts API what agents may commit (its org's or workspace's choice), besides being told.
111
+ const POLICY_EVERY_MS = 10 * 60 * 1000
112
+
113
+ export function relayConfig (opts = {}) {
114
+ const env = adoptLegacyEnv({ ...process.env })
115
+ const num = (v, d) => (v === undefined || v === '' || Number.isNaN(Number(v)) ? d : Number(v))
116
+ // The accounts API's public key. With it, every connection needs a pass (see passes.js).
117
+ const passPublicKey = opts.passPublicKey ?? env.QUILT_PASS_PUBLIC_KEY ?? ''
118
+ return {
119
+ passPublicKey,
120
+ // Presence reports for the dashboard (presence.js): only when both are set.
121
+ apiUrl: opts.apiUrl ?? env.QUILT_API_URL ?? '',
122
+ relayApiSecret: opts.relayApiSecret ?? env.RELAY_API_SECRET ?? '',
123
+ // With passes on, the accounts API decides who may start sessions: the relay key isn't used.
124
+ relayKey: passPublicKey ? '' : (opts.relayKey ?? env.QUILT_RELAY_KEY ?? ''),
125
+ // A room's whole history lives in memory while anyone is in it, and takes
126
+ // several times its stored size there: keep this well under the machine's memory.
127
+ maxRoomBytes: num(opts.maxRoomBytes ?? env.QUILT_MAX_ROOM_MB, 32) * (opts.maxRoomBytes !== undefined ? 1 : MB),
128
+ maxRoomFileBytes: num(opts.maxRoomFileBytes ?? env.QUILT_MAX_ROOM_FILES_MB, 2048) * (opts.maxRoomFileBytes !== undefined ? 1 : MB),
129
+ maxConnsPerIp: num(opts.maxConnsPerIp ?? env.QUILT_MAX_CONNS_PER_IP, 50),
130
+ maxNewRoomsPerHour: num(opts.maxNewRoomsPerHour ?? env.QUILT_MAX_NEW_ROOMS_PER_HOUR, 30),
131
+ roomTtlDays: num(opts.roomTtlDays ?? env.QUILT_ROOM_TTL_DAYS, 30),
132
+ idleUnloadMs: num(opts.idleUnloadMs, 60 * 1000),
133
+ // A branch document nobody is on leaves memory this long after its last use (the room may stay).
134
+ branchIdleMs: num(opts.branchIdleMs, BRANCH_IDLE_MS),
135
+ // How often the relay asks GitHub about a branch only hosted agents are working on (relay-upstream.js),
136
+ // and whether it does by itself (tests turn that off and ask with checkUpstreams).
137
+ upstreamCheckMs: num(opts.upstreamCheckMs, UPSTREAM_CHECK_MS),
138
+ upstreamAuto: opts.upstreamAuto !== false,
139
+ // A read-only GitHub token of the relay's own (a Fly secret, public repositories only), used when
140
+ // a session has none: GitHub allows 60 requests an hour per IP without one, 5000 with.
141
+ githubToken: opts.githubToken ?? env.QUILT_GITHUB_TOKEN ?? '',
142
+ // GitHub as a node (relay-sync.js): commits come in through the relay's git mirror, on by default; QUILT_GIT_SYNC=0 keeps the REST path.
143
+ gitSync: opts.gitSync ?? env.QUILT_GIT_SYNC !== '0',
144
+ gitRemote: typeof opts.gitRemote === 'function' ? opts.gitRemote : null, // tests: a repository slug -> where its git is
145
+ syncAuto: opts.syncAuto === true, // the relay's own commit timer: off; commits happen when someone asks for one (quilt_git_commit, a git push)
146
+ crashAt: typeof opts.crashAt === 'string' ? opts.crashAt : null, // tests: where to inject a crash (relay-sync.js crashPoint)
147
+ leaseTtlMs: num(opts.leaseTtlMs, undefined),
148
+ pushGapMs: num(opts.pushGapMs ?? env.QUILT_PUSH_GAP_MS, undefined),
149
+ pushesPerHour: num(opts.pushesPerHour, undefined),
150
+ activeBackstopMs: num(opts.activeBackstopMs, undefined),
151
+ backoffStartMs: num(opts.backoffStartMs, undefined),
152
+ backoffMaxMs: num(opts.backoffMaxMs, undefined),
153
+ leaseMarginMs: num(opts.leaseMarginMs, undefined),
154
+ leaseHeartbeatMs: num(opts.leaseHeartbeatMs, undefined),
155
+ atPoint: typeof opts.atPoint === 'function' ? opts.atPoint : null,
156
+ folderAheadWaitMs: num(opts.folderAheadWaitMs, undefined),
157
+ syncQuietMs: num(opts.syncQuietMs, undefined),
158
+ syncMaxMs: num(opts.syncMaxMs, undefined),
159
+ // How many GitHub API requests an hour the relay makes without a token, for every session together.
160
+ githubAnonPerHour: num(opts.githubAnonPerHour ?? env.QUILT_GITHUB_ANON_PER_HOUR, 50),
161
+ // A clash assignee with no progress this long is handed on to the next agent (relay-upstream.js).
162
+ clashIdleMs: num(opts.clashIdleMs, CLASH_IDLE_MS),
163
+ // Whether a write to a file in an open relay clash (or its task moved to QA or Done by its
164
+ // assignee) makes the relay look again at once; on with upstreamAuto unless set.
165
+ upstreamOnChange: opts.upstreamOnChange ?? opts.upstreamAuto !== false,
166
+ // A claim whose holder has done nothing in the session this long is let go (see sweepClaims):
167
+ // handed to the first one waiting in its file queue, or released.
168
+ claimIdleMs: num(opts.claimIdleMs, 20 * 60 * 1000),
169
+ trustProxy: opts.trustProxy ?? /^(1|true|yes)$/i.test(env.QUILT_TRUST_PROXY || ''),
170
+ // Large files: Supabase Storage when both are set, otherwise the relay's own disk.
171
+ storageUrl: opts.storageUrl ?? env.QUILT_STORAGE_URL ?? '',
172
+ storageKey: opts.storageKey ?? env.QUILT_STORAGE_KEY ?? '',
173
+ storageBucket: opts.storageBucket ?? env.QUILT_STORAGE_BUCKET ?? 'session-files',
174
+ maxStoredFileBytes: num(opts.maxStoredFileBytes ?? env.QUILT_MAX_STORED_FILE_MB, 100) * (opts.maxStoredFileBytes !== undefined ? 1 : MB),
175
+ // The clock for each room's traffic meter (relay-metrics.js); tests move it.
176
+ now: typeof opts.now === 'function' ? opts.now : Date.now
177
+ }
178
+ }
179
+
180
+ /** The files a transaction changed in a branch document (entries in files or blobs, or text inside one). */
181
+ function changedPaths (tr, e) {
182
+ const out = new Set()
183
+ for (const [type, events] of tr.changedParentTypes) {
184
+ if (type !== e.files && type !== e.blobs) continue
185
+ for (const ev of events) {
186
+ if (ev.target === type) { for (const k of ev.changes.keys.keys()) out.add(k); continue }
187
+ let t = ev.target
188
+ while (t && t._item && t._item.parent !== type) t = t._item.parent
189
+ if (t && t._item && t._item.parentSub) out.add(t._item.parentSub)
190
+ }
191
+ }
192
+ return out
193
+ }
194
+
195
+ class Room {
196
+ constructor (name, dataDir, cfg, log) {
197
+ this.name = name
198
+ this.cfg = cfg
199
+ this.log = log
200
+ this.doc = new Y.Doc()
201
+ this.conns = new Map() // ws -> Set<awareness clientID>
202
+ this.names = new Map() // ws -> verified name
203
+ this.docFile = dataDir && path.join(dataDir, `${name}.ydoc`)
204
+ this.metaFile = dataDir && path.join(dataDir, `${name}.json`)
205
+ this.meta = {}
206
+ this.bytes = 0
207
+ // Messages and bytes in and out per minute, for /stats and placement (relay-metrics.js).
208
+ this.meter = new RoomMeter({ now: cfg.now || Date.now })
209
+ this.full = false
210
+ this.unsavable = false // a disk error stopped a save: the room is read-only (see diskError)
211
+ if (dataDir) {
212
+ // A room whose files can't be read (cut off by a crash, or a disk error) is refused,
213
+ // not treated as new: that would overwrite it, or let the sweep delete it.
214
+ try {
215
+ if (fs.existsSync(this.docFile)) {
216
+ const buf = fs.readFileSync(this.docFile)
217
+ Y.applyUpdate(this.doc, buf)
218
+ this.bytes = buf.length
219
+ }
220
+ if (fs.existsSync(this.metaFile)) this.meta = JSON.parse(fs.readFileSync(this.metaFile, 'utf8'))
221
+ } catch (err) {
222
+ this.doc.destroy()
223
+ throw Object.assign(new Error(`could not read the session's data: ${err.message}`), { unreadable: true })
224
+ }
225
+ }
226
+ this.meta.identities = this.meta.identities || {} // name -> public key
227
+ this.meta.claims = this.meta.claims || {} // pattern -> { by, byId?, pattern, note, ts }; byId is the account, with sign-in on
228
+ // Each branch's files live in a document of their own (branchdocs.js); the session's
229
+ // branches are key -> { by, at, base, seen, stored }, and the default is the first one joined.
230
+ this.meta.branches = this.meta.branches || {}
231
+ // With the mirror on, every synced branch is fetched again as soon as someone is on it: a webhook may have been missed while the relay was down (plan §5.1).
232
+ if (cfg.gitSync && this.meta.upstreams && typeof this.meta.upstreams === 'object') for (const rec of Object.values(this.meta.upstreams)) if (rec && rec.repo) { rec.checkedAt = 0; delete rec.webhookAt }
233
+ // CI results the relay hadn't noted when it last stopped (noteChecks): noted now, in the order they came.
234
+ if (Array.isArray(this.meta.ciPending) && this.meta.ciPending.length) setTimeout(() => { if (!this.destroyed) for (const ev of this.meta.ciPending.slice()) this.runCi(ev) }, 500).unref?.()
235
+ this.store = new BranchStore({
236
+ dir: dataDir && path.join(dataDir, 'branches', name),
237
+ idleMs: cfg.branchIdleMs,
238
+ onLoad: (e) => this.wireBranch(e),
239
+ onSave: (e) => this.noteBranchSaved(e),
240
+ onDiskError: (err) => this.diskError(err)
241
+ })
242
+ // A room saved before branch documents: its one document becomes the default branch's.
243
+ this.migrateLegacy()
244
+ this.pruneBranches()
245
+ this.awareness = new awarenessProtocol.Awareness(this.doc)
246
+ this.awareness.setLocalState(null)
247
+ // Claim holder (see holderKey) -> when they last did something in the session (changed the
248
+ // document: a file, a message, their AI's feed; or used a hosted tool), for sweepClaims.
249
+ this.meta.seen = this.meta.seen || {}
250
+ this.loadedAt = Date.now()
251
+ // Member id -> { name, kind, role, scopes, since }. The id is the public key, or
252
+ // '<kind>:<sub>' for members approved with a pass (an account, on any computer).
253
+ this.meta.members = this.meta.members || {}
254
+ this.access = new Map() // ws -> { key, id, name, kind, role, scopes, owner } for people in the room
255
+ // ws -> { key, id, name, kind, invitedAs, since } waiting for the owner. A hosted agent (one
256
+ // that only talks to the relay over HTTP, see relay-mcp.js) waits under a { hosted: id } stand-in.
257
+ this.pending = new Map()
258
+ this.hostedSeen = new Map() // member id -> when a hosted agent last called a tool
259
+ this.hostedExpiry = new Map() // member id -> the timer that shows it offline when its check-in runs out
260
+ this.files = this.doc.getMap('files')
261
+ this.blobs = this.doc.getMap('blobs')
262
+ this.fileKeys = this.doc.getMap('fileKeys')
263
+ this.chat = this.doc.getArray('chat')
264
+ // Every message, kept past the room's newest 500, for hosted agents' conversations (chat-archive.js).
265
+ this.chatArchive = new ChatArchive(dataDir && path.join(dataDir, `${name}.chat.jsonl`), { log: (m) => log(`[${name}] ${m}`) })
266
+ this.chatArchive.add(this.chat.toArray())
267
+ this.chat.observe((ev) => { if (ev.changes.added.size) this.chatArchive.add(this.chat.toArray()) })
268
+ // A message to the relay (@Quilt, or direct) gets its answer (relay-ask.js).
269
+ this.relayAnswered = new Set()
270
+ this.chat.observe((ev) => {
271
+ for (const item of ev.changes.added) for (const m of item.content.getContent()) if (asksRelay(m)) this.answerRelay(m, ev.transaction.origin)
272
+ })
273
+ this.feed = this.doc.getArray('agentFeed')
274
+ this.activity = this.doc.getArray('activity') // { by, path, kind, detail, ts }
275
+ this.commitRequests = this.doc.getMap('commitRequests') // id -> { id, by, message, ts, state, ... }
276
+ this.published = this.doc.getMap('published') // path -> [content ids] the relay committed to GitHub (upstream.js)
277
+ this.taskComments = this.doc.getMap('taskComments') // task id -> [{ id, by, text, ts }] (task-comments.js)
278
+ // Undoes changes from people who may not make them: file changes from viewers and from
279
+ // people outside their folders, and posts from people who may not post (chat, the feed,
280
+ // task comments, words of their own in the activity log, commit requests). Only their connections are tracked.
281
+ // Branch documents get a guard each (wireBranch) sharing these tracked connections. The room
282
+ // document's files maps are only in rooms from before branch documents.
283
+ this.guard = new Y.UndoManager([this.files, this.blobs, this.fileKeys, this.chat, this.feed, this.taskComments, this.activity, this.commitRequests, this.published], { trackedOrigins: new Set(), captureTimeout: 0 })
284
+ // What checkChange works on: the room document's parts, its guard and who hears its updates.
285
+ this.roomParts = {
286
+ guard: this.guard, recorded: null, undoing: null,
287
+ files: this.files, blobs: this.blobs, fileKeys: this.fileKeys, chat: this.chat, feed: this.feed, taskComments: this.taskComments, activity: this.activity, commitRequests: this.commitRequests, published: this.published,
288
+ listeners: () => this.conns.keys(), frame: () => ROOM_DOC
289
+ }
290
+ this.guard.on('stack-item-added', ({ stackItem, type }) => { if (type === 'undo') this.roomParts.recorded = stackItem })
291
+ // What a tracked change added to the activity log, read before Yjs merges the new entries
292
+ // into older ones (after which a change event can no longer tell them apart).
293
+ this.activityAdded = new WeakMap()
294
+ this.tasks = this.doc.getMap('tasks')
295
+ // The audit trail: what a connection with a visit changed (files, chat, the board), by path or task id.
296
+ this.auditAs = null // a hosted agent whose tool is running now (relay-mcp.js): its changes are its
297
+ this.doc.on('afterTransaction', (tr) => {
298
+ if (!this.presence) return
299
+ const visit = tr.origin && tr.origin.visit ? tr.origin.visit : (this.auditAs && this.hostedVisit ? this.hostedVisit(this.auditAs) : null)
300
+ if (visit) this.auditChange(tr, visit)
301
+ })
302
+ this.doc.on('afterTransaction', (tr) => {
303
+ const events = tr.changedParentTypes.get(this.activity)
304
+ if (!events || !this.guard.trackedOrigins.has(tr.origin)) return
305
+ this.activityAdded.set(tr, events.flatMap((e) => [...e.changes.added].flatMap((item) => item.content.getContent())))
306
+ })
307
+ // The room's own document against the size limit; each branch document has a limit of its
308
+ // own (branchFull). A disk error (diskError, possibly from migrateLegacy above) keeps it read-only.
309
+ this.full = this.unsavable || this.bytes > cfg.maxRoomBytes
310
+ this.saveTimer = null
311
+ this.unloadTimer = null
312
+ this.presence = null // a PresenceReporter when the relay reports presence (set by startServer)
313
+ this.lastRenameAt = 0 // when the owner last renamed the session (RENAME_MS)
314
+
315
+ this.doc.on('update', (update, origin, doc, tr) => {
316
+ if (origin === this.guard && this.roomParts.undoing) { this.roomParts.undoing.push(update); return } // sent merged, below
317
+ if (origin && origin !== this.guard && this.guard.trackedOrigins.has(origin) && !this.checkChange(origin, update, tr)) return
318
+ if (origin && this.conns.has(origin)) this.noteActivity(this.holderKeys(origin))
319
+ const msg = updateMessage(update)
320
+ for (const ws of this.conns.keys()) if (ws !== origin) send(ws, msg)
321
+ this.bytes += update.length
322
+ if (!this.full && this.bytes > cfg.maxRoomBytes) {
323
+ this.full = true
324
+ this.log(`[${name}] over the size limit; further edits are refused`)
325
+ }
326
+ this.scheduleSave()
327
+ })
328
+ this.awareness.on('update', ({ added, updated, removed }, origin) => {
329
+ const changed = added.concat(updated, removed)
330
+ if (origin && this.conns.has(origin)) {
331
+ const ids = this.conns.get(origin)
332
+ // Updated too: a client back from a drop speaks for the same id over a new connection.
333
+ for (const id of added.concat(updated)) ids.add(id)
334
+ for (const id of removed) ids.delete(id)
335
+ }
336
+ const msg = awarenessMessage(this.awareness, changed)
337
+ for (const ws of this.conns.keys()) send(ws, msg)
338
+ // Which upstream each branch follows, and where its folders are (relay-upstream.js).
339
+ if (origin && this.conns.has(origin)) this.noteUpstreams()
340
+ })
341
+ this.githubFetch = globalThis.fetch // set by startServer (tests give their own: never the network)
342
+ this.upstreamRun = null // the relay's look at GitHub under way (checkUpstreams)
343
+ this.syncAsked = new Map() // branch -> when quilt_sync_branch last made the relay ask GitHub (syncUpstreamNow)
344
+ this.upstreamSoonKeys = new Set() // branches to look at again at once (upstreamSoon)
345
+ this.lookedFor = new WeakSet() // connections whose arrival made their branch due a look (reconnect reconciliation)
346
+ // A relay clash's task moved to QA or Done by its assignee: the files count as merged (relay-upstream.js).
347
+ this.tasks.observe((ev, tr) => {
348
+ for (const [id, c] of ev.changes.keys) {
349
+ const t = this.tasks.get(id)
350
+ // A clash task done or gone: its files are let go of, whoever held them for it (online or not).
351
+ if (!t || t.column === 'done') this.dropClashClaims(id)
352
+ if (tr.origin === 'relay-upstream') continue
353
+ // Moved (or moved again, after someone else did) to QA or Done: whose move it was decides.
354
+ if (c.action !== 'update' || !t || (t.column !== 'qa' && t.column !== 'done')) continue
355
+ this.noteClashMoved(id, t, tr.origin)
356
+ }
357
+ })
358
+ }
359
+
360
+ get exists () { return !!this.meta.secretHash }
361
+
362
+ /** Rooms made by newer clients have an owner who approves people. Older rooms let everyone edit. */
363
+ get controlled () { return !!this.meta.viewSecretHash }
364
+
365
+ /**
366
+ * First client to open a room sets its secrets; later clients must match one.
367
+ * Returns 'editor' or 'viewer' (what the secret invites you as), 'bad-secret'
368
+ * or 'need-key' (creating rooms needs the relay key). `publicKey` is the
369
+ * creator's: in a controlled room, only they can become its owner.
370
+ */
371
+ authorize (secret, key, viewSecret = '', publicKey = '') {
372
+ if (!this.meta.secretHash) {
373
+ if (this.cfg.relayKey && !sameSecret(hash(key || ''), hash(this.cfg.relayKey))) return 'need-key'
374
+ this.meta.secretHash = hash(secret || '').toString('hex')
375
+ if (viewSecret) this.meta.viewSecretHash = hash(viewSecret).toString('hex')
376
+ if (publicKey) this.meta.creator = publicKey
377
+ this.meta.createdAt = Date.now()
378
+ this.touch()
379
+ return 'editor'
380
+ }
381
+ if (sameSecret(hash(secret || ''), Buffer.from(this.meta.secretHash, 'hex'))) return 'editor'
382
+ if (this.controlled && sameSecret(hash(secret || ''), Buffer.from(this.meta.viewSecretHash, 'hex'))) return 'viewer'
383
+ return 'bad-secret'
384
+ }
385
+
386
+ /**
387
+ * Where someone stands when they sign in: let in now (with a role), or wait
388
+ * for the owner. `account` is '<kind>:<sub>' from their pass when sign-in is
389
+ * on: then they are their account, on any computer, not their key.
390
+ */
391
+ accessFor (key, name, kind, invitedAs, account = '', pass = null) {
392
+ if (!this.controlled) return { state: 'approved', role: 'editor', scopes: [], owner: false }
393
+ if (!this.meta.owner) {
394
+ // The room's creator becomes its owner when they sign in, and nobody else, however much
395
+ // earlier they get here: until then invitees wait. (Rooms made before creators were
396
+ // recorded, with nobody signed in yet, go to the first person invited to edit.)
397
+ const creator = this.meta.creator
398
+ if (creator ? key === creator : invitedAs === 'editor') { this.meta.owner = key; this.saveMeta() }
399
+ }
400
+ if (account) this.noteKey(account, key)
401
+ if (this.isOwner(key, account)) {
402
+ if (account) {
403
+ // Remember the owner's account, so they're the owner on any computer.
404
+ // Names come from accounts and can change: show the owner by the name they use now.
405
+ if (this.meta.ownerSub !== account || this.meta.ownerName !== name) { this.meta.ownerSub = account; this.meta.ownerName = name; this.saveMeta() }
406
+ }
407
+ return { state: 'approved', role: 'editor', scopes: [], owner: true }
408
+ }
409
+ // A room pass with a grant (from the accounts API) lets them straight in, with its access.
410
+ const granted = this.passGrant(pass)
411
+ if (granted) {
412
+ this.noteGranted(account, name, kind, granted, cleanFrom(pass && pass.from))
413
+ return { state: 'approved', ...relayAccess(granted), owner: false, id: account }
414
+ }
415
+ // Members approved with a pass are kept under their account; older ones under their key.
416
+ const id = account && this.meta.members[account] ? account : key
417
+ const m = this.storedMember(id, pass)
418
+ if (m) {
419
+ if (m.name !== name || m.kind !== kind) { m.name = name; m.kind = kind; this.saveMeta() }
420
+ return { state: 'approved', ...memberAccess(m), owner: false, id }
421
+ }
422
+ // A person's cloud AI stands where its person stands (never as the owner).
423
+ const standing = this.cloudAiStanding(pass)
424
+ if (standing) return { state: 'approved', ...standing, owner: false, id: account }
425
+ return { state: 'pending', invitedAs }
426
+ }
427
+
428
+ /**
429
+ * A person's cloud AI (routes/ai-vm.js on the API: its pass names its person in `of`) comes in
430
+ * with its person's standing here: the owner's AI edits, a member's AI has the member's access.
431
+ * Null when its person would wait (or its pass isn't a cloud AI's).
432
+ */
433
+ cloudAiStanding (pass) {
434
+ if (!pass || pass.kind !== 'agent' || !/^person:[A-Za-z0-9_-]{1,64}$/.test(String(pass.of || ''))) return null
435
+ if (this.meta.ownerSub === pass.of) return { role: 'editor', scopes: [], scopesExcept: [], talk: true }
436
+ const m = this.storedMember(pass.of, pass)
437
+ return m ? memberAccess(m) : null
438
+ }
439
+
440
+ /**
441
+ * What a pass for this room lets its holder do here (the grant the accounts API signed
442
+ * into it), or null: no pass for this room, no grant, or the owner removed them after it
443
+ * was issued. An owner's narrowing made after it was issued still applies.
444
+ */
445
+ passGrant (pass) {
446
+ if (!pass || pass.room !== this.name) return null
447
+ const access = cleanAccess(pass.access)
448
+ if (!access) return null
449
+ const account = `${pass.kind}:${pass.sub}`
450
+ const issued = typeof pass.iat === 'number' ? pass.iat : pass.exp - PASS_TTL_MS
451
+ const removedAt = (this.meta.removed || {})[account]
452
+ if (removedAt && issued <= removedAt) return null
453
+ const m = this.meta.members[account]
454
+ if (m && m.setAt && issued <= m.setAt) return narrowAccess(access, fromRelay(m))
455
+ return access
456
+ }
457
+
458
+ /**
459
+ * True when this pass was issued no later than the owner's last change to its holder,
460
+ * and that change cut what it grants. A pass from the same millisecond as the change
461
+ * (or from a clock a little behind) may already carry it, but the relay can't tell, so
462
+ * the app is asked for a newer one: otherwise more access waits for the next refresh.
463
+ */
464
+ passPredatesChange (pass) {
465
+ if (!pass || pass.room !== this.name) return false
466
+ const access = cleanAccess(pass.access)
467
+ const m = this.meta.members[`${pass.kind}:${pass.sub}`]
468
+ if (!access || !m || !m.setAt) return false
469
+ const issued = typeof pass.iat === 'number' ? pass.iat : pass.exp - PASS_TTL_MS
470
+ return issued <= m.setAt && !sameAccess(access, narrowAccess(access, fromRelay(m)))
471
+ }
472
+
473
+ /**
474
+ * The member record that may let this pass in, or null. Someone let in as a type (by a
475
+ * grant, or by the owner approving them as one) has their access in the API: only a pass
476
+ * for this room speaks for it, so without one their stored record lets nobody in, or an
477
+ * access type narrowed or deleted on the API would never reach the relay. Members from
478
+ * before access types keep coming in on their record.
479
+ */
480
+ storedMember (id, pass) {
481
+ const m = this.meta.members[id]
482
+ if (!m) return null
483
+ return m.granted && !(pass && pass.room === this.name) ? null : m
484
+ }
485
+
486
+ /** Keeps someone let in by their pass's grant on the member list, with that access and where it comes from (`from`, see cleanFrom). */
487
+ noteGranted (id, name, kind, access, from = null) {
488
+ const m = this.meta.members[id]
489
+ if (m && m.name === name && m.kind === kind && sameAccess(fromRelay(m), access) && JSON.stringify(m.from || null) === JSON.stringify(from)) return
490
+ this.meta.members[id] = { ...(m || {}), name, kind, ...relayAccess(access), since: m?.since || Date.now(), granted: true, ...(from ? { from } : {}) }
491
+ if (!from) delete this.meta.members[id].from
492
+ this.saveMeta()
493
+ }
494
+
495
+ /**
496
+ * A fresh pass arrived (see renewPass): its grant applies now. Someone waiting is let in,
497
+ * and someone in the room gets the new access, both without the owner.
498
+ */
499
+ passRenewed (ws) {
500
+ const granted = this.passGrant(ws.pass)
501
+ if (!granted) return
502
+ const id = `${ws.pass.kind}:${ws.pass.sub}`
503
+ if (this.meta.ownerSub === id) return
504
+ const waiting = this.pending.get(ws)
505
+ if (waiting) {
506
+ this.pending.delete(ws)
507
+ this.noteGranted(id, waiting.name, waiting.kind, granted, cleanFrom(ws.pass.from))
508
+ this.log(`[${this.name}] ${waiting.name} was let in by their invite`)
509
+ this.enter(ws, { key: waiting.key, id, name: waiting.name, kind: waiting.kind, ...relayAccess(granted), owner: false })
510
+ return
511
+ }
512
+ const a = this.access.get(ws)
513
+ if (!a || a.owner) return
514
+ const refresh = this.passPredatesChange(ws.pass) ? { refresh: true } : {}
515
+ if (sameAccess(fromRelay(a), granted)) {
516
+ if (refresh.refresh) send(ws, jsonMessage(MSG_ACCESS, { ...this.accessMessage(a), ...refresh }))
517
+ return
518
+ }
519
+ this.noteGranted(id, a.name, a.kind, granted, cleanFrom(ws.pass.from))
520
+ Object.assign(a, relayAccess(granted))
521
+ this.setAccess(ws, a)
522
+ send(ws, jsonMessage(MSG_ACCESS, { ...this.accessMessage(a), ...refresh }))
523
+ this.broadcastMembers()
524
+ }
525
+
526
+ /**
527
+ * Remembers which computer keys an account has used here, so removing the
528
+ * account also removes members approved by one of those keys.
529
+ */
530
+ noteKey (account, key) {
531
+ const keys = (this.meta.accountKeys = this.meta.accountKeys || {})
532
+ const seen = keys[account] || []
533
+ if (seen.includes(key)) return
534
+ keys[account] = [...seen, key].slice(-20)
535
+ this.saveMeta()
536
+ }
537
+
538
+ /** The owner is their account once it's known, and until then the key that made the room. */
539
+ isOwner (key, account = '') {
540
+ if (account && this.meta.ownerSub) return this.meta.ownerSub === account
541
+ return this.meta.owner === key
542
+ }
543
+
544
+ /**
545
+ * With sign-in on: may this pass use the session's files over HTTP? In a session with an
546
+ * owner, only the owner and the people they let in may (by account, or an older member's key).
547
+ * Unlike accessFor, it changes nothing.
548
+ */
549
+ letIn (pass) {
550
+ if (!this.controlled) return true
551
+ const account = `${pass.kind}:${pass.sub}`
552
+ if (this.meta.owner && this.isOwner(pass.key, account)) return true
553
+ if (this.passGrant(pass)) return true
554
+ return !!(this.storedMember(account, pass) || this.storedMember(pass.key, pass))
555
+ }
556
+
557
+ /** With sign-in on: the relay access a pass has here over HTTP (null for the owner, or nobody). */
558
+ httpAccess (pass) {
559
+ const account = `${pass.kind}:${pass.sub}`
560
+ if (!this.controlled || (this.meta.owner && this.isOwner(pass.key, account))) return null
561
+ const granted = this.passGrant(pass)
562
+ if (granted) return relayAccess(granted)
563
+ const m = this.storedMember(account, pass) || this.storedMember(pass.key, pass)
564
+ return m ? memberAccess(m) : null
565
+ }
566
+
567
+ /** The id the member list shows the owner under. */
568
+ get ownerId () { return this.meta.ownerSub || this.meta.owner }
569
+
570
+ /**
571
+ * Where a hosted agent (HTTP only, no key) stands: approved with its role, or waiting for
572
+ * the owner. Waiting puts it on the owner's list like a connection would. It never becomes
573
+ * the owner: a room with no owner yet keeps it waiting.
574
+ */
575
+ hostedRequest (pass, invitedAs) {
576
+ const id = `${pass.kind}:${pass.sub}`
577
+ const a = this.hostedAccess(pass)
578
+ if (a.state === 'approved') return a
579
+ if (!this.controlled) return { state: 'approved', role: 'editor', scopes: [], owner: false, id }
580
+ const waiting = [...this.pending].find(([k, p]) => k.hosted && p.id === id)
581
+ if (waiting) { waiting[1].since = Date.now(); waiting[1].invitedAs = invitedAs; return { state: 'pending', invitedAs } }
582
+ this.pending.set({ hosted: id }, { key: '', id, name: pass.name, kind: pass.kind === 'agent' ? 'agent' : 'human', invitedAs, since: Date.now() })
583
+ this.log(`[${this.name}] ${pass.name} (hosted) is waiting to be let in`)
584
+ this.broadcastMembers()
585
+ return { state: 'pending', invitedAs }
586
+ }
587
+
588
+ /**
589
+ * Puts a hosted agent back on the waiting list after a relay restart (the list lives in
590
+ * memory; relay.hosted remembers who was waiting). Skips anyone already let in.
591
+ */
592
+ restoreHosted (id, { name, kind, invitedAs }) {
593
+ if (!this.controlled || !name) return
594
+ if (this.meta.members[id] || id === this.meta.owner || id === this.meta.ownerSub) return
595
+ if ([...this.pending].some(([k, p]) => k.hosted && p.id === id)) return
596
+ this.pending.set({ hosted: id }, { key: '', id, name, kind: kind === 'agent' ? 'agent' : 'human', invitedAs: invitedAs || 'viewer', since: Date.now() })
597
+ this.broadcastMembers()
598
+ }
599
+
600
+ /**
601
+ * A hosted agent's current standing: approved (with its access) or pending. A room pass
602
+ * with a grant approves it, and puts it on the member list.
603
+ */
604
+ hostedAccess (pass) {
605
+ const id = `${pass.kind}:${pass.sub}`
606
+ if (!this.controlled) return { state: 'approved', role: 'editor', scopes: [], owner: false, id }
607
+ if (this.meta.owner && this.isOwner(pass.key || '', id)) return { state: 'approved', role: 'editor', scopes: [], owner: true, id }
608
+ const granted = this.passGrant(pass)
609
+ if (granted) {
610
+ this.noteGranted(id, pass.name, pass.kind === 'agent' ? 'agent' : 'human', granted, cleanFrom(pass.from))
611
+ return { state: 'approved', ...relayAccess(granted), owner: false, id }
612
+ }
613
+ const m = this.storedMember(id, pass)
614
+ if (m) {
615
+ if (m.name !== pass.name) { m.name = pass.name; this.saveMeta() }
616
+ return { state: 'approved', ...memberAccess(m), owner: false, id }
617
+ }
618
+ // Let in as a type, but this pass isn't for this room (the accounts API didn't know which
619
+ // room the agent is in, after a restart): a pass for this room will let it in.
620
+ if (this.meta.members[id]?.granted) return { state: 'pending', id, needsRoomPass: true }
621
+ return { state: 'pending', id }
622
+ }
623
+
624
+ /** What a connection's change did, for the audit trail: files changed, messages posted, tasks touched. */
625
+ auditChange (tr, visit) {
626
+ const act = (action, target) => this.presence.act(visit, action, target)
627
+ for (const e of tr.changedParentTypes.get(this.activity) || []) {
628
+ for (const item of e.changes.added) {
629
+ for (const x of item.content.getContent()) if (x && ACTIVITY_KINDS.includes(x.kind) && x.path) act(x.kind, String(x.path))
630
+ }
631
+ }
632
+ for (const e of tr.changedParentTypes.get(this.chat) || []) {
633
+ for (const item of e.changes.added) for (const m of item.content.getContent()) if (m && typeof m === 'object') act('messaged', m.to ? `to ${m.to}` : '')
634
+ }
635
+ const tasks = new Set()
636
+ for (const e of tr.changedParentTypes.get(this.tasks) || []) {
637
+ if (e.target === this.tasks) for (const k of e.keysChanged || []) tasks.add(k)
638
+ else if (e.path.length) tasks.add(String(e.path[0]))
639
+ }
640
+ // A comment on a task is work on that task.
641
+ for (const e of tr.changedParentTypes.get(this.taskComments) || []) {
642
+ if (e.target === this.taskComments) for (const k of e.keysChanged || []) if (this.taskComments.has(k)) tasks.add(k)
643
+ }
644
+ for (const id of tasks) act('task', id)
645
+ }
646
+
647
+ /** The audit trail, for a member by id: the visit of their connection here, or of the hosted agent. */
648
+ audit (id, action, target) {
649
+ if (!this.presence || !id) return
650
+ for (const ws of this.conns.keys()) {
651
+ if (ws.visit && ws.pass && `${ws.pass.kind}:${ws.pass.sub}` === id) return this.presence.act(ws.visit, action, target)
652
+ }
653
+ const v = this.hostedVisit && this.hostedVisit(id)
654
+ if (v) this.presence.act(v, action, target)
655
+ }
656
+
657
+ /** A hosted agent just used a tool: it shows as online for a while, and stops waiting. */
658
+ hostedActive (id) {
659
+ const was = this.hostedSeen.get(id) || 0
660
+ this.hostedSeen.set(id, Date.now())
661
+ this.noteHostedUpstream(id)
662
+ this.noteActivity([id])
663
+ for (const [k, p] of this.pending) if (k.hosted && p.id === id) this.pending.delete(k)
664
+ // Newly online, or a newer check-in time: everyone's member list shows it (once a minute at most).
665
+ if (Date.now() - was >= 60 * 1000) this.broadcastMembers()
666
+ // And shows it gone when this check-in runs out with no other.
667
+ clearTimeout(this.hostedExpiry.get(id))
668
+ const t = setTimeout(() => {
669
+ this.hostedExpiry.delete(id)
670
+ if (!this.hostedOnline().some((h) => h.id === id)) this.broadcastMembers()
671
+ }, HOSTED_ONLINE_MS + 1000)
672
+ t.unref?.()
673
+ this.hostedExpiry.set(id, t)
674
+ }
675
+
676
+ /** Hosted agents active in the last few minutes, for status and presence. */
677
+ hostedOnline () {
678
+ const cutoff = Date.now() - HOSTED_ONLINE_MS
679
+ const out = []
680
+ for (const [id, ts] of this.hostedSeen) {
681
+ if (ts < cutoff) { this.hostedSeen.delete(id); continue }
682
+ const m = this.meta.members[id]
683
+ if (m) out.push({ id, name: m.name, kind: m.kind, role: m.role, seen: ts })
684
+ }
685
+ return out
686
+ }
687
+
688
+ /** Tells every connection the claims changed (a hosted agent claimed or released). */
689
+ broadcastClaims () {
690
+ this.saveMeta()
691
+ for (const other of this.conns.keys()) send(other, jsonMessage(MSG_CLAIMS, { claims: this.claimList(other.branch) }))
692
+ this.noteClaimsChanged()
693
+ }
694
+
695
+ /** A claim the relay's bring-in waits on (rec.waitingOn) was let go of, handed on or swept: that branch is looked at again at once. */
696
+ noteClaimsChanged () {
697
+ if (this.cfg.gitSync && this.mirrors) for (const key of Object.keys(this.meta.sync || {})) if (Object.keys((this.meta.sync[key] || {}).pending || {}).length) this.outbound.schedule(key)
698
+ for (const [key, rec] of Object.entries(this.meta.upstreams || {})) {
699
+ const w = rec && rec.waitingOn
700
+ if (!w) continue
701
+ const c = this.meta.branches[key] ? this.claimFor(w.path, key) : null
702
+ if (c && c.by === w.by && c.pattern === w.pattern) continue
703
+ delete rec.waitingOn
704
+ this.upstreamSoon(key)
705
+ }
706
+ // A file held for a claim that is gone now (its note above may have been cleared by a later look): looked at again.
707
+ if (this.cfg.gitSync && this.mirrors) {
708
+ for (const [key, st] of Object.entries(this.meta.sync || {})) {
709
+ if (!st || !this.meta.branches[key]) continue
710
+ if ((st.held || []).some((h) => h.why === 'claimed' && !this.claimFor(h.path, key))) this.upstreamSoon(key)
711
+ }
712
+ }
713
+ }
714
+
715
+ /** May this connection change this file? */
716
+ mayWrite (a, rel) { return mayChange(a, rel) }
717
+
718
+ /** Whether a change by this connection to rel would stand (checkChange), so a box on it would hold their work. */
719
+ mayBox (ws, rel) {
720
+ const a = this.access.get(ws)
721
+ rel = String(rel ?? '').trim().replace(/^\.\//, '')
722
+ if (a && !this.mayWrite(a, rel)) return false
723
+ return !(a && a.kind === 'agent' && this.cfg.gitSync && ws.branch && heldForClash(this, this.resolveKey(ws.branch), rel))
724
+ }
725
+
726
+ /**
727
+ * A restricted member (viewer, or agent limited to folders) changed a document: the room's
728
+ * (`d` = roomParts) or a branch's (`d` = its entry). Returns true to pass it on, or false
729
+ * after scheduling an undo of what they may not change. The undo runs once the guard has
730
+ * recorded the change, whichever order Yjs fires its events in.
731
+ */
732
+ checkChange (ws, update, tr, d = this.roomParts) {
733
+ const a = this.access.get(ws)
734
+ const touched = new Map() // path -> the types (files, blobs) it changed in
735
+ const refused = []
736
+ const posts = [] // chat and the feed, for people who may not post
737
+ // Only the parts of the change that were refused are undone: one update can carry
738
+ // allowed and refused changes together (an edit and a post in one transaction).
739
+ const undo = new Set()
740
+ for (const [type, events] of tr.changedParentTypes) {
741
+ if (type === d.chat || type === d.feed || type === d.taskComments) {
742
+ if (a?.talk === false) { posts.push(type === d.chat ? 'chat' : type === d.feed ? 'the feed' : 'a task comment'); undo.add(type) }
743
+ continue
744
+ }
745
+ // What the relay published is the relay's record alone: a viewer or a member limited to folders may not change it.
746
+ if (type === d.published) { refused.push('the record of published files'); undo.add(type); continue }
747
+ if (type === d.commitRequests) {
748
+ // Asking for a commit is a message to the host; marking one done (same message) isn't.
749
+ if (a?.talk !== false) continue
750
+ for (const e of events) {
751
+ for (const [id, c] of e.changes.keys) {
752
+ const now = type.get(id)
753
+ if (c.action === 'add' || (c.action === 'update' && now?.message !== c.oldValue?.message)) { posts.push('a commit request'); undo.add(type); break }
754
+ }
755
+ }
756
+ continue
757
+ }
758
+ // Keys wrapped for the relay (plan §10) follow the same rule: added, never changed or removed, by anyone held to a folder.
759
+ if (type === d.fileKeys || (d.relayKeys && type === d.relayKeys)) {
760
+ // Keys to stored files: viewers may not touch them, and others may
761
+ // only add new ones, so nobody can lock people out of stored files.
762
+ for (const e of events) {
763
+ if (e.target !== type) { refused.push('a file key'); undo.add(type); continue }
764
+ for (const [id, c] of e.changes.keys) if (a?.role === 'viewer' || c.action !== 'add') { refused.push(`file key ${id}`); undo.add(type) }
765
+ }
766
+ continue
767
+ }
768
+ if (type !== d.files && type !== d.blobs) continue
769
+ const touch = (rel) => touched.set(rel, [...(touched.get(rel) || []), type])
770
+ for (const e of events) {
771
+ if (e.target === type) for (const k of e.changes.keys.keys()) touch(k)
772
+ else {
773
+ // A change inside a file's text: walk up to the entry in files.
774
+ let t = e.target
775
+ while (t && t._item && t._item.parent !== type) t = t._item.parent
776
+ if (t && t._item && t._item.parentSub) touch(t._item.parentSub)
777
+ }
778
+ }
779
+ }
780
+ for (const [rel, types] of touched) {
781
+ // A file held for a clash with GitHub (relay-clash.js): agents propose the merge with quilt_clash; only people edit it in place.
782
+ if (d.key && a && a.kind === 'agent' && this.cfg.gitSync && heldForClash(this, d.key, rel)) { refused.push(`${rel} (held for a clash: propose with quilt_clash)`); for (const t of types) undo.add(t); continue }
783
+ if (this.mayWrite(a, rel)) continue
784
+ refused.push(rel)
785
+ for (const t of types) undo.add(t)
786
+ // The log entry for a change that's undone would describe something that never happened.
787
+ if (d.activity && tr.changedParentTypes.has(d.activity)) undo.add(d.activity)
788
+ }
789
+ // Activity entries come apart from the file changes they describe (files are in branch
790
+ // documents now): one about a file this member may not change describes a change undone there.
791
+ let quiet = false
792
+ const added = d.activity && tr.changedParentTypes.has(d.activity) ? (this.activityAdded.get(tr) || []) : []
793
+ if (added.length && !undo.has(d.activity) && a && a.talk !== false && added.some((x) => x && typeof x.path === 'string' && x.path && !this.mayWrite(a, x.path))) {
794
+ undo.add(d.activity)
795
+ quiet = true
796
+ }
797
+ // The activity log is written by apps as files change. Someone who may not post may add
798
+ // only those entries, for files on their branch they may change, never words of their own.
799
+ if (a?.talk === false && added.length && !undo.has(d.activity)) {
800
+ const plain = (x) => x && typeof x === 'object' && Object.keys(x).every((k) => ACTIVITY_FIELDS.includes(k)) &&
801
+ x.by === a.name && ACTIVITY_KINDS.includes(x.kind) &&
802
+ (x.kind === 'switched' ? x.path === '' : typeof x.path === 'string' && this.mayWrite(a, x.path) && (x.kind === 'deleted' || touched.has(x.path) || this.onBranch(ws, x.path))) &&
803
+ (x.detail === undefined || ACTIVITY_DETAIL.test(x.detail)) && typeof x.ts === 'number' &&
804
+ (x.branch === undefined || validBranchKey(x.branch)) && (x.from === undefined || validBranchKey(x.from))
805
+ if (!added.every(plain)) { posts.push('the activity log'); undo.add(d.activity) }
806
+ }
807
+ refused.push(...posts)
808
+ // The guard recorded this change just before this 'update' (its stack-item-added): only
809
+ // that one is kept or undone, never another change that arrived in the same moment.
810
+ const item = d.recorded
811
+ d.recorded = null
812
+ if (!refused.length && !quiet) { this.forget(item, d); return true }
813
+ if (refused.length) this.log(`[${this.name}] undid ${a ? a.name : 'someone'}'s change to ${refused.slice(0, 3).join(', ')}${refused.length > 3 ? '…' : ''} (not allowed)`)
814
+ queueMicrotask(() => {
815
+ // Send the change and its undo as one update: nobody sees the change,
816
+ // and nobody is left missing part of this person's history.
817
+ const others = d.guard.undoStack.filter((x) => x !== item)
818
+ const scope = d.guard.scope
819
+ d.guard.undoStack = item ? [item] : []
820
+ d.guard.scope = scope.filter((t) => undo.has(t))
821
+ d.undoing = []
822
+ try { d.guard.undo() } finally {
823
+ d.guard.scope = scope
824
+ d.guard.undoStack = others
825
+ d.guard.redoStack = []
826
+ const merged = Y.mergeUpdates([update, ...d.undoing])
827
+ d.undoing = null
828
+ for (const other of d.listeners()) send(other, updateMessage(merged, d.frame(other)))
829
+ }
830
+ if (!refused.length) return
831
+ const why = posts.length === refused.length
832
+ ? TALK_WHY
833
+ : a && a.role === 'viewer' ? 'you can only view this session' : 'that is outside the folders you may change'
834
+ send(ws, jsonMessage(MSG_ACCESS, { ...this.accessMessage(a), refused: refused.slice(0, 20), why }))
835
+ })
836
+ return false
837
+ }
838
+
839
+ /** An allowed change: the guard never needs to undo it. */
840
+ forget (item, d = this.roomParts) {
841
+ const i = item ? d.guard.undoStack.indexOf(item) : -1
842
+ if (i >= 0) d.guard.undoStack.splice(i, 1)
843
+ }
844
+
845
+ /** The room's default branch: the first one anyone joined (∅, a folder without git, until then). */
846
+ get defaultKey () { return this.meta.defaultBranch || DEFAULT_KEY }
847
+
848
+ /** The document a branch name stands for: ∅ is the default branch. */
849
+ resolveKey (key) { return key === DEFAULT_KEY ? this.defaultKey : key }
850
+
851
+ /** Bytes the room takes: its own document and every branch's (capped at MAX_TOTAL_FACTOR times the limit, see assertBranchRoom). */
852
+ totalBytes () {
853
+ let n = this.bytes
854
+ if (this.store) for (const key of Object.keys(this.meta.branches)) n += this.store.size(key)
855
+ return n
856
+ }
857
+
858
+ /** Adds a branch to the session (once): who started it, when, and the commit it started from. True when new. */
859
+ noteBranch (key, { by = '', base = null } = {}) {
860
+ if (this.meta.branches[key]) return false
861
+ this.meta.branches[key] = { by, at: Date.now(), base: typeof base === 'string' && /^[0-9a-f]{40,64}$/.test(base) ? base : null, epoch: newEpoch() }
862
+ if (!this.meta.defaultBranch) this.meta.defaultBranch = key
863
+ this.saveMeta()
864
+ return true
865
+ }
866
+
867
+ /**
868
+ * Which document of branch `key` this is: a new id each time the branch's document is
869
+ * started (including after a prune). An app's saved copy of an older one is not merged in.
870
+ */
871
+ branchEpoch (key) {
872
+ const b = this.meta.branches[key]
873
+ if (!b) return null
874
+ if (!b.epoch) { b.epoch = newEpoch(); this.saveMeta() } // noted before epochs
875
+ return b.epoch
876
+ }
877
+
878
+ /** Whether branch `key`'s document is over the size limit (every document has the limit of its own). */
879
+ branchFull (key) { return this.store.size(key) > this.cfg.maxRoomBytes }
880
+
881
+ /** Branch `key`'s document, loaded, and added to the session when new (hosted agents and chat links use this). */
882
+ branchDoc (key, { by = '', base = null, editor = true } = {}) {
883
+ this.assertMigrated()
884
+ const k = this.resolveKey(key)
885
+ if (!this.meta.branches[k]) this.assertBranchRoom(editor)
886
+ if (this.noteBranch(k, { by, base })) this.broadcastBranches()
887
+ this.noteBranchSeen(k)
888
+ return this.store.load(k)
889
+ }
890
+
891
+ /**
892
+ * Branch `key` was used just now (joined, edited, saved, or left): refreshed in the
893
+ * session's meta, so the 30-day prune (task 4) never deletes a branch someone is on.
894
+ * Saved at most once an hour per branch unless `force` (a join is worth saving right
895
+ * away), so busy editing doesn't write the session's meta file on every keystroke.
896
+ */
897
+ noteBranchSeen (key, { force = false, used = false } = {}) {
898
+ const b = this.meta.branches[key]
899
+ if (!b) return
900
+ const now = Date.now()
901
+ // `used`: a member's app came onto the branch or left it. `seen` also moves when the relay
902
+ // itself loads or saves the branch, so only `used` says when someone last worked there (relay-ask.js).
903
+ if (used) b.used = now
904
+ if (!force && b.seen && now - b.seen < BRANCH_SEEN_SAVE_MS) return
905
+ b.seen = now
906
+ this.saveMeta()
907
+ }
908
+
909
+ /** A branch document just loaded: guarded like the room's files, its changes sent to the connections on it, and saved. */
910
+ wireBranch (e) {
911
+ e.guard = new Y.UndoManager([e.files, e.blobs, e.fileKeys, e.relayKeys, e.modes], { trackedOrigins: this.guard.trackedOrigins, captureTimeout: 0 })
912
+ e.recorded = null
913
+ e.undoing = null
914
+ e.listeners = () => e.conns
915
+ e.frame = (ws) => ws.branchAs
916
+ e.guard.on('stack-item-added', ({ stackItem, type }) => { if (type === 'undo') e.recorded = stackItem })
917
+ e.doc.on('update', (update, origin, doc, tr) => {
918
+ if (origin === e.guard && e.undoing) { e.undoing.push(update); return } // sent merged, by checkChange
919
+ if (origin && origin !== e.guard && this.guard.trackedOrigins.has(origin) && !this.checkChange(origin, update, tr, e)) return
920
+ if (origin && this.conns.has(origin)) this.noteActivity(this.holderKeys(origin))
921
+ // A member (hosted or connected) wrote a file in the relay's open clash on this branch: it looks again at once.
922
+ const rec = origin !== 'relay-upstream' && this.meta.upstreams ? this.meta.upstreams[e.key] : null
923
+ if (rec && rec.clash) for (const rel of changedPaths(tr, e)) this.noteClashWrite(e.key, rel)
924
+ // GitHub as a node: a member's change on a synced branch is pending for the next commit (relay-outbound.js).
925
+ if (origin !== 'relay-upstream' && this.cfg.gitSync && this.mirrors && rec) for (const rel of changedPaths(tr, e)) this.outbound.note(e.key, rel)
926
+ // A file edited in the session is no longer GitHub's version: its "from GitHub" mark goes (relay-sync.js markFromGithub).
927
+ if (origin !== 'relay-upstream') {
928
+ const gh = e.doc.getMap('fromGithub')
929
+ const gone = gh.size ? [...changedPaths(tr, e)].filter((rel) => gh.has(rel)) : []
930
+ if (gone.length) e.doc.transact(() => { for (const rel of gone) gh.delete(rel) }, 'relay-upstream')
931
+ }
932
+ for (const ws of e.conns) if (ws !== origin) send(ws, updateMessage(update, ws.branchAs))
933
+ const was = e.bytes > this.cfg.maxRoomBytes
934
+ e.bytes += update.length
935
+ if (!was && e.bytes > this.cfg.maxRoomBytes) {
936
+ // Only this branch: the room and the other branches carry on, and nobody is disconnected.
937
+ this.log(`[${this.name}] branch ${e.key} is over the size limit; further edits to it are refused (git checkout -b a new branch to keep syncing)`)
938
+ this.broadcastBranches()
939
+ }
940
+ this.store.scheduleSave(e)
941
+ })
942
+ }
943
+
944
+ /** Whether `rel` is a file on the branch this connection is on. */
945
+ onBranch (ws, rel) {
946
+ const e = ws.branch ? this.store.get(ws.branch) : null
947
+ return !!e && (e.files.has(rel) || e.blobs.has(rel))
948
+ }
949
+
950
+ /** A branch document was saved: records it was used just now (see noteBranchSeen), and the
951
+ * stored files it points at are remembered for when it is unloaded (storedIds). */
952
+ noteBranchSaved (e) {
953
+ const b = this.meta.branches[e.key]
954
+ if (!b) return
955
+ b.seen = Date.now()
956
+ const ids = [...e.blobs.values()].filter((x) => x && x.stored && x.stored.id).map((x) => x.stored.id).sort()
957
+ if (String(ids) !== String(b.stored || [])) b.stored = ids
958
+ this.saveMeta()
959
+ }
960
+
961
+ /**
962
+ * A room saved before branch documents kept everything in one document. That document
963
+ * becomes the default branch's as it is (every app's saved copy still matches it), and its
964
+ * room-wide parts (chat, tasks, the feed, commit requests, activity) move to a new room
965
+ * document. Once, before anyone connects. The branch file is written first, so the files
966
+ * are never only in memory, and a crash part-way is finished at the next load.
967
+ */
968
+ migrateLegacy () {
969
+ if (this.meta.layout === 2) return
970
+ const legacy = this.doc
971
+ if (this.docFile && (legacy.getMap('files').size || legacy.getMap('blobs').size)) {
972
+ const stored = [...legacy.getMap('blobs').values()].filter((x) => x && x.stored && x.stored.id).map((x) => x.stored.id).sort()
973
+ const room = splitLegacyDoc(legacy)
974
+ if (!this.store.write(DEFAULT_KEY, Y.encodeStateAsUpdate(legacy))) { room.destroy(); return } // read-only now; tried again at the next load
975
+ const state = Y.encodeStateAsUpdate(room)
976
+ try {
977
+ fs.writeFileSync(this.docFile + '.tmp', state)
978
+ fs.renameSync(this.docFile + '.tmp', this.docFile)
979
+ } catch (err) { room.destroy(); this.diskError(err); return }
980
+ legacy.destroy()
981
+ this.doc = room
982
+ this.bytes = state.length
983
+ this.meta.branches[DEFAULT_KEY] = { by: '', at: this.meta.createdAt || Date.now(), base: null, stored, epoch: newEpoch() }
984
+ this.log(`[${this.name}] moved its files into the default branch's document`)
985
+ } else if (this.store.stored(DEFAULT_KEY) && !this.meta.branches[DEFAULT_KEY]) {
986
+ // Stopped after both files were written, before this was saved: the default branch's
987
+ // entry is recovered with the stored ids its document already points at, so the stored-
988
+ // file sweep (collectStored) never mistakes them for unused before anyone loads it. Read
989
+ // directly, rather than through store.load: this runs before the room's own guard exists,
990
+ // which wireBranch (the store's onLoad) needs.
991
+ let stored = []
992
+ try {
993
+ const probe = new Y.Doc()
994
+ Y.applyUpdate(probe, fs.readFileSync(this.store.file(DEFAULT_KEY)))
995
+ stored = [...probe.getMap('blobs').values()].filter((x) => x && x.stored && x.stored.id).map((x) => x.stored.id).sort()
996
+ probe.destroy()
997
+ } catch (err) { this.log(`[${this.name}] could not read the default branch while recovering: ${err.message}`) }
998
+ this.meta.branches[DEFAULT_KEY] = { by: '', at: this.meta.createdAt || Date.now(), base: null, stored, epoch: newEpoch() }
999
+ }
1000
+ if (this.meta.branches[DEFAULT_KEY] && !this.meta.defaultBranch) this.meta.defaultBranch = DEFAULT_KEY
1001
+ this.meta.layout = 2
1002
+ if (this.exists) this.saveMeta()
1003
+ }
1004
+
1005
+ /**
1006
+ * The default branch is ∅ (a folder without git, or a room from before branch documents)
1007
+ * and nothing else is in the session: `key`, its first real branch, takes ∅'s document, so
1008
+ * nobody diverges. Two names already in the session stay two branches.
1009
+ */
1010
+ adoptDefault (key) {
1011
+ if (key === DEFAULT_KEY || this.defaultKey !== DEFAULT_KEY || !this.meta.branches[DEFAULT_KEY] || this.meta.branches[key]) return false
1012
+ if (Object.keys(this.meta.branches).some((k) => k !== DEFAULT_KEY)) return false
1013
+ this.store.rename(DEFAULT_KEY, key)
1014
+ this.meta.branches[key] = this.meta.branches[DEFAULT_KEY]
1015
+ delete this.meta.branches[DEFAULT_KEY]
1016
+ this.meta.defaultBranch = key
1017
+ for (const ws of this.conns.keys()) if (ws.branch === DEFAULT_KEY) ws.branch = key
1018
+ for (const [id, k] of Object.entries(this.meta.hostedBranch || {})) if (k === DEFAULT_KEY) this.meta.hostedBranch[id] = key
1019
+ this.saveMeta()
1020
+ return true
1021
+ }
1022
+
1023
+ /** Takes branch `key` out of the session: its document, its claims, hosted agents' choice of it. Never the git branch. */
1024
+ removeBranch (key, { save = true } = {}) {
1025
+ this.store.remove(key)
1026
+ delete this.meta.branches[key]
1027
+ for (const [k, c] of Object.entries(this.meta.claims)) if (this.branchOf(c) === key) delete this.meta.claims[k]
1028
+ for (const [id, k] of Object.entries(this.meta.hostedBranch || {})) if (k === key) delete this.meta.hostedBranch[id]
1029
+ if (save) this.saveMeta()
1030
+ }
1031
+
1032
+ /**
1033
+ * Branches nobody has been on for BRANCH_TTL_MS leave the session (never the default
1034
+ * branch); a detached checkout (key starting with '@', with no branch name to come back
1035
+ * to) leaves sooner, after DETACHED_TTL_MS.
1036
+ */
1037
+ pruneBranches (now = Date.now()) {
1038
+ let gone = 0
1039
+ for (const [key, b] of Object.entries(this.meta.branches)) {
1040
+ const ttl = key.startsWith('@') ? DETACHED_TTL_MS : BRANCH_TTL_MS
1041
+ if (key === this.defaultKey || now - (b.seen || b.at || 0) < ttl) continue
1042
+ this.removeBranch(key, { save: false })
1043
+ gone++
1044
+ }
1045
+ if (!gone) return
1046
+ this.log(`[${this.name}] removed ${gone} branch(es) nobody was on for a while`)
1047
+ if (this.exists) this.saveMeta()
1048
+ }
1049
+
1050
+ /**
1051
+ * The branch with the most members on it (connected apps, and hosted agents that chose
1052
+ * one); ties go to the owner's, then the default. Hosted agents and chat links work here
1053
+ * unless they chose a branch.
1054
+ */
1055
+ activeBranch () {
1056
+ const count = new Map()
1057
+ const counted = new Set()
1058
+ for (const ws of this.conns.keys()) {
1059
+ const who = this.names.get(ws)
1060
+ if (!ws.branch || counted.has(who)) continue
1061
+ counted.add(who)
1062
+ count.set(ws.branch, (count.get(ws.branch) || 0) + 1)
1063
+ }
1064
+ for (const [id, k] of Object.entries(this.meta.hostedBranch || {})) {
1065
+ if (this.meta.branches[k] && this.hostedSeen.has(id)) count.set(k, (count.get(k) || 0) + 1)
1066
+ }
1067
+ if (!count.size) return this.defaultKey
1068
+ const top = Math.max(...count.values())
1069
+ const tied = [...count].filter(([, n]) => n === top).map(([k]) => k)
1070
+ const owner = [...this.access].find(([, a]) => a.owner)?.[0]?.branch
1071
+ if (owner && tied.includes(owner)) return owner
1072
+ if (tied.includes(this.defaultKey)) return this.defaultKey
1073
+ return tied.sort()[0]
1074
+ }
1075
+
1076
+ /** The branch a hosted agent works on: the one it chose (quilt_switch_branch), or the active branch. */
1077
+ hostedBranch (id) {
1078
+ const k = (this.meta.hostedBranch || {})[id]
1079
+ return k && this.meta.branches[k] ? k : this.activeBranch()
1080
+ }
1081
+
1082
+ /**
1083
+ * A hosted agent that hasn't chosen a branch is pinned to the active branch (its first call on
1084
+ * files), so it stays there until quilt_switch_branch even as people check other branches out.
1085
+ */
1086
+ pinHostedBranch (id) {
1087
+ const k = (this.meta.hostedBranch || {})[id]
1088
+ if (k && this.meta.branches[k]) return k
1089
+ const to = this.activeBranch()
1090
+ this.meta.hostedBranch = { ...(this.meta.hostedBranch || {}), [id]: to }
1091
+ this.saveMeta()
1092
+ this.broadcastBranches()
1093
+ return to
1094
+ }
1095
+
1096
+ /**
1097
+ * A hosted agent works on branch `key` from now on (quilt_switch_branch), kept per member.
1098
+ * `create`: a new branch in the session, started from a copy of the files on the branch it
1099
+ * is on (as `git switch -c` carries a folder's work). Returns { branch, created, from }.
1100
+ */
1101
+ setHostedBranch (id, key, { create = false, by = '', editor = true, github = null } = {}) {
1102
+ if (!validBranchKey(key) || key === DEFAULT_KEY) throw new Error(`"${String(key).slice(0, 60)}" isn't a branch name.`)
1103
+ const to = this.resolveKey(key)
1104
+ const from = this.hostedBranch(id)
1105
+ const exists = !!this.meta.branches[to]
1106
+ if (!exists && !create) throw new Error(`${key} isn't in this session. Pass create: true to start it: from GitHub when the branch is there, otherwise from a copy of the files on ${from}.`)
1107
+ if (exists && create) throw new Error(`${key} is already in this session: switch to it without create.`)
1108
+ if (!exists && github) {
1109
+ // The branch as it is on GitHub (branchFromGitHub): its files at its head, and the commit
1110
+ // they are, so the relay brings later commits on it in (relay-upstream.js).
1111
+ this.assertMigrated()
1112
+ this.assertBranchRoom(editor, github.bytes || 0)
1113
+ const e = this.branchDoc(to, { by, base: null, editor })
1114
+ e.doc.transact(() => {
1115
+ for (const [rel, t] of github.texts) { const y = new Y.Text(); y.insert(0, t); e.files.set(rel, y) }
1116
+ for (const [rel, b] of github.blobs) e.blobs.set(rel, b)
1117
+ }, 'hosted-branch')
1118
+ upstreamsOf(this)[to] = { ...github.upstream, head: github.upstream.sha, checkedAt: Date.now(), hostedAt: Date.now() }
1119
+ } else if (!exists) {
1120
+ this.assertMigrated()
1121
+ this.assertBranchRoom(editor, this.store.size(from)) // a copy of `from`'s files
1122
+ const src = this.branchDoc(from, { by })
1123
+ const e = this.branchDoc(to, { by, base: this.meta.branches[from] ? this.meta.branches[from].base : null, editor })
1124
+ e.doc.transact(() => {
1125
+ for (const [rel, t] of src.files) { const y = new Y.Text(); y.insert(0, t.toString()); e.files.set(rel, y) }
1126
+ for (const [rel, b] of src.blobs) e.blobs.set(rel, b)
1127
+ for (const [k, v] of src.fileKeys) e.fileKeys.set(k, v)
1128
+ for (const [k, v] of src.relayKeys) e.relayKeys.set(k, v)
1129
+ }, 'hosted-branch')
1130
+ this.noteBranchCut(to, from)
1131
+ }
1132
+ this.meta.hostedBranch = { ...(this.meta.hostedBranch || {}), [id]: to }
1133
+ if (this.meta.remoteBranches && this.meta.remoteBranches[to]) delete this.meta.remoteBranches[to]
1134
+ this.saveMeta()
1135
+ this.broadcastBranches()
1136
+ return { branch: to, created: !exists, from }
1137
+ }
1138
+
1139
+ /**
1140
+ * GitHub as a node: a branch cut in the session from a live-synced branch is mirrored to the
1141
+ * GitHub branch of the same name (plan §0, §2): its record follows the same repository, and it
1142
+ * is measured from where it was cut, so its first push creates it on GitHub on that commit.
1143
+ */
1144
+ noteBranchCut (to, from) {
1145
+ if (!this.cfg.gitSync || !this.mirrors) return false
1146
+ const recs = upstreamsOf(this)
1147
+ const src = recs[from]
1148
+ if (!src || !src.repo || !src.ref || recs[to]) return false
1149
+ const base = (this.meta.sync && this.meta.sync[from] && this.meta.sync[from].base) || src.sha
1150
+ if (!base) return false
1151
+ const remote = src.name && src.name.endsWith(`/${src.ref}`) ? src.name.slice(0, -(src.ref.length + 1)) : 'origin'
1152
+ recs[to] = { repo: src.repo, name: `${remote}/${to}`, ref: to, sha: base, head: null, cutFrom: from, cutAt: base, checkedAt: 0, ...(src.github ? { github: { ...src.github } } : {}) }
1153
+ syncStateOf(this, to).base = base
1154
+ this.saveMeta()
1155
+ return true
1156
+ }
1157
+
1158
+ /** A hosted agent's branch choice, forgotten once its membership here ends (it left, or the owner removed it). */
1159
+ forgetHostedBranch (id, { save = true } = {}) {
1160
+ if (!this.meta.hostedBranch || !(id in this.meta.hostedBranch)) return
1161
+ delete this.meta.hostedBranch[id]
1162
+ if (save) this.saveMeta()
1163
+ }
1164
+
1165
+ /**
1166
+ * Whether a new branch may start: not over the session's branch limit or its total size
1167
+ * (every document together, with `adding` bytes more for the copy a new branch starts
1168
+ * from), and (unless `editor` is false) started by someone who may change files. Shared by
1169
+ * branchRequest's join and branchDoc (hosted agents, chat links), so the same caps and
1170
+ * editor-only rule apply wherever a branch can be created. Refusing here never
1171
+ * disconnects anyone: the folder stays on its branch.
1172
+ */
1173
+ assertBranchRoom (editor = true, adding = 0) {
1174
+ if (this.unsavable) throw new Error("The relay can't save this session right now, so it can't take another branch.")
1175
+ if (this.totalBytes() + adding > this.cfg.maxRoomBytes * MAX_TOTAL_FACTOR) throw new Error("This session is at its size limit across all its branches, so it can't take another branch. Branches nobody is on leave the session after 30 days.")
1176
+ if (Object.keys(this.meta.branches).length >= MAX_BRANCHES) throw new Error(`This session already has ${MAX_BRANCHES} branches, its limit; an existing one has to go before another can start.`)
1177
+ if (!editor) throw new Error('you can only view this session, so you cannot start a new branch')
1178
+ }
1179
+
1180
+ /** A room whose move to branch documents failed on the relay's disk (migrateLegacy) stays read-only: no branch document is used. */
1181
+ assertMigrated () {
1182
+ if (this.meta.layout !== 2) throw new Error("The relay couldn't finish updating how it stores this session (its disk refused), so the session is read-only until the relay is fixed.")
1183
+ }
1184
+
1185
+ /** The session's branches, for everyone's branch menu (hosted: the hosted agents on each). */
1186
+ branchList () {
1187
+ const hosted = new Map()
1188
+ for (const h of this.hostedOnline()) {
1189
+ const k = this.hostedBranch(h.id)
1190
+ hosted.set(k, [...(hosted.get(k) || []), h.name])
1191
+ }
1192
+ const ups = this.meta.upstreams || {}
1193
+ const list = Object.entries(this.meta.branches).map(([key, b]) => {
1194
+ const relay0 = relayEntry(ups[key])
1195
+ const live = relay0 && this.cfg.gitSync && this.mirrors ? liveSyncOn(this, key) : false
1196
+ const sync = relay0 && this.cfg.gitSync && this.mirrors ? syncStatus(this, key, { live }) : null
1197
+ const st = this.meta.sync && this.meta.sync[key]
1198
+ // `git`: this relay serves the session's branches over git (relay-githttp.js): agents' folders use it as their origin.
1199
+ const relay = relay0 ? { ...relay0, ...(sync ? { sync } : {}), ...(live ? { live: true } : {}), ...(this.cfg.gitSync && this.cfg.gitHttp !== false && this.cfg.passPublicKey && this.mirrors && this.dataDirOf && /^github\.com\//.test(String(relay0.repo || '')) ? { git: true } : {}), ...(live && this.meta.largeToRelay === true && this.relayFileKey ? { fileKey: this.relayFileKey.publicKey } : {}), ...(live && st && st.protected && !st.protected.leased ? { protected: true } : {}), ...(live && st && st.protected && st.protected.leased ? { leased: true, ...(this.sameOwner(ups[key].leasedBy) ? { liveIn: ups[key].leasedBy } : {}) } : {}), ...(st && st.pr && st.pr.number ? { pr: { number: st.pr.number, url: st.pr.url || '' } } : {}) } : null
1200
+ const merged = ups[key] && ups[key].merged ? { into: ups[key].merged.into, number: ups[key].merged.number, at: ups[key].merged.at } : null
1201
+ return { key, by: b.by || '', at: b.at || 0, base: b.base || null, default: key === this.defaultKey, hosted: hosted.get(key) || [], ...(this.branchFull(key) ? { full: true } : {}), ...(relay ? { relay } : {}), ...(merged ? { merged } : {}) }
1202
+ })
1203
+ // Branches on GitHub the session doesn't have yet (a create webhook, §5.4): in the menu, synced once someone opens them.
1204
+ for (const [key, r] of Object.entries(this.meta.remoteBranches || {})) if (!this.meta.branches[key]) list.push({ key, by: '', at: (r && r.at) || 0, base: null, default: false, hosted: [], onGitHub: true })
1205
+ return list
1206
+ }
1207
+
1208
+ // ------------------------------------------------------- upstream (relay-upstream.js) --
1209
+ // Commits pushed to GitHub come into a branch through a member's folder. A branch only hosted
1210
+ // agents are working on, with no folder online, is brought up to date by the relay instead.
1211
+
1212
+ /** Members' presence says which upstream each branch follows, and the commit its folders are at. */
1213
+ noteUpstreams () {
1214
+ if (!this.exists || this.ended || this.destroyed) return
1215
+ noteHolds(this)
1216
+ const was = new Map(Object.entries(upstreamsOf(this)).map(([key, rec]) => [key, rec && rec.sha]))
1217
+ if (noteFolders(this, this.log)) {
1218
+ this.saveMeta(); this.broadcastBranches(); this.noteConnections()
1219
+ // A folder brought GitHub's commits in itself (an older app): their files are marked as from GitHub too.
1220
+ if (this.cfg.gitSync && this.mirrors) {
1221
+ for (const [key, rec] of Object.entries(upstreamsOf(this))) {
1222
+ const from = was.get(key)
1223
+ if (rec && rec.sha && from && rec.sha !== from && rec.relayHead !== rec.sha) markFolderBrought(this, key, from, rec.sha, { log: this.log })
1224
+ }
1225
+ }
1226
+ }
1227
+ // With the mirror on, a branch due a look (at start, after a member connects, or when the backstop is up) is looked at as soon as someone is on it.
1228
+ if (this.cfg.gitSync && this.mirrors) {
1229
+ for (const ws of this.conns.keys()) if (ws.branch && !this.lookedFor.has(ws)) { this.lookedFor.add(ws); const rec = upstreamsOf(this)[ws.branch]; if (rec) { rec.checkedAt = 0; delete rec.webhookAt } }
1230
+ this.lookSoon()
1231
+ }
1232
+ }
1233
+
1234
+ /**
1235
+ * What agents may commit, when the session's org or workspace sets it for all its sessions (the accounts API's
1236
+ * /v1/relay/session-policy): kept in meta.agentCommitsSet ({ mode, by, name }) and put before the owner's own.
1237
+ * Asked when the room loads, about every ten minutes, and at once when the API says it changed (/github/policy).
1238
+ */
1239
+ async refreshPolicy () {
1240
+ if (!this.cfg.apiUrl || !this.cfg.relayApiSecret || this.destroyed || !this.exists) return
1241
+ this.policyAt = Date.now()
1242
+ try {
1243
+ const res = await (this.apiFetch || globalThis.fetch)(`${String(this.cfg.apiUrl).replace(/\/+$/, '')}/v1/relay/session-policy`, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${this.cfg.relayApiSecret}` }, body: JSON.stringify({ room: this.name }) })
1244
+ if (!res.ok || this.destroyed) return
1245
+ const b = await res.json()
1246
+ const next = AGENT_COMMITS.includes(b.agentCommits) ? { mode: b.agentCommits, by: ['org', 'team'].includes(b.by) ? b.by : 'workspace', name: String(b.name || '').slice(0, 100) } : null
1247
+ if (JSON.stringify(next) === JSON.stringify(this.meta.agentCommitsSet || null)) return
1248
+ const was = agentCommitsOf(this)
1249
+ if (next) this.meta.agentCommitsSet = next; else delete this.meta.agentCommitsSet
1250
+ this.saveMeta()
1251
+ if (agentCommitsOf(this) === 'off' && was !== 'off') this.revokeAgentTokens({ access: 'write' })
1252
+ this.broadcastAccess()
1253
+ this.log(`[${this.name}] what agents may commit: ${next ? `${next.mode}, set by the ${next.by}${next.name ? ` ${next.name}` : ''}` : 'the session owner decides'}`)
1254
+ } catch (err) { this.log(`[${this.name}] could not ask the accounts API what agents may commit: ${err.message}`) }
1255
+ }
1256
+
1257
+ /** Each branch that follows a GitHub repository learns whether a member's connection lets agents commit there (relay-upstream.js noteConnection). */
1258
+ noteConnections ({ keys = null, force = false } = {}) {
1259
+ if (force || Date.now() - (this.policyAt || 0) > POLICY_EVERY_MS) this.refreshPolicy()
1260
+ // Which GitHub account each person linked, for commit authorship (relay-github.js); about once an hour.
1261
+ if (this.cfg.gitSync && this.mirrors) noteMemberLogins(this, { force, fetch: this.apiFetch }).catch(() => {})
1262
+ for (const [key, rec] of Object.entries(upstreamsOf(this))) {
1263
+ if (!rec || !rec.repo || (keys && !keys.includes(key))) continue
1264
+ // A "yes" stands an hour; a "no" is asked again every ten minutes (someone may have connected
1265
+ // from an app that couldn't say so), unless forced.
1266
+ if (!force && rec.github && Date.now() - (rec.github.at || 0) < (rec.github.state === 'ok' ? 60 : 10) * 60 * 1000) continue
1267
+ noteConnection(this, key, { force }).then((changed) => { if (changed && !this.destroyed) { this.saveMeta(); this.broadcastBranches(); this.broadcastAccess() } }).catch((err) => this.log(`[${this.name}] ${key}: could not check the GitHub connection: ${err.message}`))
1268
+ }
1269
+ }
1270
+
1271
+ /** A hosted agent just called a tool: its branch counts as worked on (kept, so it outlasts the room leaving memory), and is looked at if due. */
1272
+ noteHostedUpstream (id) {
1273
+ const key = this.meta.layout === 2 ? this.hostedBranch(id) : null
1274
+ const rec = key && (this.meta.upstreams || {})[key]
1275
+ if (rec && Date.now() - (rec.hostedAt || 0) >= 60 * 1000) { rec.hostedAt = Date.now(); this.saveMeta() }
1276
+ this.lookSoon()
1277
+ }
1278
+
1279
+ /** The relay's next look at the branches due (not forced), as soon as the current turn is over. */
1280
+ lookSoon () {
1281
+ if (!this.cfg.upstreamAuto || this.upstreamSoonTimer) return
1282
+ this.upstreamSoonTimer = setTimeout(() => {
1283
+ this.upstreamSoonTimer = null
1284
+ this.checkUpstreams().catch((err) => this.log(`[${this.name}] could not look for new commits: ${err.message}`))
1285
+ }, 0)
1286
+ this.upstreamSoonTimer.unref?.()
1287
+ }
1288
+
1289
+ /**
1290
+ * Looks at GitHub for the branches due (or, with `force`, every branch the relay looks after,
1291
+ * except those GitHub told to wait), one at a time; only `keys` when given. One run at a time:
1292
+ * an ask meanwhile gets that run.
1293
+ */
1294
+ checkUpstreams ({ force = false, keys = null } = {}) {
1295
+ // A look asked for (force) waits for one under way, then has its own: the one under way may not include its branches.
1296
+ if (this.upstreamRun) return force ? this.upstreamRun.catch(() => {}).then(() => this.checkUpstreams({ force, keys })) : this.upstreamRun
1297
+ if (!this.exists || this.ended || this.destroyed || this.meta.layout !== 2) return Promise.resolve([])
1298
+ this.noteConnections({ keys }) // the connections, about once an hour per branch, with the relay's own looks
1299
+ const now = Date.now()
1300
+ // With the mirror on: about every minute (git ls-remote, no API quota); webhooks in between (addendum 16.13).
1301
+ const live = this.cfg.gitSync && this.mirrors
1302
+ const activeMs = Number.isFinite(this.cfg.activeBackstopMs) ? this.cfg.activeBackstopMs : ACTIVE_BACKSTOP_MS
1303
+ const due = live && !force
1304
+ ? [...new Set([...dueBranches(this, { now, everyMs: activeMs, keys }).filter((k) => relayLooksActive(this, k)), ...dueBranches(this, { now, everyMs: BACKSTOP_MS, keys })])]
1305
+ : dueBranches(this, { now, everyMs: force ? 0 : this.cfg.upstreamCheckMs, keys })
1306
+ if (!due.length) return Promise.resolve([])
1307
+ this.upstreamRun = (async () => {
1308
+ const out = []
1309
+ for (const key of due) {
1310
+ if (this.destroyed || !relayLooks(this, key)) continue
1311
+ try {
1312
+ // Through the relay's git mirror (GitHub as a node), or the older REST path.
1313
+ const r = this.cfg.gitSync && this.mirrors ? await bringInBranch(this, key, { log: this.log }) : await checkBranch(this, key, { fetch: this.githubFetch, log: this.log, fallbackToken: this.cfg.githubToken })
1314
+ out.push({ branch: key, ...r })
1315
+ // GitHub out of reach for a look: the branch says so (offline), until a look gets through.
1316
+ const sync = this.cfg.gitSync && this.meta.sync && this.meta.sync[key]
1317
+ if (sync && r.unreachable && !sync.paused) {
1318
+ sync.paused = { why: 'unreachable', at: Date.now(), text: 'a look at GitHub failed', backoffMs: 0, until: 0 }
1319
+ const rec = upstreamsOf(this)[key]
1320
+ if (rec) rec.problem = `GitHub unreachable since ${new Date(sync.paused.at).toISOString().slice(11, 16)} UTC: the session keeps working, and GitHub's changes come in when it answers`
1321
+ this.saveMeta(); this.broadcastBranches()
1322
+ }
1323
+ // GitHub answered: an outage is over. Commits asked for meanwhile go now; otherwise the branch just says it is reachable again.
1324
+ if (sync && sync.paused && sync.paused.why === 'unreachable' && ['brought', 'up-to-date', 'recovered', 'rewrite'].includes(r.state)) {
1325
+ if (Array.isArray(sync.asked) && sync.asked.length) this.outbound.recover(key).catch((err) => this.log(`[${this.name}] ${key}: could not make the commits asked for: ${err.message}`))
1326
+ else {
1327
+ delete sync.paused
1328
+ const rec = upstreamsOf(this)[key]
1329
+ if (rec && /^GitHub unreachable since/.test(rec.problem || '')) delete rec.problem
1330
+ this.saveMeta(); this.broadcastBranches()
1331
+ }
1332
+ }
1333
+ } catch (err) {
1334
+ this.log(`[${this.name}] ${key}: could not look for new commits: ${err.message}`)
1335
+ out.push({ branch: key, state: 'error' })
1336
+ }
1337
+ }
1338
+ return out
1339
+ })().finally(() => { this.upstreamRun = null })
1340
+ return this.upstreamRun
1341
+ }
1342
+
1343
+ /** Looks at branches `keys` now, after any look under way (not the one under way: it may not include them). */
1344
+ async checkUpstreamsNow (keys) {
1345
+ while (this.upstreamRun) { try { await this.upstreamRun } catch {} }
1346
+ return this.checkUpstreams({ force: true, keys })
1347
+ }
1348
+
1349
+ /** The claims held for clash task `id` (and its holder's requests for its files) let go of: the merge is done. */
1350
+ dropClashClaims (id) {
1351
+ const held = Object.values(this.meta.claims || {}).some((c) => c.clash === id || (c.queue || []).some((r) => r.clash === id))
1352
+ if (!held) return
1353
+ this.dropRequests((r) => r.clash === id)
1354
+ this.dropClaims((c) => c.clash === id, (c) => `the merge ${c.by} held it for (task ${id}) is done.`)
1355
+ this.broadcastClaims()
1356
+ }
1357
+
1358
+ /**
1359
+ * Claims the files of clash task `id` for its assignee `who` (a claimant: { name, id, talk, branch })
1360
+ * on that branch, taking them over from whoever held them for the task before. A file someone else
1361
+ * holds is asked for in its file queue instead. Returns { claimed: [path], asked: [{ path, holder }] }.
1362
+ */
1363
+ claimClashFiles (who, id, paths, { upstream = 'upstream' } = {}) {
1364
+ const claimed = []
1365
+ const asked = []
1366
+ for (const rel of paths) {
1367
+ try {
1368
+ this.claimRequest(who, { op: 'claim', pattern: rel, note: `Merging commits from ${upstream} (task ${id})`, clash: id })
1369
+ claimed.push(rel)
1370
+ } catch {
1371
+ const c = this.claimFor(rel, who.branch)
1372
+ if (!c) continue
1373
+ try {
1374
+ this.claimRequest(who, { op: 'request', path: rel, title: `Merging commits from ${upstream}`, description: `Task ${id}: the commits clash with the session's work in this file. I merge it once you hand it over.`, task: id, clash: id })
1375
+ asked.push({ path: rel, holder: c.by })
1376
+ } catch {}
1377
+ }
1378
+ }
1379
+ if (claimed.length || asked.length) this.broadcastClaims()
1380
+ return { claimed, asked }
1381
+ }
1382
+
1383
+ /** Branch `key` is looked at again at once, a moment from now (several asks make one look). */
1384
+ upstreamSoon (key) {
1385
+ if (!this.cfg.upstreamOnChange || !key) return
1386
+ this.upstreamSoonKeys.add(key)
1387
+ if (this.upstreamAgainTimer) return
1388
+ this.upstreamAgainTimer = setTimeout(() => {
1389
+ this.upstreamAgainTimer = null
1390
+ const keys = [...this.upstreamSoonKeys]
1391
+ this.upstreamSoonKeys.clear()
1392
+ this.checkUpstreamsNow(keys).catch((err) => this.log(`[${this.name}] could not look for new commits: ${err.message}`))
1393
+ }, 50)
1394
+ this.upstreamAgainTimer.unref?.()
1395
+ }
1396
+
1397
+ /** A file on branch `key` changed by a member: when it is in the relay's open clash there, the relay looks again at once. */
1398
+ noteClashWrite (key, rel) {
1399
+ const rec = (this.meta.upstreams || {})[key]
1400
+ if (rec && rec.clash && rec.clash.files && rel in rec.clash.files) this.upstreamSoon(key)
1401
+ }
1402
+
1403
+ /**
1404
+ * A task moved to QA or Done: when it is a relay clash's and its assignee moved it, the files it
1405
+ * lists count as merged as they are (for that commit), and the relay looks again at once.
1406
+ * Who moved it: the connection's member (or one of its AI sessions), or the hosted agent whose tool ran.
1407
+ */
1408
+ noteClashMoved (id, task, origin) {
1409
+ const recs = this.meta.upstreams || {}
1410
+ const key = Object.keys(recs).find((k) => recs[k] && recs[k].clash && recs[k].clash.task === id)
1411
+ if (!key || !task.assignee) return
1412
+ const names = []
1413
+ if (origin && this.conns.has(origin)) {
1414
+ names.push(this.names.get(origin))
1415
+ const states = this.awareness.getStates()
1416
+ for (const cid of this.conns.get(origin)) for (const p of (states.get(cid)?.personas || [])) if (p && p.name) names.push(p.name)
1417
+ } else if (this.auditAs && this.meta.members[this.auditAs]) names.push(this.meta.members[this.auditAs].name)
1418
+ if (!names.includes(task.assignee)) return
1419
+ const rec = recs[key]
1420
+ rec.clash.settled = { by: task.assignee, at: Date.now() }
1421
+ this.saveMeta()
1422
+ this.log(`[${this.name}] ${key}: ${task.assignee} moved the clash task ${id} to ${task.column}: its files count as merged`)
1423
+ this.upstreamSoon(key)
1424
+ }
1425
+
1426
+ /**
1427
+ * quilt_sync_branch where no folder can do it: the relay asks GitHub about branch `key` now
1428
+ * (at most once a minute per branch) and brings commits in as on its own looks.
1429
+ * Returns { state, said } (said: for the AI that asked).
1430
+ */
1431
+ async syncUpstreamNow (key) {
1432
+ key = this.resolveKey ? this.resolveKey(key) : key
1433
+ const rec = upstreamsOf(this)[key]
1434
+ if (!rec || !rec.repo || !rec.ref) return { state: 'unknown-repo', said: `The relay doesn't know which GitHub repository \`${key}\` follows yet: a member's folder on it (a git clone) tells it once it is online.` }
1435
+ if (!rec.sha) return { state: 'unknown-commit', said: `The relay doesn't know which commit of ${rec.name || 'the upstream'} the session's files on \`${key}\` are at yet: a member's folder on it, in step with the session, tells it.` }
1436
+ // With the mirror on, folders follow the relay's commits: only an older app's folder brings them in itself.
1437
+ const folder = this.cfg.gitSync && this.mirrors ? catcherAble(this, key) : folderAble(this, key)
1438
+ if (folder) return { state: 'folder', said: `${folder}'s folder on \`${key}\` brings commits in itself (it fetches ${rec.name} about once a minute), so the relay leaves it to that folder.` }
1439
+ if (!relayLooks(this, key)) return { state: 'not-looking', said: `The relay isn't looking after \`${key}\` right now.` }
1440
+ const now = Date.now()
1441
+ const last = this.syncAsked.get(key) || 0
1442
+ // Through the mirror a look is a git ls-remote, no API quota: folders waiting for a commit ask too (session.js bringIn).
1443
+ const mirror = this.cfg.gitSync && this.mirrors
1444
+ if (now - last < (mirror ? SYNC_EVERY_GIT_MS : SYNC_EVERY_MS)) {
1445
+ const ago = Math.max(1, Math.round((now - (rec.checkedAt || last)) / 1000))
1446
+ return { state: 'recent', said: `The relay asked GitHub about ${rec.name} for \`${key}\` ${ago}s ago, and asks at most once ${mirror ? `every ${SYNC_EVERY_GIT_MS / 1000} seconds` : 'a minute'} per branch. ${rec.clash ? `Its clash is task ${rec.clash.task} on the board.` : rec.problem ? `Note: ${rec.problem}.` : rec.sha === rec.head ? `\`${key}\` was up to date with ${rec.name} then.` : ''}`.trim() }
1447
+ }
1448
+ this.syncAsked.set(key, now)
1449
+ if (rec.backoffUntil > now) return { state: 'backoff', said: `Not now: ${rec.problem || 'GitHub asked the relay to slow down'}.` }
1450
+ const out = await this.checkUpstreamsNow([key])
1451
+ const r = out.find((x) => x.branch === key)
1452
+ return { state: r ? r.state : 'not-looking', said: describeRelayCheck(r, upstreamsOf(this)[key] || rec, key, this) }
1453
+ }
1454
+
1455
+ /**
1456
+ * A member's commit to GitHub from the session's copy of the files on branch `key`
1457
+ * (relay-commit.js): who is { name, kind, access }; `req` says files, message, branch,
1458
+ * pullRequest, withOthers, task. One at a time per session.
1459
+ */
1460
+ async commitFor (who, key, req = {}) {
1461
+ const run = () => commitForMember(this, {
1462
+ who,
1463
+ key,
1464
+ files: Array.isArray(req.files) ? req.files.slice(0, 1000).map(String) : null,
1465
+ message: typeof req.message === 'string' ? req.message : '',
1466
+ branch: typeof req.branch === 'string' ? req.branch : '',
1467
+ pullRequest: req.pullRequest === true,
1468
+ withOthers: req.withOthers === true,
1469
+ task: typeof req.task === 'string' ? req.task : null
1470
+ }, { fetch: this.githubFetch, log: this.log })
1471
+ const next = (this.committing || Promise.resolve()).then(run, run)
1472
+ this.committing = next.catch(() => {})
1473
+ return next
1474
+ }
1475
+
1476
+ /**
1477
+ * A token for the repository branch `key` follows, for a member's folder (relay-github.js): read,
1478
+ * to fetch or clone; write (`access`), to push with, while git is on for everyone here (git for
1479
+ * everyone: agentCommitsOf isn't 'off'; an agent's pre-push hook holds its pushes to the policy's
1480
+ * branches). An agent's or a person's write token is their own (minted for them alone, `actingFor`
1481
+ * their account, so the org's Require GitHub and the workspace's repositories apply as for anyone),
1482
+ * kept here so it can be taken back at GitHub when they are removed or git turns off. `who`:
1483
+ * { name, kind, id } (id: their account, person:<id>). { token, expiresAt, repo, access } or
1484
+ * { state } when there is none. A token the owner pasted by hand is never handed out.
1485
+ */
1486
+ async gitTokenFor (key, { access = 'read', who = null, repo: asked = null } = {}) {
1487
+ key = this.resolveKey(key)
1488
+ // The repository: the one the folder's git names (`asked`, owner/name), when a branch here follows
1489
+ // it; else the branch's own record, else the default branch's (a new feature branch has no record yet).
1490
+ let rec = null
1491
+ if (typeof asked === 'string' && /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/.test(asked)) {
1492
+ rec = Object.values(upstreamsOf(this)).find((r) => r && r.repo && String(r.repo).toLowerCase() === `github.com/${asked}`.toLowerCase()) || null
1493
+ }
1494
+ if (!rec) rec = (repoRecordFor(this, key) || {}).rec || null
1495
+ const repo = rec && rec.repo ? parseRepo(`https://${rec.repo}`) : null
1496
+ if (!repo || !repo.github) return { state: 'not-github' }
1497
+ const full = `${repo.owner}/${repo.name}`
1498
+ const agent = !!who && who.kind === 'agent' && !!who.name
1499
+ const person = !!who && who.kind !== 'agent' && /^person:[A-Za-z0-9_-]{1,64}$/.test(who.id || '')
1500
+ const on = agentCommitsOf(this) !== 'off'
1501
+ const level = access === 'write' && on && (agent || person) ? 'write' : 'read'
1502
+ // A person reading: the relay's own read token, as before. Everyone writing, and agents always, get one of their own.
1503
+ if (!agent && level === 'read') {
1504
+ const r = await appCredentials(this, full, { access: level })
1505
+ return r.token ? { token: r.token, expiresAt: r.expiresAt, repo: full, access: level } : { state: r.state }
1506
+ }
1507
+ if (!this.agentTokens) this.agentTokens = new Map()
1508
+ const k = `${agent ? who.name : who.id}\0${full}\0${level}`.toLowerCase()
1509
+ const hit = this.agentTokens.get(k)
1510
+ if (hit && hit.expiresAt - Date.now() > 5 * 60 * 1000) return { token: hit.token, expiresAt: hit.expiresAt, repo: full, access: level }
1511
+ // Who the token is for decides when the org requires GitHub (plan §2): an agent's member key is agent:<id>, a person's person:<id>.
1512
+ const actor = agent ? (Object.entries(this.meta.members || {}).find(([k, m]) => k.startsWith('agent:') && m && m.name === who.name) || [])[0] : who.id
1513
+ const r = await appCredentials(this, full, { access: level, share: false, ...(actor ? { actingFor: actor } : {}) })
1514
+ if (!r.token) return { state: r.state, ...(r.why ? { why: r.why } : {}), ...(r.require ? { require: true } : {}) }
1515
+ this.agentTokens.set(k, { token: r.token, expiresAt: r.expiresAt, name: who.name, account: agent ? actor || '' : who.id, access: level })
1516
+ return { token: r.token, expiresAt: r.expiresAt, repo: full, access: level }
1517
+ }
1518
+
1519
+ /** Takes back the tokens handed to members' folders: one agent's (`name`), one account's (`account`), or every write token (`access`). */
1520
+ revokeAgentTokens ({ name = null, account = null, access = null } = {}) {
1521
+ if (!this.agentTokens) return
1522
+ for (const [k, t] of this.agentTokens) {
1523
+ if ((name && t.name !== name) || (account && t.account !== account) || (access && t.access !== access)) continue
1524
+ this.agentTokens.delete(k)
1525
+ revokeGitHubToken(t.token, { fetch: this.githubFetch }).then((ok) => { if (ok) this.log(`[${this.name}] took back ${t.name}'s ${t.access} token for GitHub`) })
1526
+ }
1527
+ }
1528
+
1529
+ /** Everyone's access again: what agents may do with commits, and whether they can (the GitHub connection) changed. */
1530
+ broadcastAccess () { for (const [cws, a] of this.access) send(cws, jsonMessage(MSG_ACCESS, this.accessMessage(a))) }
1531
+
1532
+ /**
1533
+ * Git for this session, the owner's call ('off', 'branches', 'any'; '' follows the rule over the session,
1534
+ * or the default): within the rule over it (an org's, a team's, a workspace's), which is a ceiling: the
1535
+ * owner may be stricter, never looser. Everyone's access says it.
1536
+ */
1537
+ setAgentCommits (mode) {
1538
+ if (mode !== '' && !AGENT_COMMITS.includes(mode)) throw new Error(`pick one of ${AGENT_COMMITS.join(', ')}`)
1539
+ const set = this.meta.agentCommitsSet
1540
+ if (mode && set && POLICY_RANK[mode] > POLICY_RANK[set.mode]) {
1541
+ const what = set.mode === 'off' ? 'turned git off for its sessions' : 'lets agents push only to branches of their own in its sessions'
1542
+ throw new Error(`${ruleSetter(set)} ${what}: change it in its settings`)
1543
+ }
1544
+ if ((mode || undefined) !== this.meta.agentCommits) {
1545
+ const was = agentCommitsOf(this)
1546
+ if (mode) this.meta.agentCommits = mode; else delete this.meta.agentCommits
1547
+ this.saveMeta()
1548
+ if (agentCommitsOf(this) === 'off' && was !== 'off') this.revokeAgentTokens({ access: 'write' }) // nobody's folder can push any more, at once
1549
+ for (const [cws, a] of this.access) send(cws, jsonMessage(MSG_ACCESS, this.accessMessage(a)))
1550
+ }
1551
+ return agentCommitsOf(this)
1552
+ }
1553
+
1554
+ /** The owner sets (or clears, with '') the read-only GitHub token. Never echoed or logged. */
1555
+ setGithubToken (token) {
1556
+ const t = String(token ?? '').trim()
1557
+ if (t && !validToken(t)) throw new Error("That doesn't look like a GitHub token (ghp_… or github_pat_…).")
1558
+ if (t) this.meta.githubToken = t
1559
+ else delete this.meta.githubToken
1560
+ this.log(`[${this.name}] the owner ${t ? 'set' : 'cleared'} the GitHub token for bringing in commits`)
1561
+ this.upstreamTokenChanged()
1562
+ this.broadcastAccess() // the owner sees whether a token is set; everyone sees whether agents can commit now
1563
+ return { ok: true, githubToken: !!t }
1564
+ }
1565
+
1566
+ /**
1567
+ * A branch on GitHub a hosted agent switches to with create (quilt_switch_branch): its files at
1568
+ * the branch's head, from the repository the session follows (the agent's branch's, else any
1569
+ * branch's). { state: 'loaded', texts, blobs, skipped, bytes, upstream: { repo, name, ref, sha } }
1570
+ * or { state, repo } saying why not ('no-repo', 'not-github', 'no-branch', 'private', ...).
1571
+ */
1572
+ async branchFromGitHub (id, key) {
1573
+ const recs = upstreamsOf(this)
1574
+ const mine = recs[this.hostedBranch(id)]
1575
+ const src = mine && mine.repo ? mine : Object.values(recs).find((r) => r && r.repo)
1576
+ if (!src) return { state: 'no-repo' }
1577
+ const repo = parseRepo(`https://${src.repo}`)
1578
+ if (!repo || !repo.github) return { state: 'not-github', repo: src.repo }
1579
+ const own = validToken(this.meta.githubToken) ? this.meta.githubToken : null
1580
+ const token = own || (validToken(this.cfg.githubToken) ? this.cfg.githubToken : null)
1581
+ const r0 = await loadBranch({ repo: src.repo, ref: key, token, fetch: this.githubFetch })
1582
+ const r = r0.state === 'token-refused' && !own ? { ...r0, state: 'private' } : r0
1583
+ const remote = src.name && src.ref && src.name.endsWith(`/${src.ref}`) ? src.name.slice(0, -(src.ref.length + 1)) : 'origin'
1584
+ return { ...r, repo: src.repo, ...(r.state === 'loaded' ? { upstream: { repo: src.repo, name: `${remote}/${key}`, ref: key, sha: r.head } } : {}) }
1585
+ }
1586
+
1587
+ /** The owner set (or cleared) the read-only GitHub token: every branch is looked at afresh. */
1588
+ upstreamTokenChanged () {
1589
+ for (const rec of Object.values(upstreamsOf(this))) { delete rec.problem; delete rec.backoffUntil; delete rec.backoffMs; delete rec.etag; rec.checkedAt = 0 }
1590
+ this.saveMeta()
1591
+ this.broadcastBranches()
1592
+ if (this.cfg.upstreamAuto) this.checkUpstreams().catch(() => {})
1593
+ }
1594
+
1595
+ broadcastBranches () {
1596
+ const msg = jsonMessage(MSG_BRANCHES, { branches: this.branchList() })
1597
+ for (const ws of this.conns.keys()) send(ws, msg)
1598
+ }
1599
+
1600
+ /**
1601
+ * GitHub said `repo` (owner/name) moved `ref` (a branch name) to `sha`. Every branch of this
1602
+ * session that follows it looks now: folders on it are told to fetch (older apps ignore the
1603
+ * field and find it on their next look), and the relay checks GitHub itself for a branch no
1604
+ * folder can bring in. Returns whether any branch follows it.
1605
+ */
1606
+ /** The open clashes on every branch (relay-clash.js): timeouts and hand-offs, about once a minute, one branch at a time in its queue. */
1607
+ async reviewClashTick () {
1608
+ if (!this.cfg.gitSync || !this.mirrors || !this.meta.sync) return []
1609
+ const out = []
1610
+ for (const [key, st] of Object.entries(this.meta.sync)) {
1611
+ if (!st || !Array.isArray(st.held) || !st.held.some((h) => h.why === 'clash')) continue
1612
+ const rec = upstreamsOf(this)[key]
1613
+ const repo = rec && parseRepo(`https://${rec.repo}`)
1614
+ if (!repo) continue
1615
+ out.push(...await this.mirrors.queue(repo.slug, rec.ref, () => reviewClashes(this, key)))
1616
+ }
1617
+ return out
1618
+ }
1619
+
1620
+ /** Whether session `other` has this session's owner in it (its owner or a member): then the app may offer to open it ("Open the live session", plan §8). */
1621
+ sameOwner (other) {
1622
+ if (!other || !this.meta.ownerSub) return false
1623
+ if (this.dataDirOf) {
1624
+ try {
1625
+ const m = JSON.parse(fs.readFileSync(path.join(this.dataDirOf, `${other}.json`), 'utf8'))
1626
+ return m.ownerSub === this.meta.ownerSub || !!(m.members && m.members[this.meta.ownerSub])
1627
+ } catch {}
1628
+ }
1629
+ // On another relay machine: the accounts API knows who owns it and who has been in it (asked once, then remembered).
1630
+ if (this.cluster) return this.cluster.sameOwner(other, this.meta.ownerSub, () => { if (!this.destroyed) this.broadcastBranches() }) === true
1631
+ return false
1632
+ }
1633
+
1634
+ /** The relay pushed `sha` to branch `key`'s upstream: folders on it fetch now and follow it (no file is written: the session has it). */
1635
+ tellPushed (key, sha) {
1636
+ const msg = jsonMessage(MSG_BRANCHES, { branches: this.branchList(), pushed: { branch: key, sha } })
1637
+ for (const ws of this.conns.keys()) send(ws, msg)
1638
+ }
1639
+
1640
+ /** Session → GitHub for this room (relay-outbound.js): batches per branch, the push. */
1641
+ get outbound () {
1642
+ if (!this._outbound) this._outbound = outboundFor(this)
1643
+ return this._outbound
1644
+ }
1645
+
1646
+ /** Where the git of `repo` ({ slug, host, owner, name }) is: GitHub, or wherever tests say. */
1647
+ gitRemoteUrl (repo) {
1648
+ return this.cfg.gitRemote ? this.cfg.gitRemote(repo.slug) : `https://${repo.slug}.git`
1649
+ }
1650
+
1651
+ /** What signs the relay's git in to read `repo`: the owner's token, a member's GitHub connection, the relay's own token, or nothing (a public repository). */
1652
+ async gitToken (repo, { access = 'read', actingFor = '' } = {}) {
1653
+ return (await this.gitTokenState(repo, { access, actingFor })).token
1654
+ }
1655
+
1656
+ /** { token } for the relay's git on `repo`, or { token: null, state, why? } saying why not (plan §2: the workspace's access mode, the org's Require GitHub). */
1657
+ async gitTokenState (repo, { access = 'read', actingFor = '' } = {}) {
1658
+ if (validToken(this.meta.githubToken)) return { token: this.meta.githubToken }
1659
+ const r = await appCredentials(this, `${repo.owner}/${repo.name}`, { access, fresh: false, actingFor })
1660
+ if (r.token) return { token: r.token }
1661
+ if (r.state === 'not-allowed' || r.require) return { token: null, state: r.state, ...(r.why ? { why: r.why } : {}), ...(r.require ? { require: true } : {}) }
1662
+ return { token: validToken(this.cfg.githubToken) ? this.cfg.githubToken : null, state: r.state }
1663
+ }
1664
+
1665
+ /** A check suite finished on `repo`: the pull requests this room's tasks are in are looked at now (relay-commit.js reviewPulls). */
1666
+ noteChecks ({ repo, sha = '', branch = '', conclusion = '' }) {
1667
+ const want = String(repo).toLowerCase().replace(/^github\.com\//, '')
1668
+ const follows = Object.values(upstreamsOf(this)).some((rec) => rec && rec.repo && String(rec.repo).toLowerCase().replace(/^github\.com\//, '') === want)
1669
+ if (!follows) return false
1670
+ // Checks on a commit the relay pushed with merged files in it (relay-clash.js): failing pulls both parties in; passing lets held files go.
1671
+ if (this.cfg.gitSync && this.mirrors && /^[0-9a-f]{40}$/.test(sha)) {
1672
+ for (const [key, rec] of Object.entries(upstreamsOf(this))) {
1673
+ if (!rec || String(rec.repo).toLowerCase().replace(/^github\.com\//, '') !== want || (branch && rec.ref !== branch && !(rec.ref && branch === `quilt/live/${rec.ref}`))) continue
1674
+ const repoObj = parseRepo(`https://${rec.repo}`)
1675
+ this.mirrors.queue(repoObj.slug, rec.ref, () => noteMergeChecks(this, key, { sha, conclusion })).then((r) => { if (r.length) { this.saveMeta(); this.broadcastBranches() } }).catch((err) => this.log(`[${this.name}] ${key}: could not look at the checks on ${sha.slice(0, 7)}: ${err.message}`))
1676
+ }
1677
+ }
1678
+ reviewPulls(this, { fetch: this.githubFetch, log: this.log }).catch((err) => this.log(`[${this.name}] could not look at the pull requests: ${err.message}`))
1679
+ // CI failing on a branch the session follows is a task on the board until it passes again (relay-ci.js). Kept in
1680
+ // the room's meta until noted, so a restart meanwhile (a deploy) notes it when the room is back.
1681
+ if (branch && sha) {
1682
+ const ev = { repo, sha, branch, conclusion, at: Date.now() }
1683
+ this.meta.ciPending = [...(Array.isArray(this.meta.ciPending) ? this.meta.ciPending : []), ev].slice(-50)
1684
+ this.saveMeta()
1685
+ this.runCi(ev)
1686
+ }
1687
+ return true
1688
+ }
1689
+
1690
+ /** Notes CI result `ev` on the board (relay-ci.js), then drops it from the room's pending ones: unless the room shut down first. */
1691
+ runCi (ev) {
1692
+ noteCiResult(this, ev, { fetch: this.githubFetch, log: this.log }).finally(() => {
1693
+ if (this.destroyed) return
1694
+ const same = (x) => x && x.sha === ev.sha && x.branch === ev.branch && x.conclusion === ev.conclusion && x.at === ev.at
1695
+ this.meta.ciPending = (Array.isArray(this.meta.ciPending) ? this.meta.ciPending : []).filter((x) => !same(x))
1696
+ this.saveMeta()
1697
+ })
1698
+ }
1699
+
1700
+ /** GitHub says pull request `number` on `repo` changed: looked at now when this room follows that repository (relay-commit.js reviewPulls). */
1701
+ notePull ({ repo, number, action = '', merged = false, head = '', base = '', sha = '' }) {
1702
+ const want = String(repo).toLowerCase().replace(/^github\.com\//, '')
1703
+ const recs = upstreamsOf(this)
1704
+ const follows = Object.values(recs).some((rec) => rec && rec.repo && String(rec.repo).toLowerCase().replace(/^github\.com\//, '') === want)
1705
+ if (!follows) return false
1706
+ // §5.6: the pull request's source branch, when the session has it, is marked merged (the branch list says so; a deletion on GitHub then moves its members to the base).
1707
+ if (merged && head) {
1708
+ const key = this.resolveKey ? this.resolveKey(head) : head
1709
+ const rec = recs[key]
1710
+ if (rec && String(rec.repo).toLowerCase().replace(/^github\.com\//, '') === want && rec.ref === head) {
1711
+ rec.merged = { at: Date.now(), into: base || '', number, ...(sha ? { sha } : {}) }
1712
+ this.saveMeta(); this.broadcastBranches()
1713
+ }
1714
+ }
1715
+ // Addendum 16.5: the live pull request of a protected branch (or of a session without the branch's lease).
1716
+ if (this.cfg.gitSync && this.meta.sync) {
1717
+ for (const [key, rec] of Object.entries(recs)) {
1718
+ const p = this.meta.sync[key] && this.meta.sync[key].protected
1719
+ if (!rec || !p || !p.pr || p.pr.number !== number || String(rec.repo).toLowerCase().replace(/^github\.com\//, '') !== want) continue
1720
+ if (merged) {
1721
+ p.merged = { at: Date.now(), ...(sha ? { sha } : {}) }
1722
+ rec.problem = `${rec.ref} is protected: the live pull request #${number} is merged; commits go through a new one (opened with the next commit)`
1723
+ this.checkUpstreamsNow([key]).catch((err) => this.log(`[${this.name}] could not look for new commits: ${err.message}`))
1724
+ } else if (action === 'closed') {
1725
+ p.closed = Date.now()
1726
+ rec.problem = `${rec.ref} is protected: pull request #${number} was closed without merging; the session keeps its work, and a new pull request opens with the next commit`
1727
+ } else if (action === 'reopened') { delete p.closed; delete p.askedFor }
1728
+ this.saveMeta(); this.broadcastBranches()
1729
+ }
1730
+ }
1731
+ reviewPulls(this, { fetch: this.githubFetch, log: this.log, only: number }).catch((err) => this.log(`[${this.name}] could not look at pull request #${number}: ${err.message}`))
1732
+ return true
1733
+ }
1734
+
1735
+ /**
1736
+ * GitHub said `repo` moved `ref` (a branch): `after` is its new head (`sha` from an older API),
1737
+ * `before` the old one, `forced` a rewrite, `deleted` or `created` the branch itself. Every
1738
+ * branch of this session that follows it looks now: folders on it are told, and the relay reads
1739
+ * the truth from git (only the ref name is trusted). A push whose head is the one the relay
1740
+ * itself pushed is an echo, and ignored. Returns 'echo', true (a branch follows it) or false.
1741
+ */
1742
+ notePush ({ repo, ref, sha = '', after = '', before = '', forced = false, deleted = false, created = false }) {
1743
+ const want = String(repo).toLowerCase().replace(/^github\.com\//, '')
1744
+ const head = typeof after === 'string' && after ? after : (typeof sha === 'string' ? sha : '')
1745
+ let hit = false
1746
+ let echo = false
1747
+ for (const [key, rec] of Object.entries(upstreamsOf(this))) {
1748
+ if (!rec || !rec.repo || rec.ref !== ref || String(rec.repo).toLowerCase().replace(/^github\.com\//, '') !== want) continue
1749
+ hit = true
1750
+ rec.webhookAt = Date.now()
1751
+ if (deleted) { this.noteBranchDeleted(key, rec); continue } // §5.4
1752
+ if (rec.gone && (head || created)) { delete rec.gone; delete rec.problem; this.saveMeta() } // pushed again: the branch is back on GitHub
1753
+ if (created && !head) { this.saveMeta(); continue } // a branch made on GitHub: in the menu, nothing to bring in yet
1754
+ const sync = this.cfg.gitSync && this.meta.sync && this.meta.sync[key]
1755
+ if (head && sync && sync.pushed === head && sync.base === head) { echo = true; this.saveMeta(); continue } // our own push, come back round
1756
+ const at = this.cfg.gitSync && this.mirrors && this.meta.sync && this.meta.sync[key] && this.meta.sync[key].base ? this.meta.sync[key].base : rec.sha
1757
+ if (head && at === head) { this.saveMeta(); continue } // already there
1758
+ const msg = jsonMessage(MSG_BRANCHES, { branches: this.branchList(), pushed: { branch: key, sha: head, ...(forced ? { forced: true } : {}) } })
1759
+ for (const ws of this.conns.keys()) send(ws, msg)
1760
+ this.checkUpstreamsNow([key]).catch((err) => this.log(`[${this.name}] could not look for new commits: ${err.message}`))
1761
+ }
1762
+ // §5.4: a branch created on GitHub that the session doesn't have appears in the branch menu, not synced until someone opens it.
1763
+ if (created && !hit && ref && validBranchKey(ref) && !this.meta.branches[this.resolveKey ? this.resolveKey(ref) : ref]) {
1764
+ const follows = Object.values(upstreamsOf(this)).some((rec) => rec && rec.repo && String(rec.repo).toLowerCase().replace(/^github\.com\//, '') === want)
1765
+ if (follows) {
1766
+ if (!this.meta.remoteBranches || typeof this.meta.remoteBranches !== 'object') this.meta.remoteBranches = {}
1767
+ this.meta.remoteBranches[ref] = { at: Date.now() }
1768
+ this.saveMeta(); this.broadcastBranches()
1769
+ return true
1770
+ }
1771
+ }
1772
+ if (deleted && !hit && this.meta.remoteBranches && this.meta.remoteBranches[ref]) { delete this.meta.remoteBranches[ref]; this.saveMeta(); this.broadcastBranches(); return true }
1773
+ return hit ? (echo ? 'echo' : true) : false
1774
+ }
1775
+
1776
+ /**
1777
+ * A synced branch was deleted on GitHub (§5.4). After its pull request merged, its members are moved to the
1778
+ * branch it merged into (hosted agents are moved; people's folders are told) and told. Otherwise the session
1779
+ * keeps the branch: it comes back with the next commit, unless the owner turned that off (recreateDeleted).
1780
+ */
1781
+ noteBranchDeleted (key, rec) {
1782
+ const now = Date.now()
1783
+ rec.gone = now
1784
+ const merged = rec.merged && rec.merged.into ? rec.merged : null
1785
+ if (merged) {
1786
+ const into = this.resolveKey ? this.resolveKey(merged.into) : merged.into
1787
+ const where = this.meta.branches[into] ? into : this.defaultKey
1788
+ rec.problem = `${rec.ref} was merged into ${merged.into} (pull request #${merged.number}) and deleted on GitHub`
1789
+ const moved = []
1790
+ for (const [id, k] of Object.entries(this.meta.hostedBranch || {})) {
1791
+ if (k !== key) continue
1792
+ const m = this.meta.members[id]
1793
+ try { this.setHostedBranch(id, where, {}); moved.push(m && m.name) } catch {}
1794
+ }
1795
+ for (const ws of this.conns.keys()) if (ws.branch === key && this.names && this.names.get(ws) && !moved.includes(this.names.get(ws))) moved.push(this.names.get(ws))
1796
+ if (this.postChat) for (const name of [...new Set(moved.filter(Boolean))]) this.postChat({ by: RELAY_BY, to: name, kind: 'nudge', text: `${rec.ref} was merged into ${merged.into} and deleted on GitHub (pull request #${merged.number}): you are on ${where} now${this.meta.hostedBranch && Object.values(this.meta.hostedBranch).includes(where) ? '' : ' (a folder: git checkout ' + where + ')'}.` })
1797
+ } else if (this.meta.recreateDeleted === true) {
1798
+ rec.problem = `${rec.ref} was deleted on GitHub: the session keeps it, and it comes back with the next commit (the owner's choice)`
1799
+ } else {
1800
+ // Addendum 16.10: a deletion on GitHub is meant; publication pauses, the work stays in the session.
1801
+ rec.problem = `${rec.ref} was deleted on GitHub: the session keeps it and its work, and nothing is pushed there unless the owner lets Quilt recreate deleted branches`
1802
+ }
1803
+ this.log(`[${this.name}] ${key}: ${rec.problem}`)
1804
+ this.saveMeta(); this.broadcastBranches()
1805
+ }
1806
+
1807
+ /**
1808
+ * A connection's branch request; returns the reply fields. join: subscribe to a branch's
1809
+ * document (one at a time), adding the branch to the session when new. confirm: the relay
1810
+ * holds everything the app has of the branch it joined, up to the app's state vector (an
1811
+ * app asks before it clears its folder for a switch).
1812
+ */
1813
+ branchRequest (ws, req) {
1814
+ const key = String(req.branch ?? '')
1815
+ if (!validBranchKey(key)) throw new Error(`"${key.slice(0, 60)}" isn't a branch name`)
1816
+ if (req.op === 'join') {
1817
+ this.assertMigrated()
1818
+ // An app from before branch documents names the branch the old document was; else the first real branch takes ∅.
1819
+ let adopted = false
1820
+ if (validBranchKey(String(req.adopt || ''))) adopted = this.adoptDefault(String(req.adopt))
1821
+ adopted = this.adoptDefault(key) || adopted
1822
+ const k = this.resolveKey(key)
1823
+ const created = !this.meta.branches[k]
1824
+ if (created) {
1825
+ const a = this.access.get(ws)
1826
+ // A new branch starts as a copy of the folder, about the size of the branch it is on now.
1827
+ this.assertBranchRoom(!(a && a.role === 'viewer'), ws.branch && ws.branch !== k ? this.store.size(ws.branch) : 0)
1828
+ }
1829
+ try { this.store.load(k) } catch (err) {
1830
+ if (!err.unreadable) throw err
1831
+ this.log(`[${this.name}] ${err.message}`)
1832
+ throw new Error("This branch's data can't be read on the relay right now")
1833
+ }
1834
+ if (ws.branch && ws.branch !== k) {
1835
+ // Kept a moment longer: a sync message the app sent for it just before switching,
1836
+ // still in flight, is applied rather than silently dropped (handle, MSG_SYNC).
1837
+ this.store.unsubscribe(ws, ws.branch)
1838
+ ws.prevBranch = ws.branch
1839
+ }
1840
+ this.store.subscribe(ws, k)
1841
+ ws.branch = k // the document, as the relay keeps it
1842
+ ws.branchAs = key // as the app named it (∅ without git): its sync messages carry this
1843
+ this.noteBranch(k, { by: this.names.get(ws) || '', base: req.base })
1844
+ this.noteBranchSeen(k, { force: true, used: true })
1845
+ // Everyone hears a new branch, and a default branch that just took its real name.
1846
+ return { ok: true, branch: k, created, base: this.meta.branches[k].base, epoch: this.branchEpoch(k), listChanged: created || adopted }
1847
+ }
1848
+ if (req.op === 'confirm') {
1849
+ const k = this.resolveKey(key)
1850
+ const e = k === ws.branch ? this.store.get(k) : null
1851
+ if (!e) throw new Error(`you are not on ${key}`)
1852
+ let want
1853
+ try {
1854
+ want = new Uint8Array(Buffer.from(String(req.sv || ''), 'base64'))
1855
+ Y.decodeStateVector(want)
1856
+ } catch { throw new Error('that is not a state vector') }
1857
+ if (!covers(Y.encodeStateVector(e.doc), want)) throw new Error(`not all of your changes on ${key} have reached the relay yet`)
1858
+ return { ok: true, branch: k }
1859
+ }
1860
+ if (req.op === 'remove') {
1861
+ const a = this.access.get(ws)
1862
+ if (!a || (this.controlled && !a.owner)) throw new Error('only the session owner can remove a branch')
1863
+ const k = this.resolveKey(key)
1864
+ if (!this.meta.branches[k]) throw new Error(`${key} isn't in this session`)
1865
+ if (k === this.defaultKey) throw new Error(`${key} is the session's default branch; it stays`)
1866
+ const on = [...this.conns.keys()].filter((c) => c.branch === k).map((c) => this.names.get(c))
1867
+ if (on.length) throw new Error(`${[...new Set(on)].join(', ')} ${on.length === 1 ? 'is' : 'are'} on ${key}; it can be removed once nobody is`)
1868
+ this.removeBranch(k)
1869
+ return { ok: true, branch: k, listChanged: true }
1870
+ }
1871
+ throw new Error('unknown branch request')
1872
+ }
1873
+
1874
+ /** Can this connection's app handle the session as it is now? */
1875
+ supported (ws) {
1876
+ return !this.meta.largeFiles || (ws.features || []).includes('large-files')
1877
+ }
1878
+
1879
+ /**
1880
+ * The session now keeps large files in storage. Older apps would get
1881
+ * entries with no contents, so they're sent away to update.
1882
+ */
1883
+ startLargeFiles () {
1884
+ if (this.meta.largeFiles) return
1885
+ this.meta.largeFiles = true
1886
+ for (const ws of [...this.conns.keys(), ...this.pending.keys()]) {
1887
+ if (!ws.hosted && !this.supported(ws)) { ws.endReason = 'needs_update'; ws.close(CLOSE_NEEDS_UPDATE, NEEDS_UPDATE) }
1888
+ }
1889
+ }
1890
+
1891
+ get admitBy () { return cleanAdmitBy(this.meta.admitBy) || DEFAULT_ADMIT_BY }
1892
+
1893
+ /** May this connection's access let people in, under the room's setting? */
1894
+ personCanAdmit (a) { return canAdmit(a, this.admitBy) }
1895
+
1896
+ accessMessage (a) {
1897
+ // The owner learns whether a GitHub token is set (never the token itself); nobody else hears of it.
1898
+ return { state: 'approved', role: a.role, scopes: a.scopes || [], scopesExcept: a.scopesExcept || [], talk: a.talk !== false, owner: !!a.owner, controlled: this.controlled, admitBy: this.admitBy, canAdmit: this.personCanAdmit(a), agentCommits: agentCommitsOf(this), ...(this.meta.agentCommitsSet ? { agentCommitsBy: { by: this.meta.agentCommitsSet.by, name: this.meta.agentCommitsSet.name, mode: this.meta.agentCommitsSet.mode } } : {}), agentsCanCommit: agentsCanCommit(this, this.defaultKey), ...(this.cfg.gitSync ? { githubSync: this.meta.githubSync !== false, largeToRelay: this.meta.largeToRelay === true } : {}), ...(a.owner ? { githubToken: !!this.meta.githubToken } : {}) }
1899
+ }
1900
+
1901
+ /** Tracks restricted connections so their file changes are checked. */
1902
+ setAccess (ws, a) {
1903
+ this.access.set(ws, a)
1904
+ // With live sync to GitHub, agents' changes are checked too: a file held for a clash is theirs to propose, not to write (relay-clash.js).
1905
+ const restricted = a.role === 'viewer' || (a.scopes && a.scopes.length) || (a.scopesExcept && a.scopesExcept.length) || a.talk === false || (a.kind === 'agent' && !!this.cfg.gitSync)
1906
+ if (restricted) this.guard.trackedOrigins.add(ws)
1907
+ else this.guard.trackedOrigins.delete(ws)
1908
+ }
1909
+
1910
+ memberList () {
1911
+ const online = new Map()
1912
+ for (const a of this.access.values()) online.set(a.owner ? this.ownerId : a.id, true)
1913
+ const http = new Map() // member id -> its last check-in, for agents over HTTP
1914
+ for (const h of this.hostedOnline()) { online.set(h.id, true); http.set(h.id, h.seen) }
1915
+ const list = []
1916
+ if (this.meta.owner) {
1917
+ const ownerName = this.meta.ownerName || Object.entries(this.meta.identities).find(([, k]) => k === this.meta.owner)?.[0] || 'owner'
1918
+ list.push({ key: this.ownerId, name: ownerName, kind: 'human', role: 'owner', scopes: [], online: online.has(this.ownerId), ...this.awayOf(this.ownerId, online), from: { kind: 'owner' } })
1919
+ }
1920
+ pruneChatLinks(this) // chat links that ran out leave the list
1921
+ for (const [key, m] of Object.entries(this.meta.members)) list.push({ key, name: m.name, kind: m.kind, ...memberAccess(m), online: online.has(key), ...(http.has(key) ? { http: true, lastSeen: http.get(key) } : this.awayOf(key, online)), ...(m.chat ? { chat: true, expiresAt: m.expiresAt } : {}), from: m.chat ? { kind: 'chat' } : m.from || { kind: m.granted ? 'grant' : 'approved' } })
1922
+ return list
1923
+ }
1924
+
1925
+ /** { away, lastSeen } for an agent between turns (see noteTurns). */
1926
+ awayOf (key, online) {
1927
+ const at = online.has(key) ? null : this.awaySince(key)
1928
+ return at ? { away: true, lastSeen: at } : {}
1929
+ }
1930
+
1931
+ pendingList () {
1932
+ return [...this.pending.values()].map((p) => ({ key: p.id, name: p.name, kind: p.kind, invitedAs: p.invitedAs, since: p.since }))
1933
+ }
1934
+
1935
+ /** Sends everyone the member list; people who may let others in also see who's waiting. */
1936
+ broadcastMembers (replyTo = null, reply = null) {
1937
+ if (!this.controlled) return
1938
+ const members = this.memberList()
1939
+ const pending = this.pendingList()
1940
+ const admitBy = this.admitBy
1941
+ for (const [ws, a] of this.access) {
1942
+ const msg = { members, sessionName: this.meta.name || '', admitBy, pending: this.personCanAdmit(a) ? pending : [], ...(ws === replyTo && reply ? { reply } : {}) }
1943
+ send(ws, jsonMessage(MSG_MEMBERS, msg))
1944
+ }
1945
+ }
1946
+
1947
+ /** Changes to who's in the room. Letting people in follows admitBy; the rest is owner-only. Returns the reply fields. */
1948
+ adminRequest (ws, req) {
1949
+ const me = this.access.get(ws)
1950
+ if (!me) throw new Error('only the session owner can do that')
1951
+ if (req.op === 'admitBy') {
1952
+ if (!me.owner) throw new Error('only the session owner can do that')
1953
+ const next = cleanAdmitBy(req.admitBy)
1954
+ if (!next) throw new Error(BAD_ADMIT_BY)
1955
+ if (next !== this.admitBy) {
1956
+ this.meta.admitBy = next
1957
+ this.saveMeta()
1958
+ // Everyone's access follows the new setting (canAdmit / admitBy on the access message).
1959
+ for (const [cws, a] of this.access) send(cws, jsonMessage(MSG_ACCESS, this.accessMessage(a)))
1960
+ }
1961
+ return { ok: true }
1962
+ }
1963
+ if (['syncUpstream', 'commit', 'gitToken', 'pulls', 'merge', 'githubRecheck', 'unpublished', 'finishTask'].includes(req.op)) throw new Error(`${req.op} is answered by itself`) // see the MSG_ADMIN handler
1964
+ // A folder's git pushed: what went up goes on record, and the chat hears it (relay-commit.js).
1965
+ if (req.op === 'pushed') {
1966
+ if (!ws.branch) throw new Error('not in this session')
1967
+ return notePushedFor(this, { who: { name: me.name, kind: me.kind === 'agent' ? 'agent' : 'human', access: me }, key: ws.branch, branch: String(req.branch || ''), sha: String(req.sha || ''), count: req.count, files: req.files, subject: typeof req.subject === 'string' ? req.subject : '' }, { fetch: this.githubFetch, log: this.log })
1968
+ }
1969
+ if (req.op === 'githubSync') {
1970
+ // The owner's "Sync with GitHub" switch (plan §15): on by default once a connection covers the repository.
1971
+ if (!me.owner) throw new Error('only the session owner can do that')
1972
+ this.meta.githubSync = req.on !== false
1973
+ this.saveMeta(); this.broadcastBranches()
1974
+ for (const [cws, acc] of this.access) send(cws, jsonMessage(MSG_ACCESS, this.accessMessage(acc)))
1975
+ return { ok: true, githubSync: this.meta.githubSync }
1976
+ }
1977
+ if (req.op === 'largeToRelay') {
1978
+ // Large files on branches that sync with GitHub (plan §10): the owner agrees that the relay may read them, so it can commit them.
1979
+ if (!me.owner) throw new Error('only the session owner can do that')
1980
+ this.meta.largeToRelay = req.on === true
1981
+ this.saveMeta(); this.broadcastBranches()
1982
+ for (const [cws, acc] of this.access) send(cws, jsonMessage(MSG_ACCESS, this.accessMessage(acc)))
1983
+ return { ok: true, largeToRelay: this.meta.largeToRelay }
1984
+ }
1985
+ if (req.op === 'agentCommits') {
1986
+ // What agents may do with commits (relay-commit.js): the owner's call.
1987
+ if (!me.owner) throw new Error('only the session owner can do that')
1988
+ return { ok: true, agentCommits: this.setAgentCommits(req.agentCommits) }
1989
+ }
1990
+ if (req.op === 'workspace') {
1991
+ // Which workspace the session is in (the owner's app knows): webhook events say so, so an agent in several can tell them apart.
1992
+ if (!me.owner) throw new Error('only the session owner can do that')
1993
+ const id = String(req.id || '').slice(0, 64)
1994
+ if ((this.meta.workspace || '') !== id) {
1995
+ this.meta.workspace = id || undefined
1996
+ this.saveMeta()
1997
+ // The app links the session to the workspace on the accounts API first: its git rule applies now.
1998
+ this.refreshPolicy()
1999
+ }
2000
+ return { ok: true, workspace: this.meta.workspace || '' }
2001
+ }
2002
+ // A chat link lets a chat AI in, so whoever may let people in may make one, and set how long it lasts.
2003
+ if (req.op === 'approve' || req.op === 'deny' || req.op === 'chatlink' || req.op === 'chatextend') {
2004
+ if (!this.personCanAdmit(me)) throw new Error('you cannot let people into this session')
2005
+ } else if (!me.owner) {
2006
+ throw new Error('only the session owner can do that')
2007
+ }
2008
+ if (req.op === 'name') {
2009
+ // The owner's app names the session after its folder, and the owner can rename it.
2010
+ const name = cleanSessionName(req.name)
2011
+ if (!name) throw new Error(BAD_SESSION_NAME)
2012
+ // Re-sending the same name (the app does this on every launch) should write nothing
2013
+ // and report nothing: only an actual rename is news.
2014
+ if (name !== this.meta.name) {
2015
+ // A new session's first name (its folder's, sent as it starts) isn't a rename: the
2016
+ // owner may rename it straight away.
2017
+ if (this.meta.name) {
2018
+ if (Date.now() - this.lastRenameAt < RENAME_MS) throw new Error('Renaming too fast; try again in a moment')
2019
+ this.lastRenameAt = Date.now()
2020
+ }
2021
+ this.meta.name = name
2022
+ this.saveMeta()
2023
+ if (this.presence) this.presence.rename({ room: this.name, name })
2024
+ }
2025
+ return { ok: true }
2026
+ }
2027
+ if (req.op === 'chatlink') {
2028
+ // A link a chat-only AI (ChatGPT, claude.ai, Grok…) works through by opening pages (chat-links.js).
2029
+ // It joins as a member of its own; the token is in this reply only, to whoever asked.
2030
+ const l = makeChatLink(this, { name: req.name, minutes: req.minutes, by: me.name })
2031
+ this.log(`[${this.name}] chat link made for ${l.name}`)
2032
+ return { ok: true, token: l.token, name: l.name, expiresAt: l.expiresAt }
2033
+ }
2034
+ if (req.op === 'chatextend') {
2035
+ // How long a chat link still works, from now. One that ran out is gone: a new link is needed.
2036
+ const l = extendChatLink(this, String(req.key || ''), req.minutes)
2037
+ return { ok: true, name: l.name, expiresAt: l.expiresAt }
2038
+ }
2039
+ if (req.op === 'githubToken') {
2040
+ // A read-only token the relay uses to bring commits in from a private repository while no
2041
+ // folder is online (relay-upstream.js). Kept on the relay only: never in presence, the
2042
+ // branch list, a reply or the log. An empty token clears it.
2043
+ return this.setGithubToken(req.token)
2044
+ }
2045
+ if (req.op === 'end') {
2046
+ // Reply first; the relay then sends everyone away and deletes the room.
2047
+ setTimeout(() => this.onEnd && this.onEnd(), 50)
2048
+ return { ok: true }
2049
+ }
2050
+ const key = String(req.key || '')
2051
+ const role = ROLES.includes(req.role) ? req.role : null
2052
+ const scopes = Array.isArray(req.scopes)
2053
+ ? req.scopes.map((s) => String(s).trim().replace(/^\.\//, '').replace(/\/+$/, '')).filter(Boolean).slice(0, MAX_SCOPES)
2054
+ : null
2055
+ if (scopes && scopes.some((s) => s.length > MAX_PATTERN || s.split('/').includes('..'))) throw new Error('bad folder')
2056
+ // Apps with access types send the access itself ({ files, folders, foldersExcept, talk }),
2057
+ // and the type it came from (typeId), which the relay doesn't need: the API keeps grants.
2058
+ const requested = req.access === undefined ? null : cleanAccess(req.access)
2059
+ if (req.access !== undefined && !requested) throw new Error('bad access')
2060
+ if (key === this.meta.owner || key === this.meta.ownerSub) throw new Error('the owner always has full access')
2061
+ // `key` is the id from the member or pending list. One account may be waiting on several computers.
2062
+ const waiting = [...this.pending].filter(([, p]) => p.id === key)
2063
+ if (req.op === 'approve') {
2064
+ if (!waiting.length) throw new Error('nobody with that key is waiting')
2065
+ const p = waiting[0][1]
2066
+ const access = requested ? relayAccess(requested) : { role: role || p.invitedAs, scopes: scopes || [], scopesExcept: [], talk: true }
2067
+ // Let in as a type: their access lives in the API now (see storedMember).
2068
+ this.meta.members[key] = { name: p.name, kind: p.kind, ...access, since: Date.now(), ...(req.typeId ? { granted: true } : {}) }
2069
+ if (this.meta.removed) delete this.meta.removed[key]
2070
+ this.saveMeta()
2071
+ this.log(`[${this.name}] ${p.name} approved as ${access.role}`)
2072
+ for (const [pws, w] of waiting) {
2073
+ this.pending.delete(pws)
2074
+ // A hosted agent has no connection to let in: it finds out on its next tool call.
2075
+ if (pws.hosted) continue
2076
+ this.enter(pws, { key: w.key, id: key, name: w.name, kind: w.kind, ...access, owner: false })
2077
+ // Their app fetches a fresh pass now, so the grant the owner's app just wrote applies.
2078
+ send(pws, jsonMessage(MSG_ACCESS, { ...this.accessMessage(this.access.get(pws)), refresh: true }))
2079
+ }
2080
+ return { ok: true }
2081
+ }
2082
+ if (req.op === 'deny') {
2083
+ if (!waiting.length) throw new Error('nobody with that key is waiting')
2084
+ for (const [pws, w] of waiting) {
2085
+ this.pending.delete(pws)
2086
+ // A hosted agent finds out on its next tool call; relay.hosted remembers it, across restarts.
2087
+ if (pws.hosted) { if (this.onHostedDenied) this.onHostedDenied(w.id); continue }
2088
+ pws.close(CLOSE_DENIED, 'The session owner did not let you in')
2089
+ }
2090
+ return { ok: true }
2091
+ }
2092
+ const m = this.meta.members[key]
2093
+ if (!m) throw new Error('no such member')
2094
+ if (req.op === 'set') {
2095
+ const want = requested || { ...fromRelay(m), ...(role ? { files: role === 'viewer' ? 'view' : 'edit' } : {}), ...(scopes ? { folders: scopes } : {}) }
2096
+ // Someone let in as a type keeps what both their record (their last pass) and the owner
2097
+ // allow: more needs a fresh pass from the API. Older members get what the owner asks.
2098
+ const stored = m.granted ? narrowAccess(want, fromRelay(m)) : want
2099
+ // Remembered with when, so a pass issued before now can't undo it (see passGrant).
2100
+ Object.assign(m, relayAccess(stored), { setAt: Date.now() })
2101
+ this.saveMeta()
2102
+ for (const [cws, a] of this.access) {
2103
+ if (a.id !== key) continue
2104
+ // Someone let in by a grant gets what both their pass and the owner allow: the owner's
2105
+ // app can narrow access at once, but more waits for a pass from the API that allows it.
2106
+ Object.assign(a, relayAccess(this.passGrant(cws.pass) || stored))
2107
+ this.setAccess(cws, a)
2108
+ send(cws, jsonMessage(MSG_ACCESS, { ...this.accessMessage(a), refresh: true }))
2109
+ }
2110
+ return { ok: true }
2111
+ }
2112
+ if (req.op === 'remove') {
2113
+ // An account goes with any older entries for keys it has used here, so it can't get back in by key.
2114
+ const gone = [key, ...((this.meta.accountKeys || {})[key] || [])]
2115
+ // Their claims go with them: nobody left could release them.
2116
+ const names = gone.map((id) => this.meta.members[id]?.name).filter(Boolean)
2117
+ const theirs = (x) => x.byId ? gone.includes(x.byId) : names.includes(x.by)
2118
+ this.dropRequests(theirs)
2119
+ this.dropClaims(theirs, (c) => `${c.by} was removed from the session.`)
2120
+ for (const n of names) this.revokeAgentTokens({ name: n }) // a removed agent's folder can't push or fetch any more
2121
+ for (const id of gone) this.revokeAgentTokens({ account: id }) // nor a removed person's
2122
+ for (const id of gone) delete this.meta.members[id]
2123
+ // A pass issued before now can't bring them back by its grant (see passGrant). Removals
2124
+ // older than a pass lasts can match no valid pass, so they go; the newest are kept.
2125
+ const cutoff = Date.now() - PASS_TTL_MS
2126
+ const removed = Object.entries({ ...(this.meta.removed || {}), [key]: Date.now() }).filter(([, at]) => at > cutoff).sort((a, b) => a[1] - b[1])
2127
+ this.meta.removed = Object.fromEntries(removed.slice(-MAX_REMOVED))
2128
+ this.saveMeta()
2129
+ for (const [cws, a] of this.access) if (gone.includes(a.id)) { cws.endReason = 'removed'; cws.close(CLOSE_DENIED, 'The session owner removed you') }
2130
+ for (const id of gone) { this.hostedSeen.delete(id); this.forgetHostedBranch(id); if (this.onHostedGone) this.onHostedGone(id, 'removed') }
2131
+ this.broadcastClaims()
2132
+ return { ok: true }
2133
+ }
2134
+ throw new Error('unknown request')
2135
+ }
2136
+
2137
+ touch () {
2138
+ this.meta.lastActive = Date.now()
2139
+ this.saveMeta()
2140
+ }
2141
+
2142
+ /** A name belongs to the first key that signs in with it. */
2143
+ keyMatches (name, publicKey) {
2144
+ const known = this.meta.identities[name]
2145
+ return !known || known === publicKey
2146
+ }
2147
+
2148
+ bindName (name, publicKey) {
2149
+ if (this.meta.identities[name]) return
2150
+ this.meta.identities[name] = publicKey
2151
+ this.saveMeta()
2152
+ }
2153
+
2154
+ /** Presence may only describe the sender, under their verified name. */
2155
+ samePerson (a, b) {
2156
+ const x = this.access.get(a)
2157
+ const y = this.access.get(b)
2158
+ return !!(x && y && x.key === y.key && x.name === y.name)
2159
+ }
2160
+
2161
+ presenceAllowed (ws, update) {
2162
+ const name = this.names.get(ws)
2163
+ const dec = decoding.createDecoder(update)
2164
+ const n = decoding.readVarUint(dec)
2165
+ for (let i = 0; i < n; i++) {
2166
+ const id = decoding.readVarUint(dec)
2167
+ decoding.readVarUint(dec) // clock
2168
+ const state = JSON.parse(decoding.readVarString(dec))
2169
+ for (const [other, ids] of this.conns) {
2170
+ if (other === ws || !ids.has(id)) continue
2171
+ // The same person over a new connection: they're back from a drop the relay hasn't
2172
+ // noticed yet (its heartbeat takes up to a minute). The old connection is dead; let
2173
+ // it go now, or they'd stay unseen until it does.
2174
+ if (this.samePerson(other, ws)) { ids.delete(id); other.endReason = 'replaced'; other.terminate(); continue }
2175
+ return false
2176
+ }
2177
+ if (state !== null && state.name !== name) return false
2178
+ // Only a person's cloud AI says whose AI it is, and only its own person's (its pass says so).
2179
+ if (state !== null && state.aiOf !== undefined && state.aiOf !== (ws.pass && ws.pass.of ? ws.pass.ofName : undefined)) return false
2180
+ }
2181
+ return true
2182
+ }
2183
+
2184
+ /** Who a claim from this connection belongs to: their name, or with sign-in on, their account; on the branch they're on. */
2185
+ claimant (ws, as = null) {
2186
+ const name = this.names.get(ws)
2187
+ const a = this.access.get(ws)
2188
+ const who = ws.pass ? { name, id: `${ws.pass.kind}:${ws.pass.sub}`, owner: !!(a && a.owner), talk: !(a && a.talk === false), branch: ws.branch } : { name, branch: ws.branch }
2189
+ // One of several AI sessions working through this person's app (persona.js): its claims are
2190
+ // its own, under "<first name> · <label>" and the account plus the session's id. Nobody can
2191
+ // claim as someone else's: the name is built here from the connection's own.
2192
+ if (!as || typeof as.id !== 'string' || !/^[A-Za-z0-9_-]{4,40}$/.test(as.id)) return who
2193
+ return { ...who, name: personaName(name, as.label), ...(who.id ? { id: `${who.id}~${as.id}` } : {}), persona: true, of: name }
2194
+ }
2195
+
2196
+ /** Which branch a claim is on: its own, or the default branch (claims from before branches). */
2197
+ branchOf (c) { return c.branch ? this.resolveKey(c.branch) : this.defaultKey }
2198
+
2199
+ /** Where a claim on `pattern` on `branch` is kept in meta.claims: the default branch keeps bare paths. */
2200
+ claimKey (branch, pattern) {
2201
+ const b = this.resolveKey(branch || this.defaultKey)
2202
+ return b === this.defaultKey ? pattern : `${b}\0${pattern}`
2203
+ }
2204
+
2205
+ shownClaim (c) { return { ...c, queue: c.queue || [], active: this.holderPresent(c), activeAt: this.lastActive(c) } }
2206
+
2207
+ /**
2208
+ * The claims on one branch (the default when none is given), each with `active` (whoever
2209
+ * holds it is in the session now), `activeAt` (when they last did something there) and its
2210
+ * file queue: who asked for it next ({ id, path, by, title, description, task, ts }), oldest first.
2211
+ */
2212
+ claimList (branch) {
2213
+ const b = this.resolveKey(branch || this.defaultKey)
2214
+ return Object.values(this.meta.claims).filter((c) => this.branchOf(c) === b).map((c) => this.shownClaim(c)).sort((x, y) => x.ts - y.ts)
2215
+ }
2216
+
2217
+ /** Every claim on every branch. */
2218
+ allClaims () { return Object.values(this.meta.claims).map((c) => this.shownClaim(c)).sort((x, y) => x.ts - y.ts) }
2219
+
2220
+ /** The files on a branch, for telling whether two patterns overlap (and the room document's, in rooms from before branches). */
2221
+ branchPaths (branch) {
2222
+ const e = this.store.load(this.resolveKey(branch || this.defaultKey))
2223
+ return [...e.files.keys(), ...e.blobs.keys(), ...this.doc.getMap('files').keys(), ...this.doc.getMap('blobs').keys()]
2224
+ }
2225
+
2226
+ /** Who holds a claim: their account with sign-in on, otherwise their name. */
2227
+ holderKey (c) { return c.byId || `name:${c.by}` }
2228
+
2229
+ /** The holder keys a connection speaks for: its account, and its name (claims older than sign-in). */
2230
+ holderKeys (ws) {
2231
+ const keys = [`name:${this.names.get(ws)}`]
2232
+ if (ws.pass) keys.push(`${ws.pass.kind}:${ws.pass.sub}`)
2233
+ return keys
2234
+ }
2235
+
2236
+ /** The holder key of a claimant (see holderKey): an AI session's activity is its own, not its person's. */
2237
+ claimantKey (who) { return who.id || `name:${who.name}` }
2238
+
2239
+ /** Someone did something in the session: their claims' idle time starts again. */
2240
+ noteActivity (keys) {
2241
+ const now = Date.now()
2242
+ let save = false
2243
+ for (const k of keys) {
2244
+ if (!Object.values(this.meta.claims).some((c) => this.holderKey(c) === k)) continue
2245
+ // Saved now and then, so a relay restart doesn't count them idle for longer than they were.
2246
+ if (now - (this.meta.seen[k] || 0) > 60 * 1000) save = true
2247
+ this.meta.seen[k] = now
2248
+ }
2249
+ if (save) this.saveMeta()
2250
+ }
2251
+
2252
+ /** When a claim's holder last did something in the session (since the relay loaded it, at the earliest). */
2253
+ lastActive (c) {
2254
+ return Math.max(this.meta.seen[this.holderKey(c)] ?? this.loadedAt, c.ts || 0)
2255
+ }
2256
+
2257
+ /** Whether a claim's holder is in the session now: connected, or a hosted agent seen lately. */
2258
+ holderPresent (c) {
2259
+ if (c.byId) {
2260
+ // An AI session's claim (account~session) is here while its person's app is.
2261
+ const base = c.byId.split('~')[0]
2262
+ const seen = this.hostedSeen.get(base)
2263
+ if (seen && Date.now() - seen < HOSTED_ONLINE_MS) return true
2264
+ for (const ws of this.conns.keys()) if (ws.pass && `${ws.pass.kind}:${ws.pass.sub}` === base) return true
2265
+ return false
2266
+ }
2267
+ for (const n of this.names.values()) if (n === c.by || (c.by.startsWith(`${firstName(n)}${PERSONA_SEP}`))) return true
2268
+ return this.hostedOnline().some((h) => h.name === c.by)
2269
+ }
2270
+
2271
+ /** The claim covering a file on a branch: its own, or a folder or glob claim that matches it (the earliest). The
2272
+ * live record in meta.claims, not a shown copy: claimRequestOp mutates its queue in place. */
2273
+ claimFor (file, branch) {
2274
+ const own = this.meta.claims[this.claimKey(branch, file)]
2275
+ if (own) return own
2276
+ const b = this.resolveKey(branch || this.defaultKey)
2277
+ return Object.values(this.meta.claims).filter((c) => this.branchOf(c) === b && globMatcher(c.pattern)(file)).sort((x, y) => x.ts - y.ts)[0] || null
2278
+ }
2279
+
2280
+ /**
2281
+ * Lets go of the claims `pick` chooses. A claim someone is waiting for in its file queue goes
2282
+ * to the first of them instead of being released (`why` says why, in the message they get).
2283
+ * Returns how many went.
2284
+ */
2285
+ dropClaims (pick, why = null) {
2286
+ let n = 0
2287
+ for (const c of Object.values(this.meta.claims)) {
2288
+ if (!pick(c)) continue
2289
+ n++
2290
+ if (c.queue && c.queue.length) this.handOff(c, c.queue[0], { context: why ? why(c) : `${c.by} let go of it.`, auto: true })
2291
+ else delete this.meta.claims[this.claimKey(c.branch, c.pattern)]
2292
+ }
2293
+ this.forgetSeen()
2294
+ return n
2295
+ }
2296
+
2297
+ /** Takes the requests `pick` chooses out of every file queue (someone removed, or gone). */
2298
+ dropRequests (pick) {
2299
+ for (const c of Object.values(this.meta.claims)) {
2300
+ if (!c.queue) continue
2301
+ c.queue = c.queue.filter((r) => !pick(r))
2302
+ if (!c.queue.length) delete c.queue
2303
+ }
2304
+ }
2305
+
2306
+ forgetSeen () {
2307
+ for (const k of Object.keys(this.meta.seen)) if (!Object.values(this.meta.claims).some((c) => this.holderKey(c) === k)) delete this.meta.seen[k]
2308
+ }
2309
+
2310
+ /**
2311
+ * Gives claim `c` to the one who asked for it in request `r`, with the holder's context,
2312
+ * and tells them in a direct message (which wakes their agent). The rest of the queue
2313
+ * stays with the claim: the new holder hands it on in turn.
2314
+ */
2315
+ handOff (c, r, { context = '', auto = false } = {}) {
2316
+ const now = Date.now()
2317
+ const queue = (c.queue || []).filter((x) => x.id !== r.id)
2318
+ const next = { ...(c.branch ? { branch: c.branch } : {}), by: r.by, ...(r.byId ? { byId: r.byId } : {}), ...(r.of ? { of: r.of } : {}), pattern: c.pattern, note: String(r.title || '').slice(0, 500), ts: now, from: c.by, ...(r.clash ? { clash: r.clash } : {}) }
2319
+ if (queue.length) next.queue = queue
2320
+ this.meta.claims[this.claimKey(c.branch, c.pattern)] = next
2321
+ this.meta.seen[this.holderKey(next)] = now
2322
+ const text = auto
2323
+ ? `📦 ${c.pattern} is yours now: you asked for it ("${r.title}"). ${context}`.trim()
2324
+ : `📦 I'm handing you ${c.pattern} (you asked: "${r.title}"). My context: ${context}`
2325
+ // Sent as from the holder; marked as a handoff, it wakes them but asks for no answer (duties.js).
2326
+ this.postChat({ by: c.by, to: r.by, text, kind: 'handoff', path: c.pattern })
2327
+ this.log(`[${this.name}] ${c.pattern} handed from ${c.by} to ${r.by}${auto ? ' (automatically)' : ''}`)
2328
+ }
2329
+
2330
+ /** A chat entry written by the relay itself (file queue requests and handoffs). */
2331
+ postChat ({ by, to = null, text, kind, path: file, re }) {
2332
+ const chat = this.doc.getArray('chat')
2333
+ const msg = { id: crypto.randomBytes(8).toString('hex'), by, to, text: String(text).slice(0, 4000), ts: Date.now(), kind, path: file }
2334
+ if (re) msg.re = re
2335
+ this.doc.transact(() => {
2336
+ chat.push([msg])
2337
+ if (chat.length > 500) chat.delete(0, chat.length - 500)
2338
+ })
2339
+ return msg
2340
+ }
2341
+
2342
+ /**
2343
+ * Answers message `m` to the relay, sent in a change from `origin` (a connection, or a hosted
2344
+ * agent's or chat link's tool), once it has settled: a post undone (someone who may not talk)
2345
+ * gets no answer, nor does a message from before now (a room moved, or loaded again). The
2346
+ * answer goes to the asker alone, and asks for no reply. Only the owner (or anyone, in a
2347
+ * session nobody controls) may trim, and only from the app.
2348
+ */
2349
+ answerRelay (m, origin) {
2350
+ if (!m.id || this.relayAnswered.has(m.id) || Math.abs(Date.now() - (m.ts || 0)) > RELAY_ASK_MS) return
2351
+ this.relayAnswered.add(m.id)
2352
+ if (this.relayAnswered.size > 200) this.relayAnswered.delete(this.relayAnswered.values().next().value)
2353
+ const a = origin && this.access.get(origin)
2354
+ const asker = { name: (origin && this.names.get(origin)) || m.by, mayTrim: !!a && (a.owner || !this.controlled) }
2355
+ setImmediate(() => {
2356
+ if (this.destroyed || this.ended || !this.chat.toArray().some((x) => x && x.id === m.id)) return
2357
+ let text
2358
+ try { text = relayAnswer(this, m, asker) } catch (err) { text = `I couldn't answer that: ${err.message}` }
2359
+ this.postChat({ by: RELAY_BY, to: m.by, text, kind: 'nudge', re: m.id })
2360
+ })
2361
+ }
2362
+
2363
+ /**
2364
+ * Lets go of claims whose holder has done nothing in the session for claimIdleMs (no edit,
2365
+ * no message, no tool call): handed to the first one waiting for it, or released. Returns
2366
+ * how many went (and tells everyone, when any did).
2367
+ */
2368
+ sweepClaims (now = Date.now()) {
2369
+ const mins = Math.round(this.cfg.claimIdleMs / 60000)
2370
+ // A box never times out: it holds unfinished work nobody else has (sweepBoxes reminds its holder instead).
2371
+ const n = this.dropClaims((c) => !c.box && now - this.lastActive(c) >= this.cfg.claimIdleMs, (c) => `${c.by} had done nothing in the session for ${mins} minutes.`)
2372
+ if (n) this.log(`[${this.name}] let go of ${n} claim(s) idle for ${mins} minutes`)
2373
+ // Also when a holder came or went: everyone's app shows whose claims are held by someone away.
2374
+ const shown = this.allClaims().map((c) => `${c.branch || ''}\0${c.pattern}\0${c.active}`).join('\n')
2375
+ if (n || shown !== this.claimsShown) { this.claimsShown = shown; this.broadcastClaims() }
2376
+ return n
2377
+ }
2378
+
2379
+ /**
2380
+ * Boxes never time out like claims; one whose holder has been away a day gets one reminder a day in chat
2381
+ * (to their person, for an AI session's box). The session owner can release it. Returns how many were sent.
2382
+ */
2383
+ sweepBoxes (now = Date.now()) {
2384
+ let n = 0
2385
+ for (const c of Object.values(this.meta.claims)) {
2386
+ if (!c.box || this.holderPresent(c)) continue
2387
+ if (now - this.lastActive(c) < BOX_REMIND_MS || now - (c.reminded || 0) < BOX_REMIND_MS) continue
2388
+ c.reminded = now
2389
+ this.postChat({ by: RELAY_BY, to: c.of || c.by, kind: 'nudge', path: c.pattern, text: `📦 Your unfinished work on ${c.pattern}${c.branch ? ` (on ${c.branch})` : ''} is still in a box: nobody else has it in their folder. When you're back, commit it, unpack it (quilt_unpack) or put it back.` })
2390
+ n++
2391
+ }
2392
+ if (n) this.saveMeta()
2393
+ return n
2394
+ }
2395
+
2396
+ /**
2397
+ * Handles a claim/release request. `who` is { name } without sign-in, where
2398
+ * claims belong to a verified name; with it, { name, id, owner }, where they
2399
+ * belong to the account (id). Returns the reply fields.
2400
+ */
2401
+ /** A claim request (claimRequestOp), and on success a line in the audit trail. */
2402
+ claimRequest (who, req) {
2403
+ const r = this.claimRequestOp(who, req)
2404
+ const action = CLAIM_ACTIONS[req && req.op]
2405
+ if (action && who && who.id) this.audit(who.id, action, String(req.pattern || req.path || req.request || '').slice(0, 300))
2406
+ return r
2407
+ }
2408
+
2409
+ claimRequestOp (who, req) {
2410
+ const { name, id } = who
2411
+ // Whose claim is this? Older claims in a sign-in room have no account: they belong to their
2412
+ // name, as they did when made, and claiming one again adopts it under this account.
2413
+ // (The owner may release any of them too.)
2414
+ const own = (c) => id ? (c.byId ? c.byId === id : c.by === name) : c.by === name
2415
+ // A person may also release or hand off what their own AI sessions hold (account~session).
2416
+ const mine = (c) => own(c) || (!who.persona && !!id && !!c.byId && c.byId.startsWith(`${id}~`))
2417
+ const pattern = String(req.pattern ?? '').trim().replace(/^\.\//, '')
2418
+ // Claims are per branch: one on src/a.js on main never blocks src/a.js on feature-x.
2419
+ const branch = this.resolveKey(who.branch || this.defaultKey)
2420
+ const at = (p) => this.claimKey(branch, p)
2421
+ const onBranch = branch === this.defaultKey ? {} : { branch }
2422
+ if (req.op === 'touch') return { ok: true } // an AI session is at work: its claims' idle time starts again
2423
+ if (req.op === 'claim') {
2424
+ if (!pattern) throw new Error('pattern required')
2425
+ if (pattern.length > MAX_PATTERN) throw new Error('pattern too long')
2426
+ if (CONTROL_CHARS.test(pattern)) throw new Error('a pattern cannot contain control characters')
2427
+ const existing = this.meta.claims[at(pattern)]
2428
+ // A clash task's files (clash.js): claimed for its assignee while it merges, and taken over
2429
+ // from the one before when the task is handed on (only by whoever it is assigned to now).
2430
+ const clash = typeof req.clash === 'string' && /^[0-9a-f]{16}$/.test(req.clash) ? req.clash : null
2431
+ if (clash) {
2432
+ const t = this.tasks.get(clash)
2433
+ if (!t || t.column === 'done' || t.assignee !== name) throw new Error(`task ${clash} isn't yours to merge`)
2434
+ }
2435
+ const takeOver = (c) => !!clash && c.clash === clash
2436
+ if (existing && !mine(existing) && !takeOver(existing)) throw new Error(`${pattern} is already claimed by ${existing.by}`)
2437
+ const paths = this.branchPaths(branch)
2438
+ const other = this.claimList(branch).find((c) => !mine(c) && !takeOver(c) && patternsOverlap(c.pattern, pattern, paths))
2439
+ if (other) throw new Error(`${pattern} overlaps ${other.by}'s claim on ${other.pattern}`)
2440
+ // A box (docs/superpowers/specs/2026-10-16-git-boxes-design.md): this claim's file is the holder's
2441
+ // unfinished work, kept out of everyone else's folder until it's committed or unpacked. Only on one
2442
+ // file (never a glob), and sticky: claiming one's own boxed file again keeps the box.
2443
+ const box = (req.box === true || !!(existing && mine(existing) && existing.box)) && !/[*?[]/.test(pattern)
2444
+ this.meta.claims[at(pattern)] = { ...onBranch, by: name, ...(id ? { byId: id } : {}), pattern, note: who.talk === false ? '' : String(req.note ?? '').slice(0, 500), ts: Date.now(), ...(who.persona ? { of: who.of } : {}), ...(clash ? { clash } : {}), ...(existing?.queue ? { queue: existing.queue } : {}), ...(box ? { box: true } : {}) }
2445
+ return { ok: true }
2446
+ }
2447
+ if (req.op === 'release') {
2448
+ // A holder can't just let go of a file someone is waiting for: they hand it off, with
2449
+ // their context (op 'handoff'). Releasing everything lets go of the rest.
2450
+ const queued = (c) => c.queue && c.queue.length
2451
+ if (pattern === '*' || !pattern) {
2452
+ const held = Object.values(this.meta.claims).filter((c) => mine(c) && queued(c) && this.branchOf(c) === branch).map((c) => c.pattern)
2453
+ // Boxes stay: a box opens when it's committed or unpacked (op 'unbox'), not when its holder stops.
2454
+ return { ok: true, released: this.dropClaims((c) => mine(c) && !queued(c) && !c.box && this.branchOf(c) === branch), ...(held.length ? { held } : {}) }
2455
+ }
2456
+ const c = this.meta.claims[at(pattern)]
2457
+ if (!c) return { ok: true, released: 0 }
2458
+ // The owner may release anyone's claim. Someone under the same name may release one
2459
+ // held by an account that isn't here (theirs from before a re-invite), but not take it.
2460
+ // Either way, the first one waiting for it gets it.
2461
+ const stale = c.by === name && !this.holderPresent(c)
2462
+ if (!mine(c) && !who.owner && !stale) throw new Error(`${pattern} is claimed by ${c.by}; only they can release it (or the session owner)`)
2463
+ if (mine(c) && queued(c)) throw new Error(`${c.queue.map((r) => r.by).join(', ')} ${c.queue.length === 1 ? 'is' : 'are'} waiting for ${pattern} in its file queue: hand it off with your context instead of releasing it`)
2464
+ this.dropClaims((x) => x === c, () => mine(c) ? `${c.by} let go of it.` : `${name} (the session owner) released ${c.by}'s claim.`)
2465
+ return { ok: true, released: 1 }
2466
+ }
2467
+ if (req.op === 'unbox') {
2468
+ // The holder's box opens: `release` lets go of the claim too (it was committed: the first one
2469
+ // waiting for the file gets it); otherwise the holder keeps the file, its content now in every folder.
2470
+ const c = this.meta.claims[at(pattern)]
2471
+ if (!c || !c.box) return { ok: true, unboxed: 0 }
2472
+ // A person may unpack what their own AI sessions boxed (mine, or `of` them without sign-in); the session owner, anyone's.
2473
+ if (!mine(c) && !(!who.persona && c.of === name) && !who.owner) throw new Error(`${pattern} is boxed by ${c.by}; only they can unpack it (or the session owner)`)
2474
+ if (req.release === true) this.dropClaims((x) => x === c, () => `${c.by} committed it.`)
2475
+ else { delete c.box; delete c.reminded }
2476
+ return { ok: true, unboxed: 1 }
2477
+ }
2478
+ if (req.op === 'request') {
2479
+ // Asking for a file someone else holds: a line in that claim's file queue. Its holder is
2480
+ // told now, and must hand it off (with context) before letting go.
2481
+ const file = String(req.path ?? '').trim().replace(/^\.\//, '')
2482
+ if (!file || file.length > MAX_PATTERN) throw new Error('path required')
2483
+ if (CONTROL_CHARS.test(file)) throw new Error('a path cannot contain control characters')
2484
+ if (who.talk === false) throw new Error('you may not post in this session')
2485
+ const c = this.claimFor(file, branch)
2486
+ if (!c) throw new Error(`${file} is not claimed: claim it and go ahead`)
2487
+ if (mine(c)) throw new Error(`${file} is already yours`)
2488
+ const title = String(req.title ?? '').trim().slice(0, MAX_REQUEST_TITLE)
2489
+ if (!title) throw new Error('title required: what you want to do, in a few words')
2490
+ const description = String(req.description ?? '').trim().slice(0, MAX_REQUEST_TEXT)
2491
+ const task = req.task ? String(req.task).slice(0, 80) : undefined
2492
+ c.queue = c.queue || []
2493
+ // One request per person per claim: asking again updates it and keeps its place.
2494
+ const prev = c.queue.find((r) => id ? r.byId === id : r.by === name)
2495
+ if (!prev && c.queue.length >= MAX_QUEUE) throw new Error(`${c.queue.length} are already waiting for ${c.pattern}`)
2496
+ const r = prev || { id: crypto.randomBytes(6).toString('hex'), by: name, ...(id ? { byId: id } : {}), ...(who.persona ? { of: who.of } : {}), ts: Date.now() }
2497
+ Object.assign(r, { path: file, title, description, ...(task ? { task } : {}) })
2498
+ // Asked for by a clash task's assignee for the merge: let go of when the task is done (dropClashClaims).
2499
+ if (typeof req.clash === 'string' && /^[0-9a-f]{16}$/.test(req.clash) && this.tasks.get(req.clash)?.assignee === name) r.clash = req.clash
2500
+ if (!prev) c.queue.push(r)
2501
+ this.postChat({ by: name, to: c.by, kind: 'queue', path: file, text: `📥 File queue · ${file}: ${`${title}${description ? ` — ${description}` : ''}`.replace(/[.!?]+$/, '')}. When you're done with it, hand it off to me with your context.` })
2502
+ return { ok: true, request: r.id, position: c.queue.indexOf(r) + 1, holder: c.by, pattern: c.pattern }
2503
+ }
2504
+ if (req.op === 'withdraw') {
2505
+ // The one who asked (or the owner) takes a request back.
2506
+ for (const c of Object.values(this.meta.claims)) {
2507
+ const r = (c.queue || []).find((x) => x.id === req.request)
2508
+ if (!r) continue
2509
+ if (!(id ? r.byId === id : r.by === name) && !who.owner) throw new Error('that request is not yours')
2510
+ c.queue = c.queue.filter((x) => x !== r)
2511
+ if (!c.queue.length) delete c.queue
2512
+ return { ok: true, withdrawn: 1 }
2513
+ }
2514
+ return { ok: true, withdrawn: 0 }
2515
+ }
2516
+ if (req.op === 'handoff') {
2517
+ // The holder passes a claim to someone waiting for it (the first, unless `to` names a
2518
+ // request id or a person), with what they know: what they changed, what's left, gotchas.
2519
+ const c = this.meta.claims[at(pattern)] || (pattern && this.claimFor(pattern, branch))
2520
+ if (!c) throw new Error(`${pattern || 'that'} is not claimed`)
2521
+ if (!mine(c) && !who.owner) throw new Error(`${c.pattern} is ${c.by}'s to hand off`)
2522
+ const queue = c.queue || []
2523
+ if (!queue.length) throw new Error(`nobody is waiting for ${c.pattern}: release it instead`)
2524
+ const to = req.to ? String(req.to) : ''
2525
+ const r = to ? queue.find((x) => x.id === to || x.by === to) : queue[0]
2526
+ if (!r) throw new Error(`${to} is not waiting for ${c.pattern}`)
2527
+ const context = String(req.context ?? '').trim().slice(0, MAX_HANDOFF_TEXT)
2528
+ if (!context && mine(c)) throw new Error('context required: what you changed, what is left and anything they should know')
2529
+ this.handOff(c, r, { context: context || `${name} (the session owner) handed it on.`, auto: !mine(c) })
2530
+ return { ok: true, to: r.by, pattern: c.pattern, waiting: queue.length - 1 }
2531
+ }
2532
+ if (req.op === 'clear-inactive') {
2533
+ // The owner clears every claim held by someone who isn't in the session now.
2534
+ if (!who.owner) throw new Error('only the session owner can clear claims')
2535
+ return { ok: true, released: this.dropClaims((c) => !this.holderPresent(c)) }
2536
+ }
2537
+ throw new Error('unknown claim operation')
2538
+ }
2539
+
2540
+ saveMeta () {
2541
+ if (this.ended || !this.metaFile || this.crashed || this.released) return // crashed: a failure injected in a test (relay-sync.js crashPoint)
2542
+ // Written whole, then renamed: a crash mid-write leaves the old file, never a cut-off one.
2543
+ try {
2544
+ const tmp = this.metaFile + '.tmp'
2545
+ fs.writeFileSync(tmp, JSON.stringify(this.meta))
2546
+ fs.renameSync(tmp, this.metaFile)
2547
+ } catch (err) { this.diskError(err) }
2548
+ }
2549
+
2550
+ /**
2551
+ * A save failed (the disk is full, or not writable). The relay stays up and the
2552
+ * room stays readable, but takes no new changes rather than losing them quietly.
2553
+ */
2554
+ diskError (err) {
2555
+ if (!this.unsavable) this.log(`[${this.name}] could not save the session (${err.message}); it is read-only until the relay's disk is fixed and it is reloaded`)
2556
+ this.unsavable = true
2557
+ this.full = true
2558
+ }
2559
+
2560
+ scheduleSave () {
2561
+ if (!this.docFile || this.saveTimer) return
2562
+ this.saveTimer = setTimeout(() => this.save(), 1000)
2563
+ }
2564
+
2565
+ save () {
2566
+ if (this.ended || this.crashed || this.released) return
2567
+ clearTimeout(this.saveTimer)
2568
+ this.saveTimer = null
2569
+ if (!this.docFile || !this.exists) return
2570
+ const state = Y.encodeStateAsUpdate(this.doc)
2571
+ this.bytes = state.length
2572
+ try {
2573
+ const tmp = this.docFile + '.tmp'
2574
+ fs.writeFileSync(tmp, state)
2575
+ fs.renameSync(tmp, this.docFile)
2576
+ } catch (err) { this.diskError(err) }
2577
+ }
2578
+
2579
+ /**
2580
+ * Challenges the client to prove it holds the key for its name, then lets
2581
+ * it into the room. Nothing else is accepted until then.
2582
+ */
2583
+ admit (ws, name, publicKey, key, { kind = 'human', invitedAs = 'editor', account = '' } = {}, onJoin) {
2584
+ if (this.ended) return ws.close(CLOSE_ENDED, 'The owner ended this session')
2585
+ clearTimeout(this.unloadTimer)
2586
+ const nonce = crypto.randomBytes(32)
2587
+ let joined = false
2588
+ let waiting = false
2589
+ ws.meter = this.meter
2590
+ ws.on('message', (data) => {
2591
+ const buf = new Uint8Array(data)
2592
+ this.meter.in(buf.length)
2593
+ // A fresh pass may come at any time, even while waiting for the owner.
2594
+ // (MSG_PASS is under 128, so it is the whole first byte.)
2595
+ if (ws.pass && buf[0] === MSG_PASS) {
2596
+ if (!renewPass(ws, buf)) this.log(`[${this.name}] ignored a pass refresh from ${name} that wasn't theirs`)
2597
+ else this.passRenewed(ws)
2598
+ return
2599
+ }
2600
+ if (joined || this.access.has(ws)) {
2601
+ joined = true
2602
+ try { this.handle(ws, buf) } catch (err) { this.log(`[${this.name}] bad message: ${err.message}`) }
2603
+ return
2604
+ }
2605
+ if (waiting) return // nothing counts until the owner lets them in
2606
+ try {
2607
+ const dec = decoding.createDecoder(buf)
2608
+ if (decoding.readVarUint(dec) !== MSG_AUTH) throw new Error('not signed in')
2609
+ if (!verifyChallenge(key, this.name, nonce, decoding.readVarUint8Array(dec))) throw new Error('bad signature')
2610
+ } catch (err) {
2611
+ this.log(`[${this.name}] refused ${name}: ${err.message}`)
2612
+ return ws.close(CLOSE_AUTH_FAILED, 'Could not verify who you are')
2613
+ }
2614
+ if (this.ended) return ws.close(CLOSE_ENDED, 'The owner ended this session')
2615
+ // Its messages are the relay's own (relay-ask.js): nobody may write as it.
2616
+ if (isRelay(name)) return ws.close(CLOSE_NAME_TAKEN, `"${RELAY_BY}" is the relay's own name; pick another name`)
2617
+ // The session may have started storing files while this app signed in.
2618
+ if (!this.supported(ws)) return ws.close(CLOSE_NEEDS_UPDATE, NEEDS_UPDATE)
2619
+ // With sign-in on, names come from accounts and can't be taken, so they aren't bound to keys.
2620
+ if (!account) {
2621
+ // Re-check: someone else may have taken the name while we waited.
2622
+ if (!this.keyMatches(name, publicKey)) return ws.close(CLOSE_NAME_TAKEN, nameTaken(name))
2623
+ this.bindName(name, publicKey)
2624
+ }
2625
+ const acc = this.accessFor(publicKey, name, kind, invitedAs, account, ws.pass)
2626
+ if (acc.state === 'pending') {
2627
+ waiting = true
2628
+ this.pending.set(ws, { key: publicKey, id: account || publicKey, name, kind, invitedAs, since: Date.now() })
2629
+ this.log(`[${this.name}] ${name} is waiting to be let in`)
2630
+ send(ws, jsonMessage(MSG_ACCESS, { state: 'pending', invitedAs, controlled: true }))
2631
+ this.broadcastMembers()
2632
+ onJoin()
2633
+ return
2634
+ }
2635
+ joined = true
2636
+ this.enter(ws, { key: publicKey, id: acc.id || account || publicKey, name, kind, role: acc.role, scopes: acc.scopes, scopesExcept: acc.scopesExcept || [], talk: acc.talk !== false, owner: acc.owner })
2637
+ onJoin()
2638
+ })
2639
+ ws.on('close', (code) => {
2640
+ if (this.pending.delete(ws)) this.broadcastMembers()
2641
+ if (this.access.has(ws) || this.conns.has(ws)) this.leave(ws, code)
2642
+ })
2643
+ send(ws, bytesMessage(MSG_AUTH, nonce))
2644
+ }
2645
+
2646
+ /** Lets a signed-in (and, if needed, approved) person into the room. */
2647
+ enter (ws, a) {
2648
+ this.setAccess(ws, a)
2649
+ this.noteTurns(ws, a)
2650
+ // Presence: an account's visit starts once it's let in (never while it waits for the owner).
2651
+ if (ws.pass && this.presence && !ws.visit) {
2652
+ ws.visit = this.presence.visitStart({ room: this.name, account: `${ws.pass.kind}:${ws.pass.sub}`, name: a.name, owner: !!a.owner, via: 'app', tool: ws.tool || '' })
2653
+ // The accounts API learns who owns a session from this report, and only the owner may
2654
+ // give people access there: tell it now rather than within the minute.
2655
+ if (a.owner) this.presence.tick().catch(() => {})
2656
+ }
2657
+ // A person's cloud AI: the room remembers it, so it can be woken while it is away (relay-ai-vm.js).
2658
+ if (ws.pass && noteCloudAi(this.meta, ws.pass, a.name)) {
2659
+ this.saveMeta()
2660
+ if (this.cloudAiWaker) this.cloudAiWaker.check(this).catch(() => {})
2661
+ }
2662
+ // Back with a pass from before the owner's last change: a newer one may allow more.
2663
+ const refresh = !a.owner && this.passPredatesChange(ws.pass) ? { refresh: true } : {}
2664
+ send(ws, jsonMessage(MSG_ACCESS, { ...this.accessMessage(a), ...refresh }))
2665
+ this.join(ws, a.name)
2666
+ this.broadcastMembers()
2667
+ }
2668
+
2669
+ join (ws, name) {
2670
+ this.conns.set(ws, new Set())
2671
+ this.names.set(ws, name)
2672
+ send(ws, syncStep1Message(this.doc))
2673
+ send(ws, jsonMessage(MSG_BRANCHES, { branches: this.branchList() }))
2674
+ const states = [...this.awareness.getStates().keys()]
2675
+ if (states.length) send(ws, awarenessMessage(this.awareness, states))
2676
+ send(ws, jsonMessage(MSG_CLAIMS, { claims: this.claimList(ws.branch) }))
2677
+ this.touch()
2678
+ }
2679
+
2680
+ /**
2681
+ * An agent that says it works in turns (`turns` among its features) is away between them: the relay
2682
+ * keeps when its last turn ended. One that joins without it (an ordinary computer) is not any more.
2683
+ */
2684
+ noteTurns (ws, a) {
2685
+ const key = a.owner ? this.ownerId : a.id
2686
+ if (!key) return
2687
+ const turns = a.kind === 'agent' && (ws.features || []).includes('turns')
2688
+ const had = this.meta.turns && key in this.meta.turns
2689
+ if (turns) (this.meta.turns ||= {})[key] = 0 // here now
2690
+ else if (had) delete this.meta.turns[key]
2691
+ if (turns || had) this.saveMeta()
2692
+ }
2693
+
2694
+ /** When an agent that works in turns was last here (its last turn ended), or null. */
2695
+ awaySince (key) {
2696
+ const at = this.meta.turns && this.meta.turns[key]
2697
+ return at && Date.now() - at < AWAY_MS ? at : null
2698
+ }
2699
+
2700
+ leave (ws, code) {
2701
+ const left = this.access.get(ws)
2702
+ if (left) {
2703
+ const key = left.owner ? this.ownerId : left.id
2704
+ // Its turn is over once its last connection goes (an agent may have two for a moment while it reconnects).
2705
+ if (this.meta.turns && key in this.meta.turns && ![...this.access].some(([w, x]) => w !== ws && (x.owner ? this.ownerId : x.id) === key)) {
2706
+ this.meta.turns[key] = Date.now()
2707
+ this.saveMeta()
2708
+ }
2709
+ }
2710
+ if (ws.visit) { if (this.presence) this.presence.visitEnd(ws.visit, ws.endReason || endReasonFor(code)); ws.visit = null }
2711
+ const ids = this.conns.get(ws)
2712
+ if (ws.branch) { this.store.unsubscribe(ws, ws.branch); this.noteBranchSeen(ws.branch, { force: true, used: true }) }
2713
+ this.conns.delete(ws)
2714
+ this.names.delete(ws)
2715
+ if (this.access.delete(ws)) {
2716
+ this.guard.trackedOrigins.delete(ws)
2717
+ this.broadcastMembers()
2718
+ }
2719
+ if (ids && ids.size) {
2720
+ awarenessProtocol.removeAwarenessStates(this.awareness, [...ids], null)
2721
+ // Forget their clocks too: back after a drop, a client sends the same state at the
2722
+ // same clock, which would be ignored until its next renewal.
2723
+ for (const id of ids) this.awareness.meta.delete(id)
2724
+ }
2725
+ if (this.conns.size === 0) {
2726
+ this.save()
2727
+ this.touch()
2728
+ this.onEmpty && this.onEmpty()
2729
+ }
2730
+ }
2731
+
2732
+ handle (ws, buf) {
2733
+ const dec = decoding.createDecoder(buf)
2734
+ const type = decoding.readVarUint(dec)
2735
+ if (type === MSG_SYNC) {
2736
+ const docId = decoding.readVarString(dec)
2737
+ let doc = this.doc
2738
+ if (docId !== ROOM_DOC) {
2739
+ // A connection syncs the room's document and the one branch it joined, nothing else.
2740
+ // An edit it sent for the branch it just left, still in flight when the join landed
2741
+ // (ws.prevBranch, set in branchRequest), is still applied rather than lost.
2742
+ const k = this.resolveKey(docId)
2743
+ const e = (k === ws.branch || k === ws.prevBranch) ? this.store.get(k) : null
2744
+ if (!e) return
2745
+ doc = e.doc
2746
+ // A branch over its size limit takes nothing new; the room and every other branch carry
2747
+ // on, and nobody is disconnected for it (its members hear it in the branch list).
2748
+ if (!this.unsavable && this.branchFull(k) && decoding.peekVarUint(dec) !== syncProtocol.messageYjsSyncStep1) return
2749
+ }
2750
+ if (docId === ROOM_DOC ? this.full : this.unsavable) {
2751
+ // Over quota: still answer "what do you have?" so people can read, but refuse new data.
2752
+ if (decoding.peekVarUint(dec) !== syncProtocol.messageYjsSyncStep1) {
2753
+ ws.endReason = 'disconnected'
2754
+ closeSoon(ws, CLOSE_ROOM_FULL, 'room is over the size limit')
2755
+ return
2756
+ }
2757
+ }
2758
+ const enc = syncHeader(docId)
2759
+ const header = encoding.length(enc)
2760
+ syncProtocol.readSyncMessage(dec, enc, doc, ws)
2761
+ if (encoding.length(enc) > header) send(ws, encoding.toUint8Array(enc))
2762
+ } else if (type === MSG_BRANCH) {
2763
+ let req = {}
2764
+ let reply
2765
+ try {
2766
+ req = JSON.parse(decoding.readVarString(dec))
2767
+ reply = { id: req.id, op: req.op, ...this.branchRequest(ws, req) }
2768
+ } catch (err) {
2769
+ reply = { id: req.id, op: req.op, ok: false, error: err.message }
2770
+ }
2771
+ const listChanged = reply.listChanged
2772
+ delete reply.listChanged
2773
+ send(ws, jsonMessage(MSG_BRANCHES, { branches: this.branchList(), reply }))
2774
+ if (reply.ok && reply.op === 'join') {
2775
+ // What the relay has of the branch, and its claims: the app answers with what it has.
2776
+ send(ws, syncStep1Message(this.store.get(ws.branch).doc, ws.branchAs))
2777
+ send(ws, jsonMessage(MSG_CLAIMS, { claims: this.claimList(ws.branch) }))
2778
+ }
2779
+ if (listChanged) this.broadcastBranches()
2780
+ } else if (type === MSG_AWARENESS) {
2781
+ const update = decoding.readVarUint8Array(dec)
2782
+ if (!this.presenceAllowed(ws, update)) return this.log(`[${this.name}] dropped presence from ${this.names.get(ws)} under another name`)
2783
+ awarenessProtocol.applyAwarenessUpdate(this.awareness, update, ws)
2784
+ } else if (type === MSG_QUERY_AWARENESS) {
2785
+ send(ws, awarenessMessage(this.awareness, [...this.awareness.getStates().keys()]))
2786
+ } else if (type === MSG_CLAIM) {
2787
+ let req = {}
2788
+ let reply
2789
+ try {
2790
+ req = JSON.parse(decoding.readVarString(dec))
2791
+ const who = this.claimant(ws, req.as)
2792
+ // No box on a file this member's change to would be undone (checkChange): nothing of theirs would be in it.
2793
+ if (req && req.op === 'claim' && req.box === true && !this.mayBox(ws, req.pattern)) req.box = false
2794
+ reply = { id: req.id, ...this.claimRequest(who, req) }
2795
+ this.noteActivity(who.persona ? [this.claimantKey(who)] : this.holderKeys(ws))
2796
+ } catch (err) {
2797
+ return send(ws, jsonMessage(MSG_CLAIMS, { claims: this.claimList(ws.branch), reply: { id: req.id, ok: false, error: err.message } }))
2798
+ }
2799
+ this.saveMeta()
2800
+ for (const other of this.conns.keys()) {
2801
+ const claims = this.claimList(other.branch)
2802
+ send(other, jsonMessage(MSG_CLAIMS, other === ws ? { claims, reply } : { claims }))
2803
+ }
2804
+ this.noteClaimsChanged()
2805
+ } else if (type === MSG_ADMIN) {
2806
+ let req = {}
2807
+ let reply
2808
+ try {
2809
+ req = JSON.parse(decoding.readVarString(dec))
2810
+ // quilt_sync_branch from a folder that can't bring commits in itself (no git, or its fetch
2811
+ // fails): any member let in may ask the relay to look at GitHub for its branch now.
2812
+ if (req.op === 'syncUpstream') {
2813
+ const id = req.id
2814
+ if (!this.access.get(ws) || !ws.branch) throw new Error('not in this session')
2815
+ this.syncUpstreamNow(ws.branch).then((sync) => ({ id, ok: true, sync }), (err) => ({ id, ok: false, error: err.message }))
2816
+ .then((r) => { if (this.conns.has(ws)) send(ws, jsonMessage(MSG_MEMBERS, { members: this.memberList(), sessionName: this.meta.name || '', admitBy: this.admitBy, pending: this.personCanAdmit(this.access.get(ws)) ? this.pendingList() : [], reply: r })) })
2817
+ return
2818
+ }
2819
+ // A folder's git asking for a token for the session's repository (Quilt's credential helper, in
2820
+ // agents' and people's folders): read to fetch or clone, write to push while git is on for everyone here.
2821
+ if (req.op === 'gitToken') {
2822
+ const id = req.id
2823
+ const a = this.access.get(ws)
2824
+ if (!a || !ws.branch) throw new Error('not in this session')
2825
+ this.gitTokenFor(ws.branch, { access: req.access === 'write' ? 'write' : 'read', who: { name: a.name, kind: a.kind === 'agent' ? 'agent' : 'human', id: a.id }, repo: typeof req.repo === 'string' ? req.repo : null })
2826
+ .then((git) => ({ id, ok: true, git }), (err) => ({ id, ok: false, error: err.message }))
2827
+ .then((r) => { if (this.conns.has(ws)) send(ws, jsonMessage(MSG_MEMBERS, { members: this.memberList(), sessionName: this.meta.name || '', admitBy: this.admitBy, pending: this.personCanAdmit(this.access.get(ws)) ? this.pendingList() : [], reply: r })) })
2828
+ return
2829
+ }
2830
+ // Someone just connected GitHub (or asks whether it is): the connection is checked again now.
2831
+ if (req.op === 'githubRecheck') {
2832
+ const id = req.id
2833
+ if (!this.access.get(ws) || !ws.branch) throw new Error('not in this session')
2834
+ const key = this.resolveKey(ws.branch)
2835
+ noteConnection(this, key, { force: true }).then((changed) => { if (changed) { this.saveMeta(); this.broadcastBranches() } return { id, ok: true, github: relayEntry(upstreamsOf(this)[key])?.github || null } }, (err) => ({ id, ok: false, error: err.message }))
2836
+ .then((r) => { if (this.conns.has(ws)) send(ws, jsonMessage(MSG_MEMBERS, { members: this.memberList(), sessionName: this.meta.name || '', admitBy: this.admitBy, pending: this.personCanAdmit(this.access.get(ws)) ? this.pendingList() : [], reply: r })) })
2837
+ return
2838
+ }
2839
+ // Which of a member's files (or a task's) aren't in git on GitHub yet (relay-commit.js).
2840
+ if (req.op === 'unpublished') {
2841
+ const id = req.id
2842
+ const a = this.access.get(ws)
2843
+ if (!a || !ws.branch) throw new Error('not in this session')
2844
+ unpublishedFor(this, { key: ws.branch, who: a.name, files: Array.isArray(req.files) ? req.files : null, task: typeof req.task === 'string' ? req.task : null, column: typeof req.column === 'string' ? req.column : '' }, { fetch: this.githubFetch })
2845
+ .then((u) => ({ id, ok: true, unpublished: u }), (err) => ({ id, ok: false, error: err.message }))
2846
+ .then((r) => { if (this.conns.has(ws)) send(ws, jsonMessage(MSG_MEMBERS, { members: this.memberList(), sessionName: this.meta.name || '', admitBy: this.admitBy, pending: this.personCanAdmit(this.access.get(ws)) ? this.pendingList() : [], reply: r })) })
2847
+ return
2848
+ }
2849
+ // The branch's pull requests, merging one, and a task moved to Done (which merges its pull request) (relay-commit.js).
2850
+ if (req.op === 'pulls' || req.op === 'merge' || req.op === 'finishTask') {
2851
+ const id = req.id
2852
+ const a = this.access.get(ws)
2853
+ if (!a || !ws.branch) throw new Error('not in this session')
2854
+ const who = { name: a.name, kind: a.kind === 'agent' ? 'agent' : 'human', access: a }
2855
+ const run = req.op === 'pulls' ? pullRequestsFor(this, { key: ws.branch }, { fetch: this.githubFetch }) : req.op === 'finishTask' ? finishTaskFor(this, { who, key: ws.branch, task: String(req.task || '') }, { fetch: this.githubFetch, log: this.log }) : mergeForMember(this, { who, key: ws.branch, number: req.number, branch: req.branch, method: req.method, deleteBranch: req.deleteBranch, force: req.force === true }, { fetch: this.githubFetch, log: this.log })
2856
+ run.then((out) => ({ id, ok: true, [req.op]: out }), (err) => ({ id, ok: false, error: err.message }))
2857
+ .then((r) => { if (this.conns.has(ws)) send(ws, jsonMessage(MSG_MEMBERS, { members: this.memberList(), sessionName: this.meta.name || '', admitBy: this.admitBy, pending: this.personCanAdmit(this.access.get(ws)) ? this.pendingList() : [], reply: r })) })
2858
+ return
2859
+ }
2860
+ // A person's folder hands its local commits to the relay (plan §6; relay-outbound.js replayBundle).
2861
+ if (req.op === 'bundle') {
2862
+ const id = req.id
2863
+ const a = this.access.get(ws)
2864
+ if (!a || (a.state && a.state !== 'approved') || !ws.branch) throw new Error('not in this session')
2865
+ if (a.role === 'viewer' || a.kind === 'agent') throw new Error('only people\'s folders hand commits to the relay')
2866
+ replayBundle(this, ws.branch, { who: { name: a.name, kind: 'human' }, bundle: String(req.bundle || ''), head: String(req.head || ''), ref: String(req.ref || '') })
2867
+ .then((out) => ({ id, ok: true, bundle: out }), (err) => ({ id, ok: false, error: err.message }))
2868
+ .then((r) => { if (this.conns.has(ws)) send(ws, jsonMessage(MSG_MEMBERS, { members: this.memberList(), sessionName: this.meta.name || '', admitBy: this.admitBy, pending: this.personCanAdmit(this.access.get(ws)) ? this.pendingList() : [], reply: r })) })
2869
+ return
2870
+ }
2871
+ // A member's offline edits clash with the session's (plan §7.1): held and merged as a clash (relay-clash.js).
2872
+ if (req.op === 'offlineClash') {
2873
+ const id = req.id
2874
+ const a = this.access.get(ws)
2875
+ if (!a || (a.state && a.state !== 'approved') || !ws.branch || a.role === 'viewer') throw new Error('not in this session')
2876
+ if (!this.cfg.gitSync || !this.mirrors) throw new Error('This session doesn\'t sync live with GitHub.')
2877
+ const text = (v) => typeof v === 'string' && v.length <= 1024 * 1024 ? v : null
2878
+ openOfflineClash(this, ws.branch, { who: { name: a.name }, path: String(req.path || ''), mergeId: typeof req.mergeId === 'string' ? req.mergeId.slice(0, 40) : null, base: text(req.base), ours: text(req.ours), oursDeleted: req.oursDeleted === true, binary: req.binary === true })
2879
+ .then((h) => ({ id, ok: true, offlineClash: { task: h.task, merger: h.merger, reviewer: h.reviewer } }), (err) => ({ id, ok: false, error: err.message }))
2880
+ .then((r) => { this.broadcastBranches(); if (this.conns.has(ws)) send(ws, jsonMessage(MSG_MEMBERS, { members: this.memberList(), sessionName: this.meta.name || '', admitBy: this.admitBy, pending: this.personCanAdmit(this.access.get(ws)) ? this.pendingList() : [], reply: r })) })
2881
+ return
2882
+ }
2883
+ // A person adopts an agent's branch into theirs (addendum 16.7; relay-outbound.js adoptBranch).
2884
+ if (req.op === 'adopt') {
2885
+ const id = req.id
2886
+ const a = this.access.get(ws)
2887
+ if (!a || (a.state && a.state !== 'approved') || !ws.branch || a.role === 'viewer') throw new Error('not in this session')
2888
+ if (!this.cfg.gitSync || !this.mirrors) throw new Error('This session doesn\'t sync live with GitHub.')
2889
+ adoptBranch(this, ws.branch, { who: { name: a.name, kind: a.kind === 'agent' ? 'agent' : 'human' }, from: String(req.from || '') })
2890
+ .then((out) => ({ id, ok: true, adopt: out }), (err) => ({ id, ok: false, error: err.message }))
2891
+ .then((r) => { if (this.conns.has(ws)) send(ws, jsonMessage(MSG_MEMBERS, { members: this.memberList(), sessionName: this.meta.name || '', admitBy: this.admitBy, pending: this.personCanAdmit(this.access.get(ws)) ? this.pendingList() : [], reply: r })) })
2892
+ return
2893
+ }
2894
+ // quilt_clash from a folder's AI or app (relay-clash.js): the member's own name and kind.
2895
+ if (req.op === 'clash') {
2896
+ const id = req.id
2897
+ const a = this.access.get(ws)
2898
+ if (!a || (a.state && a.state !== 'approved') || !ws.branch) throw new Error('not in this session')
2899
+ if (!this.cfg.gitSync || !this.mirrors) throw new Error('This session doesn\'t sync live with GitHub, so there are no held clashes here.')
2900
+ clashAction(this, { who: { name: a.name, kind: a.kind === 'agent' ? 'agent' : 'human', access: a }, key: ws.branch, action: String(req.action || ''), path: String(req.path || ''), content: typeof req.content === 'string' ? req.content : null, checks: req.checks && typeof req.checks === 'object' ? req.checks : null, force: req.force === true })
2901
+ .then((out) => out.ok ? { id, ok: true, clash: out, text: out.text } : { id, ok: false, error: out.text }, (err) => ({ id, ok: false, error: err.message }))
2902
+ .then((r) => { if (this.conns.has(ws)) send(ws, jsonMessage(MSG_MEMBERS, { members: this.memberList(), sessionName: this.meta.name || '', admitBy: this.admitBy, pending: this.personCanAdmit(this.access.get(ws)) ? this.pendingList() : [], reply: r })) })
2903
+ return
2904
+ }
2905
+ // quilt_commit from a folder: the relay commits the session's copy of the files to GitHub (relay-commit.js).
2906
+ if (req.op === 'commit') {
2907
+ const id = req.id
2908
+ const a = this.access.get(ws)
2909
+ if (!a || !ws.branch) throw new Error('not in this session')
2910
+ this.commitFor({ name: a.name, kind: a.kind === 'agent' ? 'agent' : 'human', access: a }, ws.branch, req)
2911
+ .then((commit) => ({ id, ok: true, commit }), (err) => ({ id, ok: false, error: err.message }))
2912
+ .then((r) => { if (this.conns.has(ws)) send(ws, jsonMessage(MSG_MEMBERS, { members: this.memberList(), sessionName: this.meta.name || '', admitBy: this.admitBy, pending: this.personCanAdmit(this.access.get(ws)) ? this.pendingList() : [], reply: r })) })
2913
+ return
2914
+ }
2915
+ reply = { id: req.id, ...this.adminRequest(ws, req) }
2916
+ } catch (err) {
2917
+ reply = { id: req.id, ok: false, error: err.message }
2918
+ }
2919
+ if (reply.ok) this.broadcastMembers(ws, reply)
2920
+ else {
2921
+ const a = this.access.get(ws)
2922
+ send(ws, jsonMessage(MSG_MEMBERS, { members: this.memberList(), sessionName: this.meta.name || '', admitBy: this.admitBy, pending: this.personCanAdmit(a) ? this.pendingList() : [], reply }))
2923
+ }
2924
+ }
2925
+ }
2926
+
2927
+ destroy () {
2928
+ this.destroyed = true
2929
+ clearTimeout(this.unloadTimer)
2930
+ clearTimeout(this.upstreamSoonTimer)
2931
+ clearTimeout(this.upstreamAgainTimer)
2932
+ this.save()
2933
+ this.store.destroy()
2934
+ this.guard.destroy()
2935
+ this.awareness.destroy()
2936
+ this.doc.destroy()
2937
+ }
2938
+
2939
+ /** Stored-file ids any of the room's documents still points at (unloaded branches: as of their last save). */
2940
+ storedIds () {
2941
+ const ids = new Set()
2942
+ const add = (b) => { if (b && b.stored && b.stored.id) ids.add(b.stored.id) }
2943
+ for (const b of this.blobs.values()) add(b) // rooms from before branch documents
2944
+ for (const [key, meta] of Object.entries(this.meta.branches)) {
2945
+ const e = this.store.get(key)
2946
+ if (e) for (const b of e.blobs.values()) add(b)
2947
+ else for (const id of meta.stored || []) ids.add(id)
2948
+ }
2949
+ return ids
2950
+ }
2951
+ }
2952
+
2953
+ const TALK_WHY = "you can't post in this session"
2954
+ // What an app's activity entries look like (session.js recordActivity, relay-mcp.js quilt_write).
2955
+ const ACTIVITY_FIELDS = ['by', 'path', 'kind', 'detail', 'ts', 'branch', 'from']
2956
+ const ACTIVITY_KINDS = ['created', 'edited', 'deleted', 'switched']
2957
+ // Claim ops as the audit trail names them.
2958
+ const CLAIM_ACTIONS = { claim: 'claimed', release: 'released', request: 'requested', handoff: 'handed_off', withdraw: 'withdrew' }
2959
+ const ACTIVITY_DETAIL = /^(\+\d+ -\d+|\d+ bytes)?$/
2960
+ const nameTaken = (name) => `The name "${name}" belongs to someone else in this room; pick another name`
2961
+
2962
+ /** A saved member's access, in the relay's shape (members saved before access types may talk and have no exceptions). */
2963
+ const memberAccess = (m) => ({ role: m.role, scopes: m.scopes || [], scopesExcept: m.scopesExcept || [], talk: m.talk !== false })
2964
+
2965
+ /**
2966
+ * Why a connection's visit ended, from the close code, when the relay didn't close it
2967
+ * itself (those set ws.endReason). A clean close (1000, 1001, 1005) is the app or agent
2968
+ * leaving; anything else (1006 and the like) is a dropped connection.
2969
+ */
2970
+ export function endReasonFor (code) {
2971
+ if (code === 1000 || code === 1001 || code === 1005) return 'left'
2972
+ if (code === CLOSE_DENIED) return 'removed'
2973
+ if (code === CLOSE_ENDED) return 'session_ended'
2974
+ if (code === CLOSE_PASS_EXPIRED) return 'pass_expired'
2975
+ return 'disconnected'
2976
+ }
2977
+
2978
+ /** Closes the connection when its pass runs out, unless a newer one arrives first. */
2979
+ function trackPass (ws, pass) {
2980
+ ws.pass = pass
2981
+ clearTimeout(ws.passTimer)
2982
+ const left = Math.min(Math.max(pass.exp - Date.now(), 0), 2 ** 31 - 1)
2983
+ ws.passTimer = setTimeout(() => { ws.endReason = 'pass_expired'; ws.close(CLOSE_PASS_EXPIRED, PASS_EXPIRED) }, left)
2984
+ }
2985
+
2986
+ /** A MSG_PASS: a valid pass for the same account (or agent) and key extends the connection. */
2987
+ function renewPass (ws, buf) {
2988
+ let next = null
2989
+ try {
2990
+ const dec = decoding.createDecoder(buf)
2991
+ decoding.readVarUint(dec)
2992
+ next = verifyPass(String(JSON.parse(decoding.readVarString(dec)).pass || ''), ws.passKey)
2993
+ } catch {}
2994
+ if (!next || next.sub !== ws.pass.sub || next.kind !== ws.pass.kind || next.key !== ws.pass.key || next.room !== ws.pass.room) return false
2995
+ trackPass(ws, next)
2996
+ return true
2997
+ }
2998
+
2999
+ function send (ws, msg) {
3000
+ if (ws.readyState !== ws.OPEN) return
3001
+ if (ws.meter) ws.meter.out(msg.byteLength ?? msg.length ?? 0)
3002
+ ws.send(msg, (err) => { if (err) ws.terminate() })
3003
+ }
3004
+
3005
+ /**
3006
+ * Closes a socket for a reason the app should see (a normal `ws.close`, so a well-behaved
3007
+ * app gets the code and reason in its 'close' handler), but doesn't wait out the library's
3008
+ * full ~30s close-handshake timeout for a peer that never acks: if the close hasn't
3009
+ * finished on its own well before then, it terminates the socket outright. Used for closes
3010
+ * the relay forces for policy reasons (e.g. CLOSE_ROOM_FULL) rather than ones a healthy,
3011
+ * responsive client is expected to negotiate.
3012
+ */
3013
+ function closeSoon (ws, code, reason, ms = 2000) {
3014
+ ws.close(code, reason)
3015
+ const t = setTimeout(() => { if (ws.readyState !== ws.CLOSED) ws.terminate() }, ms)
3016
+ ws.once('close', () => clearTimeout(t))
3017
+ }
3018
+
3019
+ /** Whether anyone (a folder, a connected app or CLI, a hosted agent) is on branch `key` now. */
3020
+ function relayLooksActive (room, key) { return folderOn(room, key) || connectedOn(room, key) || hostedOn(room, key).length > 0 }
3021
+
3022
+ export function startServer ({ port = 4321, host = '0.0.0.0', dataDir = null, log = console.log, presenceOptions = {}, ...opts } = {}) {
3023
+ const cfg = relayConfig(opts)
3024
+ // Many machines (relay-cluster.js): each room on one of them, the accounts API says which.
3025
+ const cc = clusterConfig(opts)
3026
+ configureGithubBudget({ anonPerHour: cfg.githubAnonPerHour })
3027
+ const passKey = cfg.passPublicKey ? parsePublicKey(cfg.passPublicKey) : null
3028
+ if (cfg.passPublicKey && !passKey) throw new Error('QUILT_PASS_PUBLIC_KEY is not an Ed25519 public key (spki, base64url)')
3029
+ /** With sign-in on: the request's valid pass, or null. With it off: an empty pass. */
3030
+ // The relay's own key for large files on branches that sync with GitHub (relay-filekey.js).
3031
+ const relayFileKeyPair = cfg.gitSync ? relayFileKey(dataDir, { pem: opts.relayFileKey ?? process.env.QUILT_RELAY_FILE_KEY ?? '', log }) : null
3032
+ // Git's hooks on this machine ask the relay with this (relay-githttp.js): a secret of this process.
3033
+ const hookToken = crypto.randomBytes(24).toString('base64url')
3034
+ const httpPass = (req, room = '') => {
3035
+ if (!passKey) return {}
3036
+ const p = verifyPass(String(req.headers['x-quilt-pass'] || ''), passKey)
3037
+ // A room pass is good in its own room only.
3038
+ return p && (!p.room || !room || p.room === room) ? p : null
3039
+ }
3040
+ // Who a new session counts against: their account with sign-in on, otherwise their address.
3041
+ const starterOf = (pass, req) => pass && pass.sub ? `${pass.kind}:${pass.sub}` : clientIp(req)
3042
+ // Without sign-in the limit is per address, and the message says so (as before).
3043
+ const TOO_MANY = passKey ? 'too many new sessions; try again later' : 'too many new sessions from this address; try again later'
3044
+ if (dataDir) fs.mkdirSync(dataDir, { recursive: true })
3045
+ // The relay's git mirrors (relay-git.js): one bare repository per GitHub repository, one writer per branch.
3046
+ const mirrors = mirrorManager({ dataDir, log, ...(cfg.pushGapMs !== undefined ? { pushGapMs: cfg.pushGapMs } : {}), ...(cfg.pushesPerHour !== undefined ? { pushesPerHour: cfg.pushesPerHour } : {}) })
3047
+ // One live session per repository and branch (relay-lease.js): in the accounts API when this relay has one, else in this process.
3048
+ const leases = leaseKeeper({ apiUrl: cfg.apiUrl, secret: cfg.relayApiSecret, fetch: opts.apiFetch || globalThis.fetch, dataDir, log, ...(cfg.leaseTtlMs ? { ttlMs: cfg.leaseTtlMs } : {}), ...(cfg.leaseMarginMs !== undefined ? { marginMs: cfg.leaseMarginMs } : {}), holder: `relay:${cc.machineId || os.hostname()}:${process.pid}`.slice(0, 100) })
3049
+ // Whether the accounts API has workspaces on, for hosted agents' library tools: asked in
3050
+ // the background (never holding up the start) and again every 10 minutes.
3051
+ const apiFetch = opts.apiFetch || globalThis.fetch
3052
+ {
3053
+ const problems = clusterProblems(cc, cfg, { fileKey: relayFileKeyPair && relayFileKeyPair.shared ? 'shared' : '' })
3054
+ if (problems.length) throw new Error(`QUILT_CLUSTER is on, but the relay is missing ${problems.join(', ')}`)
3055
+ }
3056
+ const cluster = cc.on ? new Cluster({ cc, apiUrl: cfg.apiUrl, secret: cfg.relayApiSecret, fetch: apiFetch, log, dataDir }) : null
3057
+ const features = cfg.apiUrl ? watchFeatures({ apiUrl: cfg.apiUrl, fetch: apiFetch, log }) : null
3058
+ // Who is in which session, for the dashboard (presence.js). Off unless both settings are set,
3059
+ // and then only for connections with a pass. Visits a crash left open are ended now.
3060
+ const presence = cfg.apiUrl && cfg.relayApiSecret
3061
+ ? new PresenceReporter({ apiUrl: cfg.apiUrl, secret: cfg.relayApiSecret, file: dataDir ? path.join(dataDir, PRESENCE_FILE) : null, log, ...presenceOptions })
3062
+ : null
3063
+ // People's cloud AIs that are away (their machines suspend when idle) are woken through the API.
3064
+ const cloudAis = cfg.apiUrl && cfg.relayApiSecret ? cloudAiWaker({ apiUrl: cfg.apiUrl, secret: cfg.relayApiSecret, fetch: apiFetch, log }) : null
3065
+ if (presence) {
3066
+ const ended = presence.load()
3067
+ if (ended) log(`presence: ended ${ended} visit(s) left open by the last run`)
3068
+ presence.start()
3069
+ }
3070
+ const rooms = new Map() // loaded rooms only
3071
+ const ipConns = new Map()
3072
+ // New sessions are rate-limited per account when sign-in is on, otherwise per address.
3073
+ const newRooms = new Map() // starter -> creation timestamps in the last hour
3074
+ const canCreate = (starter) => {
3075
+ if (!cfg.maxNewRoomsPerHour) return true
3076
+ const cutoff = Date.now() - 60 * 60 * 1000
3077
+ const recent = (newRooms.get(starter) || []).filter((t) => t > cutoff)
3078
+ if (recent.length) newRooms.set(starter, recent); else newRooms.delete(starter)
3079
+ return recent.length < cfg.maxNewRoomsPerHour
3080
+ }
3081
+ const noteCreated = (starter) => newRooms.set(starter, [...(newRooms.get(starter) || []), Date.now()])
3082
+
3083
+ const tooBig = new Set() // stored rooms already reported as too big to load
3084
+ /** A stored room far over the limit would run the relay out of memory while loading. */
3085
+ const tooBigToLoad = (name) => {
3086
+ if (!dataDir || rooms.has(name)) return false
3087
+ let size = 0
3088
+ try { size = fs.statSync(path.join(dataDir, `${name}.ydoc`)).size } catch { return false }
3089
+ if (size <= cfg.maxRoomBytes * 2) return false
3090
+ if (!tooBig.has(name)) { tooBig.add(name); log(`[${name}] too big to load (${Math.round(size / MB)} MB stored, limit ${Math.round(cfg.maxRoomBytes / MB)} MB); refusing it`) }
3091
+ return true
3092
+ }
3093
+
3094
+ const unreadable = new Set() // stored rooms whose files could not be read, until they can
3095
+ /** Why getRoom gave null: the HTTP status and message to refuse with. */
3096
+ const refused = (name) => cluster && !rooms.has(name) && !cluster.holds(name) ? [503, cluster.moving.has(name) ? MOVING : ELSEWHERE] : unreadable.has(name) ? [503, UNREADABLE] : [413, TOO_BIG]
3097
+
3098
+ /** The room, loading it if needed; null if it's too big to load, or its files can't be read. */
3099
+ let webhooks = null // hosted agents' webhook subscriptions (set below, once `hosted` exists)
3100
+ let adoptHosted = null // puts back hosted agents waiting on a room (set below, once `hosted` exists)
3101
+ let endHostedVisits = null // ends the audit-trail visits of hosted agents in a room (set below)
3102
+ let sweepHosted = null // ends the visits of hosted agents gone quiet (set below)
3103
+ const getRoom = (name) => {
3104
+ let room = rooms.get(name)
3105
+ if (!room) {
3106
+ // On many machines, only the machine the room is placed on may load (or start) it.
3107
+ if (cluster && !cluster.holds(name)) return null
3108
+ if (tooBigToLoad(name)) return null
3109
+ try {
3110
+ room = new Room(name, dataDir, cfg, log)
3111
+ room.mirrors = mirrors
3112
+ room.leases = leases
3113
+ room.cluster = cluster
3114
+ room.dataDirOf = dataDir
3115
+ room.relayFileKey = relayFileKeyPair // large files on synced branches, once the owner agrees (plan §10)
3116
+ room.blobStore = store
3117
+ room.apiFetch = apiFetch // the API: credentials for agents' commits (relay-commit.js)
3118
+ room.refreshPolicy() // what agents may commit, when its org or workspace says
3119
+ } catch (err) {
3120
+ if (!err.unreadable) throw err
3121
+ // Logged once, not on every retry; tried again each time, in case the operator repaired it.
3122
+ if (!unreadable.has(name)) { unreadable.add(name); log(`[${name}] ${err.message}; refusing it, and leaving its files alone`) }
3123
+ return null
3124
+ }
3125
+ unreadable.delete(name)
3126
+ rooms.set(name, room)
3127
+ if (webhooks) webhooks.watch(room) // hosted agents' webhooks fire on its chat and board
3128
+ if (cloudAis) { room.cloudAiWaker = cloudAis; cloudAis.watch(room) } // people's cloud AIs are woken from it
3129
+ if (adoptHosted) adoptHosted(room)
3130
+ room.presence = presence
3131
+ if (opts.githubFetch) room.githubFetch = opts.githubFetch
3132
+ // Idle rooms are saved and dropped from memory (only when they're on disk).
3133
+ room.onEmpty = () => {
3134
+ if (!dataDir || rooms.get(name) !== room) return
3135
+ clearTimeout(room.unloadTimer)
3136
+ room.unloadTimer = setTimeout(() => {
3137
+ if (room.conns.size || rooms.get(name) !== room) return
3138
+ collectStored(room)
3139
+ room.destroy()
3140
+ rooms.delete(name)
3141
+ if (!room.exists) unplaceIfNothing(name)
3142
+ }, cfg.idleUnloadMs)
3143
+ }
3144
+ room.onEnd = () => {
3145
+ if (room.ended) return
3146
+ room.ended = true
3147
+ for (const ws of [...room.conns.keys(), ...room.pending.keys()]) if (!ws.hosted) { ws.endReason = 'session_ended'; ws.close(CLOSE_ENDED, 'The owner ended this session') }
3148
+ // Hosted agents in it have no connection to close: their visits end here.
3149
+ if (endHostedVisits) endHostedVisits(name, 'session_ended')
3150
+ clearTimeout(room.unloadTimer)
3151
+ room.store.discard(); room.guard.destroy(); room.awareness.destroy(); room.doc.destroy()
3152
+ if (rooms.get(name) === room) rooms.delete(name)
3153
+ removeRoomData(name)
3154
+ // A tombstone keeps the room refused for good, instead of letting a new one start under the same name.
3155
+ if (dataDir) {
3156
+ const now = Date.now()
3157
+ try {
3158
+ fs.writeFileSync(path.join(dataDir, `${name}.json`), JSON.stringify({ ended: true, endedAt: now, lastActive: now }))
3159
+ } catch (err) { log(`[${name}] could not write its tombstone: ${err.message}`) }
3160
+ }
3161
+ log(`[${name}] ended by its owner`)
3162
+ }
3163
+ }
3164
+ return room
3165
+ }
3166
+ // A room that was probed but never created (bad secret / no key) shouldn't linger.
3167
+ const dropIfUnused = (room) => {
3168
+ if (!room.exists && !room.conns.size) { room.destroy(); rooms.delete(room.name); unplaceIfNothing(room.name) }
3169
+ }
3170
+ // On many machines: a session name someone tried that never became a session isn't kept placed.
3171
+ const unplaceIfNothing = (name) => { if (cluster && !rooms.has(name) && !cluster.onDisk(name)) cluster.forgetPlacement(name) }
3172
+
3173
+ const clientIp = (req) => {
3174
+ if (cfg.trustProxy) {
3175
+ const fwd = String(req.headers['x-forwarded-for'] || '').split(',')[0].trim()
3176
+ if (fwd) return fwd
3177
+ }
3178
+ return req.socket.remoteAddress || 'unknown'
3179
+ }
3180
+
3181
+ // AI links: a browser's token -> the session and name that browser is in now,
3182
+ // so the person's AI can use /mcp/<token> (see relay-mcp.js). Tokens are stored hashed.
3183
+ const linksFile = dataDir && path.join(dataDir, 'agent-links.json')
3184
+ const links = new Map()
3185
+ try { for (const [k, v] of Object.entries(JSON.parse(fs.readFileSync(linksFile, 'utf8')))) links.set(k, v) } catch {}
3186
+ let linksTimer = null
3187
+ const saveLinks = () => {
3188
+ if (!linksFile || linksTimer) return
3189
+ linksTimer = setTimeout(() => {
3190
+ linksTimer = null
3191
+ const cutoff = Date.now() - 30 * DAY
3192
+ for (const [k, v] of links) if ((v.tabSeenAt || 0) < cutoff) links.delete(k)
3193
+ try { fs.writeFileSync(linksFile, JSON.stringify(Object.fromEntries(links))) } catch {}
3194
+ }, 2000)
3195
+ linksTimer.unref()
3196
+ }
3197
+ const tokenKey = (t) => hash(t).toString('hex')
3198
+ const TOKEN_RE = /^[A-Za-z0-9_-]{20,64}$/
3199
+
3200
+ // Hosted agents: which session each one (by pass id) is in, so every tool call over
3201
+ // /mcp knows its room. Kept on disk so a relay restart doesn't drop them out.
3202
+ // The newest Quilt this relay knows of (its own build, or GitHub's latest), so agents are told to update.
3203
+ const updates = new UpdateCheck().start()
3204
+ const hostedFile = dataDir && path.join(dataDir, 'hosted-agents.json')
3205
+ const hosted = new Map()
3206
+ try { for (const [k, v] of Object.entries(JSON.parse(fs.readFileSync(hostedFile, 'utf8')))) { const h = normalizeHosted(v); if (h) hosted.set(k, h) } } catch {}
3207
+ let hostedTimer = null
3208
+ const writeHosted = () => {
3209
+ hostedTimer = null
3210
+ const cutoff = Date.now() - 30 * DAY
3211
+ for (const [k, v] of hosted) if ((v.seenAt || 0) < cutoff) hosted.delete(k)
3212
+ try { fs.writeFileSync(hostedFile, JSON.stringify(Object.fromEntries(hosted))) } catch {}
3213
+ }
3214
+ const saveHosted = () => {
3215
+ if (!hostedFile || hostedTimer) return
3216
+ hostedTimer = setTimeout(writeHosted, 2000)
3217
+ hostedTimer.unref()
3218
+ }
3219
+ webhooks = hostedWebhooks({ hosted, saveHosted, log, fetch: opts.webhookFetch, delays: opts.webhookDelays })
3220
+ for (const room of rooms.values()) webhooks.watch(room)
3221
+ // Hosted agents' visits, for the audit trail. With no connection to open and close, a
3222
+ // visit starts at the first tool call it's let in for, and ends when it leaves, is
3223
+ // removed, its session ends, or it goes quiet for HOSTED_ONLINE_MS (ended at its last call).
3224
+ // An agent is in several sessions at once; each session's record (hosted-agents.js) holds its visit there.
3225
+ const openVisit = (r) => (r && r.visit && presence && presence.open.has(r.visit.start) ? r.visit : null)
3226
+ const hostedVisitStart = (id, room, { name, tool, owner }) => {
3227
+ const r = hostedIn(hosted.get(id), room.name)
3228
+ if (!r || !presence) return
3229
+ if (openVisit(r)) return
3230
+ r.visit = presence.visitStart({ room: room.name, account: id, name, owner: !!owner, via: 'hosted', tool })
3231
+ saveHosted()
3232
+ }
3233
+ const hostedVisitEnd = (id, roomName, reason, at) => {
3234
+ const r = hostedIn(hosted.get(id), roomName)
3235
+ const open = openVisit(r)
3236
+ if (!open) return
3237
+ presence.visitEnd(open, reason, at)
3238
+ delete r.visit
3239
+ saveHosted()
3240
+ }
3241
+ endHostedVisits = (roomName, reason) => { for (const [id, h] of hosted) if (hostedIn(h, roomName)) hostedVisitEnd(id, roomName, reason) }
3242
+ const sweepHostedVisits = () => {
3243
+ const cutoff = Date.now() - HOSTED_ONLINE_MS
3244
+ for (const [id, h] of hosted) for (const [roomName, r] of Object.entries(h.rooms || {})) if (openVisit(r) && (r.seenAt || 0) < cutoff) hostedVisitEnd(id, roomName, 'idle', r.seenAt)
3245
+ }
3246
+ sweepHosted = sweepHostedVisits
3247
+ // Hosted agents waiting to be let in are back on the owner's list when their room loads,
3248
+ // without having to call a tool first; one the owner turns away is told on its next call.
3249
+ adoptHosted = (room) => {
3250
+ if (cluster) for (const [id, h] of hosted) if (hostedIn(h, room.name)) syncHosted(id, room.name)
3251
+ room.hostedVisit = (id) => openVisit(hostedIn(hosted.get(id), room.name))
3252
+ room.onHostedGone = (id, reason) => { if (hostedIn(hosted.get(id), room.name)) hostedVisitEnd(id, room.name, reason) }
3253
+ for (const [id, h] of hosted) { const r = hostedIn(h, room.name); if (r && r.pending && !r.denied) room.restoreHosted(id, r) }
3254
+ room.onHostedDenied = (id) => {
3255
+ const r = hostedIn(hosted.get(id), room.name)
3256
+ if (r && r.pending) { r.denied = true; saveHosted() }
3257
+ }
3258
+ }
3259
+ for (const room of rooms.values()) adoptHosted(room)
3260
+ /**
3261
+ * On many machines, an agent's one webhook is kept by the accounts API (relay-cluster.js) so every
3262
+ * machine holding one of its sessions delivers to it: `off` there means it unsubscribed (on any
3263
+ * machine); none there yet means this machine's own (from before) is the one, and is shared.
3264
+ */
3265
+ function applySharedWebhook (account, w) {
3266
+ const h = hosted.get(account)
3267
+ if (!h) return
3268
+ const restock = () => { for (const r of Object.values(h.rooms || {})) delete r.webhookState }
3269
+ if (w && w.off) {
3270
+ if (h.webhook) { delete h.webhook; restock(); saveHosted() }
3271
+ } else if (w && w.url) {
3272
+ if (!h.webhook || h.webhook.url !== w.url || h.webhook.secret !== w.secret || h.webhook.bearer !== w.bearer || JSON.stringify(h.webhook.events) !== JSON.stringify(w.events)) { h.webhook = w; restock(); saveHosted() }
3273
+ } else if (h.webhook) cluster.hostedWebhook(account, h.webhook)
3274
+ }
3275
+ /** The agent's webhook from the accounts API, and this session in its index (a record from before cluster mode is added). */
3276
+ function syncHosted (account, roomName = '') {
3277
+ cluster.hostedOf(account, { fresh: true }).then((idx) => {
3278
+ if (!idx) return
3279
+ applySharedWebhook(account, idx.webhook)
3280
+ if (roomName && !idx.rooms.includes(roomName) && hostedIn(hosted.get(account), roomName)) cluster.hostedRoom(account, { add: roomName })
3281
+ }).catch((err) => log(`cluster: ${account}'s webhook: ${err.message}`))
3282
+ }
3283
+
3284
+ // Files shared in chat are stored on the relay, not in the synced project.
3285
+ const filesDir = path.join(dataDir || fs.mkdtempSync(path.join(os.tmpdir(), 'quilt-relay-')), 'files')
3286
+ // Large files, already encrypted by the apps. See blobstore.js.
3287
+ const store = makeStore(cfg, path.join(path.dirname(filesDir), 'blobs'))
3288
+ const storedBytes = (room) => Object.values(room.meta.blobs || {}).reduce((n, b) => n + (b.size || 0), 0)
3289
+ /** Deletes everything a room left on the relay and in storage. */
3290
+ const removeRoomData = (name) => {
3291
+ try {
3292
+ if (dataDir) {
3293
+ fs.rmSync(path.join(dataDir, `${name}.ydoc`), { force: true })
3294
+ fs.rmSync(path.join(dataDir, `${name}.json`), { force: true })
3295
+ fs.rmSync(path.join(dataDir, `${name}.chat.jsonl`), { force: true })
3296
+ fs.rmSync(path.join(dataDir, 'branches', name), { recursive: true, force: true })
3297
+ dropCheckpoints(dataDir, name) // quilt sync's checkpoints (relay-turn-sync.js)
3298
+ }
3299
+ fs.rmSync(path.join(filesDir, name), { recursive: true, force: true })
3300
+ } catch (err) { log(`[${name}] could not delete its files: ${err.message}`) }
3301
+ store.removeRoom(name).catch((err) => log(`[${name}] could not delete stored files: ${err.message}`))
3302
+ }
3303
+ /** Deletes stored files nothing points at any more (a day's grace for uploads in flight, or apps still offline). */
3304
+ const collectStored = (room) => {
3305
+ const blobs = room.meta.blobs || {}
3306
+ const used = room.storedIds()
3307
+ const cutoff = Date.now() - DAY
3308
+ const unused = Object.keys(blobs).filter((id) => !used.has(id) && (blobs[id].ts || 0) < cutoff)
3309
+ if (!unused.length) return
3310
+ for (const id of unused) delete blobs[id]
3311
+ room.saveMeta()
3312
+ store.remove(room.name, unused).catch((err) => log(`[${room.name}] could not delete stored files: ${err.message}`))
3313
+ }
3314
+ /** Refuses an ended room's tombstone without loading it into memory. */
3315
+ const roomEnded = (name) => {
3316
+ if (!dataDir) return false
3317
+ try { return !!JSON.parse(fs.readFileSync(path.join(dataDir, `${name}.json`), 'utf8')).ended } catch { return false }
3318
+ }
3319
+
3320
+ // Public: the app's relay check reads this. It says nothing about who's using the relay.
3321
+ const health = () => ({ ok: true, version: 1, requiresKey: !!cfg.relayKey })
3322
+ /** Whether a request carries the relay secret (the accounts API, or another relay machine). */
3323
+ const fromApi = (req) => {
3324
+ const auth = String(req.headers.authorization || '').replace(/^Bearer\s+/i, '')
3325
+ const want = cfg.relayApiSecret || ''
3326
+ return !!want && auth.length === want.length && crypto.timingSafeEqual(Buffer.from(auth), Buffer.from(want))
3327
+ }
3328
+ // A room's bytes on disk, worked out at most once a report (a minute): it walks the room's folders.
3329
+ const diskBytes = new Map() // room -> { at, n }
3330
+ const diskTtl = Math.max(0, Math.min(60 * 1000, cc.reportMs) - 100)
3331
+ const diskOf = (name) => {
3332
+ const c = diskBytes.get(name)
3333
+ const t = Date.now()
3334
+ if (c && t - c.at < diskTtl) return c.n
3335
+ const n = roomDiskBytes(dataDir, name)
3336
+ diskBytes.set(name, { at: t, n })
3337
+ return n
3338
+ }
3339
+ /** How busy and how big a loaded room is (relay-metrics.js): for /stats and the accounts API's placement. */
3340
+ const roomStats = (room) => ({ room: room.name, conns: room.conns.size, hosted: room.hostedSeen.size, ...room.meter.lastMinute(), docBytes: room.totalBytes(), diskBytes: diskOf(room.name) })
3341
+ const machineStats = () => {
3342
+ const mem = process.memoryUsage()
3343
+ return { rooms: rooms.size, conns: wss.clients.size, rss: mem.rss, heapUsed: mem.heapUsed, load: os.loadavg()[0], uptime: Math.round(process.uptime()) }
3344
+ }
3345
+ const stats = () => {
3346
+ for (const name of diskBytes.keys()) if (!rooms.has(name)) diskBytes.delete(name)
3347
+ return { machine: machineStats(), rooms: [...rooms.values()].filter((r) => !r.ended).map(roomStats) }
3348
+ }
3349
+ /** Closes a loaded room on this machine without ending it: its people reconnect (to wherever it is now). */
3350
+ const unloadForMove = (name) => {
3351
+ const room = rooms.get(name)
3352
+ if (!room) return
3353
+ // Everyone on it, including sockets still proving who they are.
3354
+ for (const ws of wss.clients) if (ws.roomName === name) { ws.endReason = 'relay_restart'; closeSoon(ws, 1012, 'This session is moving to another relay machine', 1000) }
3355
+ if (endHostedVisits) endHostedVisits(name, 'relay_restart')
3356
+ clearTimeout(room.unloadTimer)
3357
+ room.destroy()
3358
+ room.released = true // no save after this: the copy here is on its way to another machine
3359
+ rooms.delete(name)
3360
+ }
3361
+ /** The room is leaving this machine: refused from now on, saved, unloaded; its leases are the next machine's. */
3362
+ const letRoomGo = (name) => {
3363
+ cluster.release(name)
3364
+ unloadForMove(name)
3365
+ leases.forget(name)
3366
+ if (hostedTimer) { clearTimeout(hostedTimer); writeHosted() }
3367
+ log(`cluster: letting ${name} go to another machine`)
3368
+ }
3369
+ /** The room lives on `to` now: this machine's copy goes (never its large files in shared storage). */
3370
+ const forgetRoomHere = (name, to) => {
3371
+ unloadForMove(name)
3372
+ leases.forget(name)
3373
+ removeLocalRoom(dataDir, name)
3374
+ fs.rmSync(viewDir(dataDir, name), { recursive: true, force: true })
3375
+ for (const h of hosted.values()) if (hostedIn(h, name)) delete h.rooms[name]
3376
+ saveHosted()
3377
+ cluster.forget(name, to)
3378
+ log(`cluster: ${name} lives on ${to || 'another machine'} now; deleted its copy here`)
3379
+ }
3380
+ /** Hosted agents' records for a room, to go with it to its next machine. */
3381
+ const hostedRecordsOf = (name) => {
3382
+ const out = {}
3383
+ for (const [account, h] of hosted) { const r = hostedIn(h, name); if (r) out[account] = { record: r, ...(h.webhook ? { webhook: h.webhook } : {}) } }
3384
+ return out
3385
+ }
3386
+ const takeHostedRecords = (name, recs) => {
3387
+ if (!recs || typeof recs !== 'object') return
3388
+ for (const [account, x] of Object.entries(recs)) {
3389
+ if (!/^(person|agent):[A-Za-z0-9_-]{1,64}$/.test(account) || !x || typeof x.record !== 'object') continue
3390
+ const h = hosted.get(account) || newHosted()
3391
+ h.rooms[name] = x.record
3392
+ if (x.webhook && !h.webhook) h.webhook = x.webhook
3393
+ h.seenAt = Math.max(h.seenAt || 0, x.record.seenAt || 0)
3394
+ hosted.set(account, h)
3395
+ }
3396
+ saveHosted()
3397
+ }
3398
+
3399
+ // A request for a room on another machine: Fly's proxy sends it there (fly-replay), or, with a
3400
+ // body too big for that, this machine streams it there over the private network.
3401
+ const sendAway = (req, res, machine, body = null) => {
3402
+ if (canReplay(req, body)) {
3403
+ res.writeHead(409, { 'fly-replay': `instance=${machine}`, 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' })
3404
+ return res.end(ELSEWHERE)
3405
+ }
3406
+ cluster.forward(req, res, machine, body)
3407
+ }
3408
+ /** Where a request for `room` goes: { here } | { machine } | { moving } | { error }; never back where it came from. */
3409
+ const whereTo = async (room, req, pass = '') => {
3410
+ const src = replaySource(req)
3411
+ // Only someone signed in may have a new session placed (and so started); anyone else finds existing ones only.
3412
+ const create = !!(passKey && pass && verifyPass(String(pass), passKey))
3413
+ const w = await cluster.locate(room, { fresh: !!src, create })
3414
+ return w.machine && w.machine === src ? { error: ELSEWHERE } : w
3415
+ }
3416
+ // Hosted agents' MCP over HTTP: the body says which session (src/mcp-room.js), else the pass, else the agent's only session.
3417
+ const MAX_MCP_ROUTE_BODY = 8 * MB
3418
+ const mcpRoom = async (req, body) => {
3419
+ const named = joiningRoom(body) || sessionArg(body)
3420
+ if (named) return named
3421
+ const pass = httpPass(req)
3422
+ if (!pass) return ''
3423
+ if (pass.room) return pass.room
3424
+ const h = await cluster.hostedOf(`${pass.kind}:${pass.sub}`)
3425
+ return h && h.rooms.length === 1 ? h.rooms[0] : ''
3426
+ }
3427
+ const readRaw = (req, max) => new Promise((resolve, reject) => {
3428
+ const chunks = []
3429
+ let size = 0
3430
+ req.on('data', (c) => { size += c.length; if (size > max) { reject(Object.assign(new Error('too large'), { code: 413 })); req.destroy() } else chunks.push(c) })
3431
+ req.on('end', () => resolve(Buffer.concat(chunks)))
3432
+ req.on('error', reject)
3433
+ })
3434
+ /** On many machines: the room a request is about decides which machine answers it. */
3435
+ const route = async (req, res, url) => {
3436
+ const plain = (code, msg, extra = {}) => { res.writeHead(code, { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store', ...extra }); res.end(msg) }
3437
+ let room = roomOfPath(url.pathname)
3438
+ let body = null
3439
+ if (!room && req.method === 'POST' && (url.pathname === '/mcp' || url.pathname === '/agent/link')) {
3440
+ try { body = await readRaw(req, url.pathname === '/mcp' ? MAX_MCP_ROUTE_BODY : 4096) } catch (err) { return plain(err.code === 413 ? 413 : 400, err.code === 413 ? 'too large' : 'bad request') }
3441
+ if (url.pathname === '/mcp') room = await mcpRoom(req, body)
3442
+ else { try { const b = JSON.parse(body.toString('utf8')); room = typeof b.room === 'string' && ROOM_RE.test(b.room) ? b.room : '' } catch {} }
3443
+ }
3444
+ if (!room) return handle(req, res, url, body)
3445
+ const basic = /^Basic\s+([A-Za-z0-9+/=]+)$/i.exec(String(req.headers.authorization || '')) // git sends the pass as its password
3446
+ const w = await whereTo(room, req, req.headers['x-quilt-pass'] || (basic ? Buffer.from(basic[1], 'base64').toString('utf8').replace(/^[^:]*:/, '') : ''))
3447
+ if (w.none) return plain(404, 'no such session')
3448
+ if (w.here) {
3449
+ const r = rooms.get(room)
3450
+ if (r) r.meter.in(body ? body.length : Number(req.headers['content-length']) || 0)
3451
+ return handle(req, res, url, body)
3452
+ }
3453
+ if (w.machine) return sendAway(req, res, w.machine, body)
3454
+ plain(503, w.moving ? MOVING : w.error || ELSEWHERE, { 'retry-after': '5' })
3455
+ }
3456
+ const httpServer = http.createServer((req, res) => {
3457
+ const url = new URL(req.url, 'http://x')
3458
+ if (!cluster) return handle(req, res, url, null)
3459
+ route(req, res, url).catch((err) => {
3460
+ log(`cluster: routing ${url.pathname}: ${err.message}`)
3461
+ if (!res.headersSent) { res.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' }); res.end('the relay could not route this request') }
3462
+ })
3463
+ })
3464
+ /** Every HTTP request, on the machine that holds its room. `pre`: its body, when routing read it already. */
3465
+ function handle (req, res, url, pre) {
3466
+ const text = (code, msg) => { res.writeHead(code, { 'content-type': 'text/plain; charset=utf-8' }); res.end(msg) }
3467
+ const json = (code, body) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(body)) }
3468
+ if (pre) req.preBody = pre
3469
+
3470
+ if (url.pathname === '/healthz') {
3471
+ res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' })
3472
+ return res.end(JSON.stringify(health()))
3473
+ }
3474
+ // How busy and how big each loaded room is, and the machine: for the accounts API and operators, with the relay secret.
3475
+ if (url.pathname === '/stats') {
3476
+ if (!cfg.relayApiSecret) return text(404, 'not found')
3477
+ if (!fromApi(req)) return json(401, { error: 'only the accounts API may ask this' })
3478
+ return json(200, stats())
3479
+ }
3480
+ // GitHub said a branch moved (the API checked GitHub's signature; src/api/routes/github.js). Each
3481
+ // session following that branch looks now: its folders fetch at once, and the relay brings the
3482
+ // commits in itself where no folder can. Only a hint: everyone reads the truth from git.
3483
+ if (url.pathname === '/github/push' && req.method === 'POST') {
3484
+ const auth = String(req.headers.authorization || '').replace(/^Bearer\s+/i, '')
3485
+ const want = cfg.relayApiSecret || ''
3486
+ const same = want && auth.length === want.length && crypto.timingSafeEqual(Buffer.from(auth), Buffer.from(want))
3487
+ if (!same) return json(401, { error: 'only the accounts API may say this' })
3488
+ return readJson(req, 16 * 1024, (err, b) => {
3489
+ if (err || !b || typeof b.repo !== 'string' || typeof b.ref !== 'string') return json(400, { error: 'give repo and ref' })
3490
+ let told = 0
3491
+ for (const room of rooms.values()) if (room.notePush && room.notePush({ repo: b.repo, ref: b.ref, sha: typeof b.sha === 'string' ? b.sha : '', after: typeof b.after === 'string' ? b.after : '', before: typeof b.before === 'string' ? b.before : '', forced: b.forced === true, deleted: b.deleted === true, created: b.created === true })) told++
3492
+ json(200, { ok: true, rooms: told })
3493
+ })
3494
+ }
3495
+ // A check suite finished on GitHub (the API relays the webhook): every session following that repository looks at the pull requests its tasks are in.
3496
+ if (url.pathname === '/github/checks' && req.method === 'POST') {
3497
+ const auth = String(req.headers.authorization || '').replace(/^Bearer\s+/i, '')
3498
+ const want = cfg.relayApiSecret || ''
3499
+ const same = want && auth.length === want.length && crypto.timingSafeEqual(Buffer.from(auth), Buffer.from(want))
3500
+ if (!same) return json(401, { error: 'only the accounts API may say this' })
3501
+ return readJson(req, 16 * 1024, (err, b) => {
3502
+ if (err || !b || typeof b.repo !== 'string') return json(400, { error: 'give repo' })
3503
+ let told = 0
3504
+ for (const room of rooms.values()) if (room.noteChecks && room.noteChecks({ repo: b.repo, sha: typeof b.sha === 'string' ? b.sha : '', branch: typeof b.branch === 'string' ? b.branch : '', conclusion: typeof b.conclusion === 'string' ? b.conclusion : '' })) told++
3505
+ json(200, { ok: true, rooms: told })
3506
+ })
3507
+ }
3508
+ // A pull request changed on GitHub (the API relays the webhook): every session following that repository looks at the pull requests its tasks are in now.
3509
+ if (url.pathname === '/github/pull' && req.method === 'POST') {
3510
+ const auth = String(req.headers.authorization || '').replace(/^Bearer\s+/i, '')
3511
+ const want = cfg.relayApiSecret || ''
3512
+ const same = want && auth.length === want.length && crypto.timingSafeEqual(Buffer.from(auth), Buffer.from(want))
3513
+ if (!same) return json(401, { error: 'only the accounts API may say this' })
3514
+ return readJson(req, 16 * 1024, (err, b) => {
3515
+ if (err || !b || typeof b.repo !== 'string' || !Number.isInteger(b.number)) return json(400, { error: 'give repo and number' })
3516
+ let told = 0
3517
+ for (const room of rooms.values()) if (room.notePull && room.notePull({ repo: b.repo, number: b.number, action: typeof b.action === 'string' ? b.action : '', merged: b.merged === true, head: typeof b.head === 'string' ? b.head : '', base: typeof b.base === 'string' ? b.base : '', sha: typeof b.sha === 'string' ? b.sha : '' })) told++
3518
+ json(200, { ok: true, rooms: told })
3519
+ })
3520
+ }
3521
+ // An org or workspace changed what agents may commit (the accounts API): every open session asks again now.
3522
+ if (url.pathname === '/github/policy' && req.method === 'POST') {
3523
+ const auth = String(req.headers.authorization || '').replace(/^Bearer\s+/i, '')
3524
+ const want = cfg.relayApiSecret || ''
3525
+ const same = want && auth.length === want.length && crypto.timingSafeEqual(Buffer.from(auth), Buffer.from(want))
3526
+ if (!same) return json(401, { error: 'only the accounts API may say this' })
3527
+ for (const room of rooms.values()) room.refreshPolicy()
3528
+ return json(200, { ok: true, rooms: rooms.size })
3529
+ }
3530
+ // Someone connected GitHub (the API's callback): every session they are in checks its repository again now.
3531
+ if (url.pathname === '/github/connected' && req.method === 'POST') {
3532
+ const auth = String(req.headers.authorization || '').replace(/^Bearer\s+/i, '')
3533
+ const want = cfg.relayApiSecret || ''
3534
+ const same = want && auth.length === want.length && crypto.timingSafeEqual(Buffer.from(auth), Buffer.from(want))
3535
+ if (!same) return json(401, { error: 'only the accounts API may say this' })
3536
+ return readJson(req, 4 * 1024, (err, b) => {
3537
+ if (err || !b || !/^person:[A-Za-z0-9_-]{1,64}$/.test(String(b.account || ''))) return json(400, { error: 'give the account' })
3538
+ let n = 0
3539
+ for (const room of rooms.values()) {
3540
+ if (!room.meta || (room.meta.ownerSub !== b.account && !(room.meta.members && room.meta.members[b.account]))) continue
3541
+ room.noteConnections({ force: true }); n++
3542
+ }
3543
+ json(200, { ok: true, rooms: n })
3544
+ })
3545
+ }
3546
+ // Moving a room between machines (the accounts API runs it, src/api/relay-cluster.js): the source
3547
+ // lets it go, the target copies it from the source, the source forgets its copy (or takes it back).
3548
+ const cm = url.pathname.match(/^\/cluster\/rooms\/([A-Za-z0-9_-]{1,64})\/(release|export|fetch|forget|restore)$/)
3549
+ if (cm) {
3550
+ if (!cluster || !dataDir) return text(404, 'not found')
3551
+ if (!fromApi(req)) return json(401, { error: 'only the accounts API may ask this' })
3552
+ const [, name, action] = cm
3553
+ if (action === 'export') {
3554
+ if (req.method !== 'GET') return text(405, 'method not allowed')
3555
+ if (!cluster.moving.has(name)) return json(409, { error: `${name} is not being moved off this machine` })
3556
+ res.writeHead(200, { 'content-type': 'application/octet-stream', 'cache-control': 'no-store' })
3557
+ return packRoom(dataDir, name, { hosted: hostedRecordsOf(name) }, res).then((r) => log(`cluster: sent ${name} (${r.files} files, ${r.bytes} bytes)`)).catch((err) => { log(`cluster: could not send ${name}: ${err.message}`); res.destroy() })
3558
+ }
3559
+ if (req.method !== 'POST') return text(405, 'method not allowed')
3560
+ return readJson(req, 4096, (err, b) => {
3561
+ if (err) return json(400, { error: err.message })
3562
+ if (action === 'release') { letRoomGo(name); return json(200, { ok: true }) }
3563
+ if (action === 'restore') { cluster.restore(name); log(`cluster: ${name} stays on this machine`); return json(200, { ok: true }) }
3564
+ if (action === 'forget') { forgetRoomHere(name, String((b && b.to) || '')); return json(200, { ok: true }) }
3565
+ // fetch
3566
+ const from = String((b && b.from) || '')
3567
+ if (!/^[A-Za-z0-9_-]{1,64}$/.test(from) || from === cluster.machineId) return json(400, { error: 'give the machine to copy from' })
3568
+ if (rooms.has(name) || cluster.holds(name)) return json(409, { error: `${name} is on this machine already` })
3569
+ const stage = path.join(dataDir, '.incoming', name)
3570
+ cluster.fromPeer(from, `/cluster/rooms/${name}/export`)
3571
+ .then((stream) => unpackRoom(stream, name, stage))
3572
+ .then((got) => {
3573
+ placeStaged(dataDir, name, stage)
3574
+ takeHostedRecords(name, got.extras && got.extras.hosted)
3575
+ cluster.elsewhere.delete(name)
3576
+ log(`cluster: took ${name} from ${from} (${got.files} files, ${got.bytes} bytes)`)
3577
+ json(200, { ok: true, files: got.files, bytes: got.bytes })
3578
+ })
3579
+ .catch((e) => {
3580
+ fs.rmSync(stage, { recursive: true, force: true })
3581
+ log(`cluster: could not take ${name} from ${from}: ${e.message}`)
3582
+ json(502, { error: e.message })
3583
+ })
3584
+ })
3585
+ }
3586
+ if (url.pathname === '/logo.svg') {
3587
+ res.writeHead(200, { 'content-type': 'image/svg+xml', 'cache-control': 'public, max-age=86400' })
3588
+ return res.end(fs.readFileSync(LOGO))
3589
+ }
3590
+ const j = url.pathname.match(/^\/join\/([A-Za-z0-9_-]{1,64})\/?$/)
3591
+ if (j) {
3592
+ // Invites live on the website now. Browsers keep the #secret across the redirect.
3593
+ res.writeHead(302, { location: `https://${JOIN_HOST}/${j[1]}`, 'cache-control': 'no-store', 'referrer-policy': 'no-referrer' })
3594
+ return res.end()
3595
+ }
3596
+ if (url.pathname === '/agent/link' && req.method === 'POST') {
3597
+ const pass = httpPass(req)
3598
+ if (!pass) return text(401, SIGN_IN)
3599
+ return readJson(req, 4096, (err, body) => {
3600
+ if (err) return text(400, err.message)
3601
+ const { token, room: roomName, secret, name, tool } = body || {}
3602
+ if (!TOKEN_RE.test(String(token)) || !ROOM_RE.test(String(roomName)) || typeof name !== 'string' || !name.trim()) return text(400, 'bad link')
3603
+ if (pass.room && pass.room !== roomName) return text(401, SIGN_IN)
3604
+ if (roomEnded(roomName)) return text(410, ENDED_MESSAGE)
3605
+ const room = getRoom(roomName)
3606
+ if (!room) return text(...refused(roomName))
3607
+ if (!room.exists || room.authorize(String(secret || ''), '') !== 'editor') { dropIfUnused(room); return text(403, 'wrong room secret') }
3608
+ if (!room.conns.size) room.onEmpty && room.onEmpty()
3609
+ const k = tokenKey(token)
3610
+ const prev = links.get(k) || {}
3611
+ links.set(k, { room: roomName, name: (pass.name || name).trim().slice(0, 60), tool: String(tool || '').slice(0, 40), tabSeenAt: Date.now(), aiSeenAt: prev.aiSeenAt || 0 })
3612
+ saveLinks()
3613
+ res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' })
3614
+ res.end(JSON.stringify({ ok: true, aiSeenAt: prev.aiSeenAt || 0 }))
3615
+ })
3616
+ }
3617
+ // Agents' folders use git through the relay (relay-githttp.js, plan §9): clone, fetch and push a session's
3618
+ // branches at /git/<room>.git, signed in with the member's Quilt pass (git's password; quilt git-credential).
3619
+ const gm = url.pathname.match(/^\/git\/([A-Za-z0-9_-]{1,64})\.git(\/info\/refs|\/git-upload-pack|\/git-receive-pack)$/)
3620
+ if (gm) {
3621
+ if (!cfg.gitSync || !passKey || !dataDir) return text(404, 'this relay serves no git')
3622
+ const name = gm[1]
3623
+ const basic = /^Basic\s+([A-Za-z0-9+/=]+)$/i.exec(String(req.headers.authorization || ''))
3624
+ const decoded = basic ? Buffer.from(basic[1], 'base64').toString('utf8') : ''
3625
+ const pass = decoded ? verifyPass(decoded.slice(decoded.indexOf(':') + 1), passKey) : null
3626
+ if (!pass || (pass.room && pass.room !== name)) { res.writeHead(401, { 'www-authenticate': 'Basic realm="Quilt"', 'content-type': 'text/plain' }); return res.end('Sign in with your Quilt pass: git asks Quilt for it (quilt git-credential).\n') }
3627
+ const room = rooms.get(name) || getRoom(name)
3628
+ if (!room || !room.exists || room.ended) return text(404, 'no such session')
3629
+ if (!room.letIn(pass)) return text(403, NOT_LET_IN)
3630
+ const service = gm[2] === '/info/refs' ? url.searchParams.get('service') : gm[2].slice(1)
3631
+ if (service === 'git-receive-pack') { const a = room.httpAccess(pass); if (a && a.role === 'viewer') return text(403, 'you can only view this session, so you can\'t push') }
3632
+ const def = viewRepoOf(room)
3633
+ if (!def) { log(`[${name}] git over http: no GitHub repository (default ${room.defaultKey}: ${JSON.stringify(upstreamsOf(room)[room.defaultKey] || null)})`); return text(404, 'this session follows no GitHub repository') }
3634
+ const repo = parseRepo(`https://${def.repo}`)
3635
+ ;(async () => {
3636
+ const m = await room.mirrors.ensure(repo, { url: room.gitRemoteUrl(repo) })
3637
+ if (!(await runGit(m.dir, ['rev-parse', '--verify', '-q', `refs/remotes/origin/${def.ref}`])).out.trim()) await room.mirrors.fetch(m.dir, def.ref, { token: await room.gitToken(repo, { access: 'read' }) }).catch(() => {})
3638
+ const dir = await ensureView(dataDir, name, m.dir)
3639
+ await syncViewRefs(room, dir, def.repo)
3640
+ serveGit(req, res, { root: path.dirname(dir), pathInfo: `/${name}.git${gm[2]}`, query: url.search.slice(1), env: { QUILT_HOOK_URL: `http://127.0.0.1:${httpServer.address().port}/git-hook`, QUILT_HOOK_TOKEN: hookToken, QUILT_ROOM: name, QUILT_WHO: `${pass.kind}:${pass.sub}`, QUILT_WHO_NAME: String(pass.name || '') } })
3641
+ })().catch((err) => { log(`[${name}] git over http: ${err.message}`); if (!res.headersSent) text(500, 'the relay could not serve git right now') })
3642
+ return
3643
+ }
3644
+ // A push into a session's git view asks here, from git's hook on this machine (relay-githttp.js, git-hook.js).
3645
+ if (url.pathname === '/git-hook' && req.method === 'POST') {
3646
+ const auth = String(req.headers.authorization || '').replace(/^Bearer\s+/i, '')
3647
+ if (auth.length !== hookToken.length || !crypto.timingSafeEqual(Buffer.from(auth), Buffer.from(hookToken))) return json(401, { ok: false, message: 'Quilt: not this relay\'s push' })
3648
+ return readJson(req, 1024 * 1024, async (err, b) => {
3649
+ try {
3650
+ if (err || !b || typeof b.room !== 'string') return json(400, { ok: false, message: 'Quilt: a malformed push' })
3651
+ const room = rooms.get(b.room) || getRoom(b.room)
3652
+ if (!room || !room.exists) return json(404, { ok: false, message: 'Quilt: no such session' })
3653
+ const updates = parseUpdates(b.updates)
3654
+ if (b.phase === 'post') {
3655
+ // The view's branches go back to what GitHub has (the push may have been replayed onto a newer head).
3656
+ const published = (room.viewPublished && room.viewPublished.get(String(b.who))) || new Map()
3657
+ const dir = viewDir(dataDir, b.room)
3658
+ for (const u of updates) { const sha = published.get(u.ref); if (sha) await runGit(dir, ['update-ref', u.ref, sha]) }
3659
+ const def = viewRepoOf(room)
3660
+ if (def) await syncViewRefs(room, dir, def.repo)
3661
+ return json(200, { ok: true })
3662
+ }
3663
+ const r = await receiveAgentPush(room, { who: String(b.who || ''), name: String(b.name || ''), updates, env: b.env || {} }, { log })
3664
+ if (r.ok) { if (!room.viewPublished) room.viewPublished = new Map(); room.viewPublished.set(String(b.who), r.published) }
3665
+ return json(200, { ok: r.ok, message: r.message })
3666
+ } catch (e) { log(`[${b && b.room}] git push: ${e.message}`); return json(500, { ok: false, message: `Quilt: the relay couldn't take the push (${e.message})` }) }
3667
+ })
3668
+ }
3669
+ if (url.pathname === '/mcp') {
3670
+ // Hosted agents (and anyone with a pass): session tools over HTTP, no app needed.
3671
+ if (!passKey) return text(404, 'this relay has sign-in off; hosted agents need it on')
3672
+ const pass = httpPass(req)
3673
+ if (!pass) return text(401, SIGN_IN)
3674
+ const workspaces = features && features.on() && pass.kind === 'agent' ? { apiUrl: cfg.apiUrl, pass: String(req.headers['x-quilt-pass']), fetch: apiFetch } : null
3675
+ let parsed
3676
+ if (pre) { try { parsed = JSON.parse(pre.toString('utf8')) } catch { return json(400, { jsonrpc: '2.0', error: { code: -32700, message: 'Parse error' }, id: null }) } }
3677
+ const account = `${pass.kind}:${pass.sub}`
3678
+ // On many machines: the agent's sessions everywhere, and its webhook, from the accounts API.
3679
+ const shared = cluster
3680
+ ? cluster.hostedOf(account).then((idx) => {
3681
+ if (!idx) return {}
3682
+ applySharedWebhook(account, idx.webhook)
3683
+ return { index: { rooms: [...idx.rooms] }, noteHosted: (o) => cluster.hostedRoom(account, o), shareWebhook: (w) => cluster.hostedWebhook(account, w || null) }
3684
+ })
3685
+ : Promise.resolve({})
3686
+ return shared.then((more) => handleHostedMcp({ req, res, pass, workspaces, body: parsed, relay: { getRoom, roomEnded, refused, hosted, saveHosted, webhooks, log, endedMessage: ENDED_MESSAGE, updates, visitStart: hostedVisitStart, visitEnd: hostedVisitEnd, ...more } })
3687
+ // Its first session on this machine: the webhook it set elsewhere applies here too.
3688
+ .then(() => { if (more.index && cluster.hosted.get(account)) applySharedWebhook(account, cluster.hosted.get(account).webhook) }))
3689
+ .catch((err) => { log(`mcp error: ${err.message}`); if (!res.headersSent) text(500, 'mcp error') })
3690
+ }
3691
+ const mm = url.pathname.match(/^\/mcp\/([A-Za-z0-9_-]{20,64})$/)
3692
+ if (mm) {
3693
+ // An AI link carries no pass, so with sign-in on it would get into a session without one.
3694
+ // Agents join as their own members now; links saved before that stop working here.
3695
+ if (passKey) return text(401, SIGN_IN)
3696
+ const k = tokenKey(mm[1])
3697
+ const link = links.get(k) || null
3698
+ let room = null
3699
+ if (link) {
3700
+ room = getRoom(link.room)
3701
+ if (!room) return text(...refused(link.room))
3702
+ if (!room.exists) { dropIfUnused(room); room = null } else {
3703
+ link.aiSeenAt = Date.now()
3704
+ saveLinks()
3705
+ }
3706
+ }
3707
+ handleAgentMcp({ req, res, room, link: room ? link : null, toolHint: url.searchParams.get('tool') || '', updates })
3708
+ .catch((err) => { log(`mcp error: ${err.message}`); if (!res.headersSent) text(500, 'mcp error') })
3709
+ .finally(() => { if (room && !room.conns.size && room.onEmpty) room.onEmpty() })
3710
+ return
3711
+ }
3712
+ // Chat links: GET pages a chat-only AI opens to read and talk (chat-links.js).
3713
+ if (url.pathname.startsWith('/c/') && handleChatLink(req, res, url, { getRoom, roomEnded, refused, dropIfUnused, endedMessage: ENDED_MESSAGE, fetchFile: opts.chatFetch || ((u) => fetchPublicFile(u)) })) return
3714
+ const bm = url.pathname.match(/^\/blobs\/([A-Za-z0-9_-]{1,64})\/([a-f0-9]{32})\/(upload|download|data)$/)
3715
+ if (bm) {
3716
+ const [, name, id, action] = bm
3717
+ if (roomEnded(name)) return text(410, ENDED_MESSAGE)
3718
+ if (action === 'data') {
3719
+ // The disk store's signed links: no secret needed, the signature is the permission.
3720
+ if (!(store instanceof DiskStore)) return text(404, 'not found')
3721
+ const method = req.method === 'PUT' ? 'PUT' : 'GET'
3722
+ const n = url.searchParams.get('n')
3723
+ if (url.searchParams.get('m') !== method || !store.verify(name, id, method, url.searchParams.get('exp'), url.searchParams.get('sig'), method === 'PUT' ? Number(n) : undefined)) return text(403, 'this link has expired')
3724
+ const file = store.file(name, id)
3725
+ if (method === 'GET') return streamFile(res, file, text)
3726
+ return receiveBlob(req, file, Number(n), (err) => err ? text(err.code || 500, err.message) : text(201, 'stored'))
3727
+ }
3728
+ if (req.method !== 'POST') return text(405, 'method not allowed')
3729
+ const pass = httpPass(req, name)
3730
+ if (!pass) return text(401, SIGN_IN)
3731
+ const starter = starterOf(pass, req)
3732
+ const room = getRoom(name)
3733
+ if (!room) return text(...refused(name))
3734
+ const creating = !room.exists
3735
+ if (creating && !canCreate(starter)) { dropIfUnused(room); return text(429, TOO_MANY) }
3736
+ const auth = room.authorize(req.headers['x-quilt-secret'] || '', req.headers['x-quilt-key'] || '')
3737
+ if (auth === 'need-key' || auth === 'bad-secret') {
3738
+ dropIfUnused(room)
3739
+ return text(auth === 'need-key' ? 403 : 401, auth === 'need-key' ? 'this relay needs a key to create rooms' : 'wrong room secret')
3740
+ }
3741
+ if (creating) noteCreated(starter)
3742
+ const done = () => { if (!room.conns.size && room.onEmpty) room.onEmpty() }
3743
+ if (passKey && !room.letIn(pass)) { done(); return text(403, NOT_LET_IN) }
3744
+ return readJson(req, 1024, async (err, body) => {
3745
+ try {
3746
+ if (err) return text(400, err.message)
3747
+ room.meta.blobs = room.meta.blobs || {}
3748
+ if (action === 'download') {
3749
+ if (!room.meta.blobs[id]) return text(404, 'no such file')
3750
+ return json(200, await store.downloadTarget(name, id))
3751
+ }
3752
+ // The room secret and, with sign-in on, the person's own access: a view-only grant
3753
+ // with the edit link may not use up the room's storage.
3754
+ if (auth !== 'editor' || (passKey && room.httpAccess(pass)?.role === 'viewer')) return text(403, 'you can only view this session')
3755
+ const size = Number(body && body.size)
3756
+ if (!(size >= 0)) return text(400, 'size required')
3757
+ if (size > cfg.maxStoredFileBytes) return text(413, `files over ${Math.round(cfg.maxStoredFileBytes / MB)} MB can't be shared`)
3758
+ const others = storedBytes(room) - (room.meta.blobs[id]?.size || 0)
3759
+ if (others + size + dirSize(path.join(filesDir, name)) > cfg.maxRoomFileBytes) return text(413, 'this room has used its file storage quota')
3760
+ room.meta.blobs[id] = { size, ts: Date.now() }
3761
+ room.startLargeFiles()
3762
+ room.saveMeta()
3763
+ json(200, await store.uploadTarget(name, id, size))
3764
+ } catch (e) {
3765
+ log(`[${name}] storage error: ${e.message}`)
3766
+ if (!res.headersSent) text(502, 'file storage is unavailable right now')
3767
+ } finally { done() }
3768
+ })
3769
+ }
3770
+ const m = url.pathname.match(/^\/files\/([A-Za-z0-9_-]{1,64})(?:\/([a-f0-9]{32}))?$/)
3771
+ if (!m) return text(404, 'not found')
3772
+
3773
+ const [, name, id] = m
3774
+ if (roomEnded(name)) return text(410, ENDED_MESSAGE)
3775
+ const pass = httpPass(req, name)
3776
+ if (!pass) return text(401, SIGN_IN)
3777
+ const starter = starterOf(pass, req)
3778
+ const room = getRoom(name)
3779
+ if (!room) return text(...refused(name))
3780
+ const creating = !room.exists
3781
+ if (creating && !canCreate(starter)) { dropIfUnused(room); return text(429, TOO_MANY) }
3782
+ const auth = room.authorize(req.headers['x-quilt-secret'] || req.headers['x-cowove-secret'] || '', req.headers['x-quilt-key'] || req.headers['x-cowove-key'] || '')
3783
+ if (creating && auth !== 'need-key' && auth !== 'bad-secret') noteCreated(starter)
3784
+ if (auth === 'need-key' || auth === 'bad-secret') {
3785
+ dropIfUnused(room)
3786
+ return text(auth === 'need-key' ? 403 : 401, auth === 'need-key' ? 'this relay needs a key to create rooms' : 'wrong room secret')
3787
+ }
3788
+ if (passKey && !room.letIn(pass)) {
3789
+ if (!room.conns.size && room.onEmpty) room.onEmpty()
3790
+ return text(403, NOT_LET_IN)
3791
+ }
3792
+ const dir = path.join(filesDir, name)
3793
+ if (req.method === 'POST' && !id) {
3794
+ // A chat file is a post: someone who may not post may not send one.
3795
+ if (passKey && room.httpAccess(pass)?.talk === false) {
3796
+ if (!room.conns.size && room.onEmpty) room.onEmpty()
3797
+ return text(403, TALK_REFUSED)
3798
+ }
3799
+ // Chat files and stored large files share one quota.
3800
+ const used = dirSize(dir) + storedBytes(room)
3801
+ const incoming = Number(req.headers['content-length'] || 0)
3802
+ if (used + incoming > cfg.maxRoomFileBytes) return text(413, 'this room has used its file storage quota')
3803
+ return receiveFile(req, dir, cfg.maxRoomFileBytes - used, (err, newId) => err ? text(err.code || 500, err.message) : text(201, newId))
3804
+ }
3805
+ if (req.method === 'GET' && id) return streamFile(res, path.join(dir, id), text)
3806
+ text(405, 'method not allowed')
3807
+ }
3808
+ // One message can't be bigger than a whole room may be.
3809
+ const wss = new WebSocketServer({ noServer: true, maxPayload: Math.max(MB, Math.min(64 * MB, cfg.maxRoomBytes)) })
3810
+
3811
+ httpServer.on('upgrade', (req, socket, head) => {
3812
+ if (!cluster) return upgrade(req, socket, head)
3813
+ // On many machines, the room's machine takes the connection: Fly's proxy replays it there.
3814
+ let name
3815
+ try { name = decodeURIComponent(new URL(req.url, 'http://x').pathname.slice(1)) } catch { return reject(socket, 400, 'Bad room name') }
3816
+ if (!ROOM_RE.test(name)) return reject(socket, 400, 'Bad room name')
3817
+ socket.on('error', () => {})
3818
+ let passGiven = ''
3819
+ try { passGiven = String(req.headers['x-quilt-pass'] || new URL(req.url, 'http://x').searchParams.get('pass') || '') } catch {}
3820
+ whereTo(name, req, passGiven).then((w) => {
3821
+ if (w.here) return upgrade(req, socket, head)
3822
+ if (w.none) return reject(socket, 401, SIGN_IN)
3823
+ if (w.machine) {
3824
+ socket.write(`HTTP/1.1 409 Conflict\r\nfly-replay: instance=${w.machine}\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: ${Buffer.byteLength(ELSEWHERE)}\r\nConnection: close\r\n\r\n${ELSEWHERE}`)
3825
+ return socket.destroy()
3826
+ }
3827
+ reject(socket, 503, w.moving ? MOVING : w.error || ELSEWHERE)
3828
+ }).catch((err) => { log(`cluster: routing a connection to ${name}: ${err.message}`); reject(socket, 500, 'The relay could not route this connection') })
3829
+ })
3830
+ function upgrade (req, socket, head) {
3831
+ let url, name
3832
+ try {
3833
+ url = new URL(req.url, 'http://x')
3834
+ name = decodeURIComponent(url.pathname.slice(1))
3835
+ } catch {
3836
+ return reject(socket, 400, 'Bad room name')
3837
+ }
3838
+ // Secrets come in headers, which proxies don't log; the query string is only still read
3839
+ // for clients from before 0.3.2, and goes away in the release after. Never log req.url.
3840
+ const header = (n) => (req.headers[n] === undefined ? '' : String(req.headers[n]))
3841
+ const secret = header('x-quilt-secret') || url.searchParams.get('secret') || ''
3842
+ const publicKey = url.searchParams.get('key') || ''
3843
+ const relayKey = header('x-quilt-key') || header('x-cowove-key') || url.searchParams.get('relayKey') || ''
3844
+ const viewSecret = header('x-quilt-view-secret') || url.searchParams.get('viewSecret') || ''
3845
+ if (!ROOM_RE.test(name)) return reject(socket, 400, 'Bad room name')
3846
+ // With sign-in on, nobody gets further without a pass, and who they are comes from it.
3847
+ const pass = passKey ? verifyPass(header('x-quilt-pass') || url.searchParams.get('pass') || '', passKey) : null
3848
+ if (passKey && (!pass || pass.key !== publicKey || (pass.room && pass.room !== name))) return reject(socket, 401, SIGN_IN)
3849
+ const person = pass ? pass.name : (url.searchParams.get('name') || '').trim()
3850
+ const kind = pass ? (pass.kind === 'agent' ? 'agent' : 'human') : (url.searchParams.get('kind') === 'agent' ? 'agent' : 'human')
3851
+ // People and agents come from different id spaces, so the kind is part of who they are.
3852
+ const account = pass ? `${pass.kind}:${pass.sub}` : ''
3853
+ // Checked before the room is touched: a room's creator is recorded by this key (see authorize).
3854
+ if (!publicKey) return reject(socket, 400, 'This relay needs a newer quilt; please update')
3855
+ const key = parsePublicKey(publicKey)
3856
+ if (!person || person.length > MAX_NAME || !key) return reject(socket, 400, 'Bad name or identity key')
3857
+ // Every session keeps its files in branch documents: an app that can't sync them is told to update.
3858
+ const features = String(url.searchParams.get('features') || '').split(',')
3859
+ if (!features.includes('branches')) return reject(socket, 400, NEEDS_UPDATE)
3860
+ if (roomEnded(name)) return reject(socket, 410, ENDED_MESSAGE)
3861
+ const ip = clientIp(req)
3862
+ const starter = pass ? account : ip
3863
+ if ((ipConns.get(ip) || 0) >= cfg.maxConnsPerIp) return reject(socket, 429, 'Too many connections')
3864
+ const room = getRoom(name)
3865
+ if (!room) return reject(socket, ...refused(name))
3866
+ const creating = !room.exists
3867
+ if (creating && !canCreate(starter)) { dropIfUnused(room); return reject(socket, 429, 'Too many new sessions') }
3868
+ const auth = room.authorize(secret, relayKey, viewSecret, publicKey)
3869
+ if (creating && auth !== 'need-key' && auth !== 'bad-secret') noteCreated(starter)
3870
+ if (auth === 'need-key' || auth === 'bad-secret') {
3871
+ dropIfUnused(room)
3872
+ return reject(socket, auth === 'need-key' ? 403 : 401, auth === 'need-key' ? 'Relay key required to create rooms' : 'Wrong room secret')
3873
+ }
3874
+ // Checked after the secret, so only members learn what the session needs.
3875
+ if (room.meta.largeFiles && !features.includes('large-files')) {
3876
+ if (!room.conns.size && room.onEmpty) room.onEmpty() // don't keep it in memory for nobody
3877
+ return reject(socket, 400, NEEDS_UPDATE)
3878
+ }
3879
+ if (!pass && !room.keyMatches(person, publicKey)) return reject(socket, 403, nameTaken(person))
3880
+ wss.handleUpgrade(req, socket, head, (ws) => {
3881
+ // A frame over maxPayload or a reset mid-frame is this socket's problem, not
3882
+ // the relay's: ws closes the socket itself once the error has a listener.
3883
+ ws.on('error', (err) => log(`[${name}] dropping ${person}: ${err.message}`))
3884
+ ws.roomName = name
3885
+ ws.features = features
3886
+ // Which app or AI tool this is ("Quilt app", "Cursor"), for the audit trail; the app says so if it knows.
3887
+ ws.tool = header('x-quilt-tool').replace(/[^\w .()/+-]/g, '').trim().slice(0, 40)
3888
+ if (pass) { ws.passKey = passKey; trackPass(ws, pass) }
3889
+ ipConns.set(ip, (ipConns.get(ip) || 0) + 1)
3890
+ ws.isAlive = true
3891
+ ws.on('pong', () => { ws.isAlive = true })
3892
+ ws.on('close', () => {
3893
+ clearTimeout(ws.passTimer)
3894
+ const n = (ipConns.get(ip) || 1) - 1
3895
+ if (n) ipConns.set(ip, n)
3896
+ else ipConns.delete(ip)
3897
+ })
3898
+ room.admit(ws, person, publicKey, key, { kind, invitedAs: auth, account }, () => {
3899
+ log(`[${name}] ${person} connected (${room.conns.size} online)`)
3900
+ ws.on('close', () => log(`[${name}] ${person} left (${room.conns.size} online)`))
3901
+ if (room.full) log(`[${name}] ${person} joined while over quota (read-only)`)
3902
+ })
3903
+ })
3904
+ }
3905
+
3906
+ // Many machines: hello now (the rooms on this disk), then this machine's minute for the accounts API.
3907
+ let reportTicker = null
3908
+ if (cluster) {
3909
+ cluster.start({
3910
+ onDisplaced: (names) => {
3911
+ for (const name of names) {
3912
+ if (rooms.has(name)) continue
3913
+ try { log(`cluster: ${name} is placed on another machine; its copy here was moved to ${displaceRoom(dataDir, name)}`) } catch (err) { log(`cluster: could not move ${name} aside: ${err.message}`) }
3914
+ }
3915
+ }
3916
+ })
3917
+ reportTicker = setInterval(() => {
3918
+ cluster.report(machineStats(), [...rooms.values()].filter((r) => !r.ended).map(roomStats)).catch((err) => log(`cluster: could not report this machine's minute: ${err.message}`))
3919
+ // Hosted agents in loaded rooms: a webhook set or removed on another machine reaches this one within a minute.
3920
+ let n = 0
3921
+ for (const [id, h] of hosted) if (n < 200 && h.rooms && Object.keys(h.rooms).some((r) => rooms.has(r))) { n++; syncHosted(id) }
3922
+ }, cc.reportMs)
3923
+ reportTicker.unref()
3924
+ }
3925
+
3926
+ const heartbeat = setInterval(() => {
3927
+ for (const ws of wss.clients) {
3928
+ if (!ws.isAlive) { ws.endReason = 'disconnected'; ws.terminate(); continue }
3929
+ ws.isAlive = false
3930
+ ws.ping()
3931
+ }
3932
+ for (const room of rooms.values()) if (!room.ended) { room.sweepClaims(); room.sweepBoxes() }
3933
+ if (sweepHosted) sweepHosted()
3934
+ }, 30000)
3935
+
3936
+ // Delete rooms nobody has opened for a while (hosted relays shouldn't grow forever).
3937
+ const sweep = () => {
3938
+ mirrors.prune()
3939
+ if (!dataDir || !cfg.roomTtlDays) return
3940
+ const cutoff = Date.now() - cfg.roomTtlDays * DAY
3941
+ let removed = 0
3942
+ for (const f of fs.readdirSync(dataDir)) {
3943
+ if (!f.endsWith('.json')) continue
3944
+ const name = f.slice(0, -5)
3945
+ if (!ROOM_RE.test(name) || NOT_ROOMS.has(name)) continue
3946
+ if (rooms.has(name)) continue
3947
+ let meta
3948
+ try {
3949
+ meta = JSON.parse(fs.readFileSync(path.join(dataDir, f), 'utf8'))
3950
+ } catch (err) {
3951
+ // Never delete what can't be read: it may be a session cut off mid-write, for the operator to repair.
3952
+ log(`[${name}] could not read its metadata (${err.message}); leaving it alone`)
3953
+ continue
3954
+ }
3955
+ if ((meta.lastActive || meta.createdAt || 0) > cutoff) continue
3956
+ if (cluster && cluster.moving.has(name)) continue
3957
+ removeRoomData(name)
3958
+ if (cluster) cluster.forgetPlacement(name)
3959
+ removed++
3960
+ }
3961
+ if (removed) log(`removed ${removed} room(s) idle for more than ${cfg.roomTtlDays} days`)
3962
+ }
3963
+ sweep()
3964
+ const sweeper = setInterval(sweep, 6 * 60 * 60 * 1000)
3965
+ sweeper.unref()
3966
+ // Branches only hosted agents work on, with no folder online: the relay looks for new commits on GitHub itself.
3967
+ const upstreamTicker = cfg.upstreamAuto && cfg.upstreamCheckMs > 0
3968
+ ? setInterval(() => { for (const room of rooms.values()) if (!room.ended) { room.checkUpstreams().catch((err) => log(`[${room.name}] could not look for new commits: ${err.message}`)); room.reviewClashTick().catch((err) => log(`[${room.name}] could not look at the clashes: ${err.message}`)) } }, Math.min(cfg.gitSync ? 20 * 1000 : 60 * 1000, cfg.upstreamCheckMs)) // with the mirror, often enough that each branch's minute look isn't a tick late
3969
+ : null
3970
+ upstreamTicker?.unref()
3971
+ // Live-sync leases renewed, and let go of for sessions that ended or sat idle for a day (relay-lease.js).
3972
+ const leaseTicker = cfg.gitSync ? setInterval(() => {
3973
+ leases.tick({ ended: (name) => { const r = rooms.get(name); return !!(r && r.ended) } }).then((lost) => {
3974
+ for (const l of lost) { const r = rooms.get(l.room); if (r && !r.ended) { r.log(`[${r.name}] lost the live-sync lease of ${l.branch} to ${l.to || 'nobody (lapsed)'}`); r.broadcastBranches() } }
3975
+ }).catch((err) => log(`lease tick: ${err.message}`))
3976
+ }, cfg.leaseHeartbeatMs || LEASE_HEARTBEAT_MS) : null
3977
+ leaseTicker?.unref()
3978
+ // Agents with work not in git on GitHub are reminded to commit it (relay-commit.js), about every ten minutes.
3979
+ const remindTicker = cfg.upstreamAuto
3980
+ ? setInterval(() => {
3981
+ for (const room of rooms.values()) {
3982
+ if (room.ended) continue
3983
+ for (const key of new Set([...Object.keys(upstreamsOf(room)), ...Object.keys(room.meta.branches || {})].map((k) => room.resolveKey(k)))) remindUnpublished(room, key, { fetch: room.githubFetch }).catch((err) => log(`[${room.name}] ${key}: could not remind about uncommitted work: ${err.message}`))
3984
+ reviewPulls(room, { fetch: room.githubFetch, log }).catch((err) => log(`[${room.name}] could not look at the tasks' pull requests: ${err.message}`))
3985
+ }
3986
+ }, 10 * 60 * 1000)
3987
+ : null
3988
+ remindTicker?.unref()
3989
+
3990
+ return new Promise((resolve, reject) => {
3991
+ httpServer.once('error', (err) => { clearInterval(heartbeat); clearInterval(sweeper); clearInterval(upstreamTicker); clearInterval(remindTicker); features?.stop(); reject(err) })
3992
+ httpServer.listen(port, host, () => {
3993
+ const actualPort = httpServer.address().port
3994
+ resolve({
3995
+ port: actualPort,
3996
+ config: cfg,
3997
+ rooms, // exposed for tests
3998
+ loadRoom: (name) => getRoom(name), // tests: a room from its disk, with nobody connected
3999
+ store, // exposed for tests
4000
+ presence, // exposed for tests
4001
+ hosted, // exposed for tests
4002
+ mirrors, // the git mirrors (exposed for tests)
4003
+ leases, // the live-sync leases (exposed for tests)
4004
+ sweepHosted: () => sweepHosted && sweepHosted(), // exposed for tests: ends quiet hosted agents' visits now
4005
+ features, // exposed for tests
4006
+ sweep,
4007
+ // exposed for tests: the relay's look at GitHub for a loaded room now ({ force }: every branch it looks after, not only those due)
4008
+ checkUpstreams: (name, o) => { const room = rooms.get(name); return room ? room.checkUpstreams(o) : Promise.resolve([]) },
4009
+ cluster, // exposed for tests
4010
+ close: async () => {
4011
+ clearInterval(reportTicker)
4012
+ if (cluster) cluster.stop()
4013
+ clearInterval(leaseTicker)
4014
+ clearInterval(heartbeat)
4015
+ clearInterval(sweeper)
4016
+ clearInterval(upstreamTicker)
4017
+ clearInterval(remindTicker)
4018
+ features?.stop()
4019
+ updates.stop()
4020
+ // A join or a denial in the last moments before a deploy is kept.
4021
+ if (hostedTimer) { clearTimeout(hostedTimer); writeHosted() }
4022
+ // Rooms are saved first, synchronously: Fly's kill timeout is about as long as
4023
+ // presence gets to reach the accounts API, so a hung or slow API must never be
4024
+ // able to delay saving a room's data.
4025
+ for (const ws of wss.clients) { ws.endReason = 'relay_restart'; ws.terminate() }
4026
+ for (const room of rooms.values()) room.destroy()
4027
+ rooms.clear()
4028
+ wss.close()
4029
+ // Every open visit ends now (even ones no `leave` got to yet), and the queue
4030
+ // gets one last, bounded try at the accounts API.
4031
+ if (presence) await presence.close()
4032
+ // An upload or download still in flight would otherwise hold close() open forever.
4033
+ const closed = new Promise((resolve) => httpServer.close(() => resolve()))
4034
+ httpServer.closeAllConnections()
4035
+ await closed
4036
+ }
4037
+ })
4038
+ })
4039
+ })
4040
+ }
4041
+
4042
+ function dirSize (dir) {
4043
+ let total = 0
4044
+ try { for (const f of fs.readdirSync(dir)) total += fs.statSync(path.join(dir, f)).size } catch {}
4045
+ return total
4046
+ }
4047
+
4048
+ function readJson (req, limit, done) {
4049
+ // Read already, to see which machine it is for (relay-cluster.js).
4050
+ if (req.preBody) {
4051
+ if (req.preBody.length > limit) return done(new Error('too large'))
4052
+ try { return done(null, JSON.parse(req.preBody.toString('utf8'))) } catch { return done(new Error('bad json')) }
4053
+ }
4054
+ let size = 0
4055
+ const chunks = []
4056
+ req.on('data', (c) => {
4057
+ size += c.length
4058
+ if (size > limit) { req.destroy(); done(new Error('too large')) } else chunks.push(c)
4059
+ })
4060
+ req.on('end', () => {
4061
+ if (size > limit) return
4062
+ try { done(null, JSON.parse(Buffer.concat(chunks).toString('utf8'))) } catch { done(new Error('bad json')) }
4063
+ })
4064
+ }
4065
+
4066
+ function receiveFile (req, dir, room, done) {
4067
+ const id = crypto.randomBytes(16).toString('hex')
4068
+ const file = path.join(dir, id)
4069
+ let out
4070
+ try {
4071
+ fs.mkdirSync(dir, { recursive: true })
4072
+ out = fs.createWriteStream(file)
4073
+ } catch { req.resume(); return done(Object.assign(new Error('could not save the file'), { code: 500 })) }
4074
+ const limit = Math.min(MAX_SHARED_FILE_BYTES, room)
4075
+ let size = 0
4076
+ let failed = false
4077
+ const fail = (code, message) => {
4078
+ if (failed) return
4079
+ failed = true
4080
+ out.destroy()
4081
+ fs.rm(file, { force: true }, () => {})
4082
+ done(Object.assign(new Error(message), { code }))
4083
+ }
4084
+ req.on('data', (chunk) => {
4085
+ size += chunk.length
4086
+ if (size > limit) { fail(413, limit < MAX_SHARED_FILE_BYTES ? 'this room has used its file storage quota' : 'file too large'); req.destroy() }
4087
+ })
4088
+ req.on('error', () => fail(400, 'upload interrupted'))
4089
+ req.pipe(out)
4090
+ out.on('finish', () => { if (!failed) done(null, id) })
4091
+ out.on('error', (err) => fail(500, err.message))
4092
+ }
4093
+
4094
+ /**
4095
+ * Sends a stored file. The file can vanish at any moment (the room ended, or
4096
+ * the sweep ran), so every step copes with that instead of throwing.
4097
+ */
4098
+ function streamFile (res, file, text) {
4099
+ const rs = fs.createReadStream(file)
4100
+ rs.on('error', (err) => {
4101
+ if (!res.headersSent) text(err.code === 'ENOENT' || err.code === 'EISDIR' ? 404 : 500, 'no such file')
4102
+ else res.destroy()
4103
+ })
4104
+ rs.on('open', (fd) => {
4105
+ let size
4106
+ try { size = fs.fstatSync(fd).size } catch { rs.destroy(); return text(404, 'no such file') }
4107
+ res.writeHead(200, { 'content-type': 'application/octet-stream', 'content-length': size })
4108
+ rs.pipe(res)
4109
+ })
4110
+ }
4111
+
4112
+ /**
4113
+ * Streams a request body to `file`, refusing anything over `limit` bytes.
4114
+ * Stored files are written once: their ids come from their content, so one
4115
+ * that's already there is refused with 409 and left as it is.
4116
+ */
4117
+ function receiveBlob (req, file, limit, done) {
4118
+ let size = 0
4119
+ let failed = false
4120
+ let drained = 0
4121
+ let out = null
4122
+ let tmp = null
4123
+ const fail = (code, message) => {
4124
+ if (failed) return
4125
+ failed = true
4126
+ if (out) { req.unpipe(out); out.destroy() }
4127
+ if (tmp) fs.rm(tmp, { force: true }, () => {})
4128
+ // Read what's left so the reply gets through, but not forever.
4129
+ req.resume()
4130
+ done(Object.assign(new Error(message), { code }))
4131
+ }
4132
+ req.on('data', (chunk) => {
4133
+ if (failed) { drained += chunk.length; if (drained > MB) req.destroy(); return }
4134
+ size += chunk.length
4135
+ if (size > limit) fail(413, 'file too large')
4136
+ })
4137
+ req.on('error', () => fail(400, 'upload interrupted'))
4138
+ if (fs.existsSync(file)) return fail(409, 'already stored')
4139
+ try {
4140
+ fs.mkdirSync(path.dirname(file), { recursive: true })
4141
+ tmp = `${file}.${crypto.randomBytes(4).toString('hex')}.tmp`
4142
+ out = fs.createWriteStream(tmp)
4143
+ } catch { return fail(500, 'could not save the file') }
4144
+ out.on('error', () => fail(500, 'could not save the file'))
4145
+ out.on('finish', () => {
4146
+ if (failed) return
4147
+ // link, unlike rename, never replaces a file that's already there. It
4148
+ // also fails if the room's folder is gone by now (it ended, or was swept).
4149
+ try {
4150
+ fs.linkSync(tmp, file)
4151
+ } catch (err) {
4152
+ return err.code === 'EEXIST' ? fail(409, 'already stored') : fail(500, 'could not save the file')
4153
+ }
4154
+ fs.rm(tmp, { force: true }, () => {})
4155
+ done(null)
4156
+ })
4157
+ req.pipe(out)
4158
+ }
4159
+
4160
+ const TOO_BIG = 'Session over the size limit'
4161
+ const UNREADABLE = "This session's data can't be read on the relay right now"
4162
+ const ENDED_MESSAGE = 'The owner ended this session'
4163
+ const NEEDS_UPDATE = 'This session needs a newer version of Quilt. Update Quilt, then join again.'
4164
+ const SIGN_IN = 'Update Quilt and sign in to continue'
4165
+ const NOT_LET_IN = "The session owner hasn't let you in yet."
4166
+ const PASS_EXPIRED = 'Your sign-in expired. Reconnecting.'
4167
+
4168
+ function reject (socket, code, message) {
4169
+ socket.write(`HTTP/1.1 ${code} ${message}\r\nConnection: close\r\n\r\n`)
4170
+ socket.destroy()
4171
+ }
4172
+
4173
+ /**
4174
+ * Why a pass's holder may be in the room, as the accounts API signed it ({ kind: 'grant' },
4175
+ * { kind: 'workspace', via, workspace }, { kind: 'org', workspace, org }), trimmed to that
4176
+ * shape for the member list; null when it says nothing.
4177
+ */
4178
+ function cleanFrom (from) {
4179
+ if (!from || typeof from !== 'object' || !['grant', 'workspace', 'org'].includes(from.kind)) return null
4180
+ const text = (v) => typeof v === 'string' ? v.slice(0, 80) : ''
4181
+ return {
4182
+ kind: from.kind,
4183
+ ...(from.kind === 'workspace' && ['owner', 'member', 'placed', 'global'].includes(from.via) ? { via: from.via } : {}),
4184
+ ...(from.kind !== 'grant' && text(from.workspace) ? { workspace: text(from.workspace) } : {}),
4185
+ ...(from.kind === 'org' && text(from.org) ? { org: text(from.org) } : {})
4186
+ }
4187
+ }