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/runner.js ADDED
@@ -0,0 +1,306 @@
1
+ // Starting and stopping a session for a folder. Shared by `quilt join` and
2
+ // `quilt ui` so both behave identically (config, STATUS.md, control API).
3
+ import fs from 'node:fs'
4
+ import os from 'node:os'
5
+ import path from 'node:path'
6
+ import crypto from 'node:crypto'
7
+ import { Session } from './session.js'
8
+ import { startControl } from './control.js'
9
+ import { renderStatus } from './status.js'
10
+ import { startAgentReaders } from './agents/index.js'
11
+ import { relayUrl, isHostedRelay, getSettings } from './settings.js'
12
+ import { createSummarizer, createTaskTitler } from './summarize.js'
13
+ import { quiltHome, migrateDir } from './legacy.js'
14
+ import { writePrivateJson } from './private-file.js'
15
+ import { listProcesses } from './procs.js'
16
+ import { JOIN_HOST, INVALID_INVITE, buildInvite, parseInvite } from './ui/invite.js'
17
+ import { installHooks, HOOKS_FILE } from './setup.js'
18
+ import { releaseLeftoverHookClaims } from './hooks.js'
19
+ import { registerOnStart } from './integrations.js'
20
+ import { autowork } from './autowork.js'
21
+ import { keptInProject } from './agent-join.js'
22
+
23
+ export { JOIN_HOST }
24
+
25
+ /**
26
+ * An invite link: https://join.heyquilt.com/<room>#<secret> for sessions on Quilt's relay,
27
+ * or https://<relay>/join/<room>#<secret> for any other relay (development relays).
28
+ */
29
+ export function encodeInvite (c) {
30
+ return buildInvite(c, isHostedRelay)
31
+ }
32
+
33
+ /**
34
+ * Reads an invite link (or an older base64 code), with or without "quilt join" or "quilt:" in front.
35
+ * A link naming its own relay is accepted only for Quilt's relay or the one this computer already
36
+ * uses (QUILT_SERVER), so a crafted link can't hand this computer's pass and files to another relay.
37
+ */
38
+ export function decodeInvite (code) {
39
+ const r = parseInvite(code, { allowRelay: (s) => isHostedRelay(s) || s === relayUrl() })
40
+ return { server: r.relay || relayUrl(), room: r.room, secret: r.secret }
41
+ }
42
+
43
+ /** A new room on Quilt's relay: `secret` invites people to edit, `viewSecret` to only watch. */
44
+ export function newConn (server = relayUrl()) {
45
+ return {
46
+ server,
47
+ // 64 random bits: a new session never draws the name of one already on the relay.
48
+ room: `room-${crypto.randomBytes(8).toString('hex')}`,
49
+ secret: crypto.randomBytes(18).toString('base64url'),
50
+ viewSecret: crypto.randomBytes(18).toString('base64url')
51
+ }
52
+ }
53
+
54
+ export function readConfig (dir) {
55
+ try { return JSON.parse(fs.readFileSync(path.join(migrateDir(dir), 'config.json'), 'utf8')) } catch { return null }
56
+ }
57
+
58
+ /** The other process syncing this exact folder ({ pid, port, token } from its daemon.json), or null. */
59
+ function otherSync (dir) {
60
+ try {
61
+ const info = JSON.parse(fs.readFileSync(path.join(dir, '.quilt', 'daemon.json'), 'utf8'))
62
+ if (info.pid === process.pid) return null
63
+ process.kill(info.pid, 0)
64
+ return info
65
+ } catch {
66
+ return null
67
+ }
68
+ }
69
+
70
+ /** True if another process is already syncing this exact folder. */
71
+ export function runningElsewhere (dir) {
72
+ return !!otherSync(dir)
73
+ }
74
+
75
+ /**
76
+ * Says what is already syncing `dir`, so the person can stop it rather than guess: an AI
77
+ * agent that joined from its tool's quilt MCP server, a `quilt join` in a terminal, another
78
+ * copy of the app. Null when nothing else is.
79
+ */
80
+ export async function describeOtherSync (dir) {
81
+ const other = otherSync(dir)
82
+ if (!other) return null
83
+ let info = null
84
+ try {
85
+ const res = await fetch(`http://127.0.0.1:${other.port}/info`, { headers: { authorization: `Bearer ${other.token}` }, signal: AbortSignal.timeout(1500) })
86
+ if (res.ok) info = await res.json()
87
+ } catch {}
88
+ const proc = listProcesses().find((p) => p.pid === other.pid)
89
+ const who = info && info.kind === 'agent'
90
+ ? `your AI agent "${info.name}" joined it from its tool's quilt MCP server (process ${other.pid}). Ask the agent to leave with quilt_leave_session, or close that tool`
91
+ : proc && proc.kind === 'app'
92
+ ? `another copy of the Quilt app (process ${other.pid}) has it open. Leave it there, or quit that app`
93
+ : proc && proc.kind === 'sync'
94
+ ? `a \`quilt join\` in a terminal (process ${other.pid}) is syncing it${info ? ` as "${info.name}"` : ''}. Stop that one (Ctrl-C there, or \`quilt stop\`)`
95
+ : `another quilt process (${other.pid}) is syncing it${info ? ` as "${info.name}"` : ''}. Stop that one first`
96
+ return `This folder is already being synced by another quilt process: ${who}, then rejoin here.`
97
+ }
98
+
99
+ /**
100
+ * Starts syncing `dir`. `conn` is { server, room, secret }; `inviteServer`
101
+ * optionally overrides the relay address given out in invites (e.g. a public
102
+ * tunnel URL when the relay runs on this machine).
103
+ */
104
+ export async function runSession ({ dir, conn, name, tool, color = null, shareByDefault = true, summarizeByDefault = false, joined = false, prefer = 'remote', inviteServer, onLog, onFatal, onDebug, kind = 'human', agentFeed = true, readerOptions = {}, passes = null, identity = null, startName = '', startAgentCommits = '', workspace = '' }) {
105
+ dir = path.resolve(dir)
106
+ if (!/^wss?:\/\//.test(conn.server)) throw new Error('The relay address must start with ws:// or wss://')
107
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
108
+ if (!fs.statSync(dir).isDirectory()) throw new Error(`${dir} is not a folder`)
109
+ const busy = await describeOtherSync(dir)
110
+ if (busy) throw new Error(busy)
111
+
112
+ // With passes, the relay knows you by your account (or agent): its name and agent
113
+ // badge come from the pass. The session starts with the name saved on this computer
114
+ // (or one already fetched) and takes the pass's once it arrives, without waiting for
115
+ // one here, so being offline doesn't stop a session from starting.
116
+ const p = passes && passes.payload
117
+ if (p && p.name) name = p.name
118
+ if (p && p.kind === 'agent') kind = 'agent'
119
+ name = (name || os.userInfo().username).trim()
120
+ tool = tool || 'unknown'
121
+ const invite = encodeInvite({ ...conn, server: inviteServer || conn.server })
122
+ // Only the person who made the room has the view-only secret.
123
+ const viewInvite = conn.viewSecret ? encodeInvite({ ...conn, secret: conn.viewSecret, server: inviteServer || conn.server }) : null
124
+ const previous = readConfig(dir)
125
+ // Sharing your AI chat follows your setting; a pause or resume is remembered for this folder.
126
+ const shareAgent = previous && previous.room === conn.room && typeof previous.shareAgent === 'boolean' ? previous.shareAgent : shareByDefault !== false
127
+ const summarize = previous && previous.room === conn.room && typeof previous.summarize === 'boolean' ? previous.summarize : !!summarizeByDefault
128
+ // The workspace carries over only for the same room: a new room in this folder starts outside any.
129
+ const savedWorkspace = workspace || (previous && previous.room === conn.room ? previous.workspace : '') || ''
130
+ fs.mkdirSync(path.join(dir, '.quilt'), { recursive: true })
131
+ const configFile = path.join(dir, '.quilt', 'config.json')
132
+ // It holds the room secret: written privately and atomically (see private-file.js).
133
+ writePrivateJson(configFile, { ...conn, name, tool, kind, inviteServer: inviteServer || undefined, shareAgent, summarize, workspace: savedWorkspace || undefined })
134
+ // Keeps the saved name in step with the pass's (the rest of the file may have changed since).
135
+ const saveName = (name) => {
136
+ try { writePrivateJson(configFile, { ...JSON.parse(fs.readFileSync(configFile, 'utf8')), name }) } catch {}
137
+ }
138
+ ensureGitExclude(dir)
139
+
140
+ // An agent whose keys are kept in its project (quilt agent join --here) is in a sandbox that is cleaned up after every run: it works in turns.
141
+ const turns = kind === 'agent' && keptInProject(name)
142
+ const session = new Session({ dir, ...conn, name, tool, color, prefer, kind, shareAgent, identity, passes, startName, startAgentCommits, workspace: savedWorkspace, turns })
143
+ const summarizer = () => createSummarizer({ onWarn: (msg) => session.log(`✂️ ${msg}`) })
144
+ if (summarize) session.summarizer = summarizer()
145
+ session.taskTitler = createTaskTitler({ onWarn: (msg) => session.log(`📋 ${msg}`) })
146
+ if (onLog) session.on('log', onLog)
147
+ if (onDebug) session.on('debug', onDebug)
148
+ session.on('fatal', (err) => onFatal && onFatal(err))
149
+ const statusFile = path.join(dir, '.quilt', 'STATUS.md')
150
+ session.on('status-changed', () => {
151
+ // Read config fresh: the workspace can change after the session started (the app's
152
+ // "move to workspace", or a later quilt_start_session call writing it here).
153
+ try { fs.writeFileSync(statusFile, renderStatus({ ...session.status(), workspace: readConfig(dir)?.workspace })) } catch {}
154
+ })
155
+
156
+ try {
157
+ await session.start({ waitTimeoutMs: 15000 })
158
+ } catch (err) {
159
+ await session.stop().catch(() => {})
160
+ throw err
161
+ }
162
+ // The pass may have named us differently from the name we started with.
163
+ if (session.name !== name) saveName(session.name)
164
+ session.on('identity', ({ name }) => saveName(name))
165
+ const control = await startControl(session, { invite, viewInvite, joined })
166
+ // Work for this person's AI can run on its own here, when they turned that on (autowork.js).
167
+ autowork().attach(session)
168
+ remember({ dir, room: conn.room, server: conn.server, name: session.name, tool, kind, workspace: savedWorkspace })
169
+
170
+ // Every AI tool on this computer can reach Quilt (its MCP server, by absolute path), with nothing to set up.
171
+ registerOnStart((line) => session.log(line))
172
+ // Claude Code also applies Quilt's rules by itself through hooks (src/hooks.js), in this person's own
173
+ // settings file; everyone's session writes its own, so the rule holds for everyone in the room.
174
+ try { if (installHooks(dir)) session.log(`🪝 added Quilt's Claude Code hooks to ${HOOKS_FILE}: files are checked before Claude edits them`) } catch {}
175
+ releaseLeftoverHookClaims(session).then((n) => { if (n) session.log(`🔓 released ${n} claim(s) left by an earlier AI session`) }).catch(() => {})
176
+
177
+ // Share this person's AI chat (Claude Code, Cursor) with the room.
178
+ const readers = agentFeed
179
+ ? startAgentReaders({
180
+ dir,
181
+ ...readerOptions,
182
+ onEntries: (entries) => session.pushAgentEntries(entries),
183
+ onState: (state) => session.setAgentState(state),
184
+ onLog: (line) => onLog && onLog(line)
185
+ })
186
+ : null
187
+
188
+ let stopped = false
189
+ return {
190
+ session,
191
+ invite,
192
+ viewInvite,
193
+ summarizer,
194
+ dir,
195
+ stop: async () => {
196
+ if (stopped) return
197
+ stopped = true
198
+ if (readers) readers.stop()
199
+ await control.close()
200
+ await session.stop()
201
+ }
202
+ }
203
+ }
204
+
205
+ /** Keep .quilt/ and this person's hook settings out of git without editing the (synced) .gitignore. */
206
+ function ensureGitExclude (dir) {
207
+ const exclude = path.join(dir, '.git', 'info', 'exclude')
208
+ try {
209
+ if (!fs.existsSync(path.join(dir, '.git'))) return
210
+ let text = fs.existsSync(exclude) ? fs.readFileSync(exclude, 'utf8') : ''
211
+ for (const line of ['.quilt/', HOOKS_FILE]) {
212
+ if (text.split('\n').includes(line)) continue
213
+ fs.mkdirSync(path.dirname(exclude), { recursive: true })
214
+ text = `${text && !text.endsWith('\n') ? `${text}\n` : text}${line}\n`
215
+ fs.writeFileSync(exclude, text)
216
+ }
217
+ } catch {}
218
+ }
219
+
220
+ // ------------------------------------------------- whose folder is it? --
221
+ // A folder is a person's unless it is an agent's own copy of a room, and that is known
222
+ // by its name: "quilt-<room>-<agent>" (or "quilt-<room>" from older versions), which is
223
+ // how agents' copies are always made. Who ran it last says nothing: an agent run in a
224
+ // person's folder (an older version, a `quilt join --agent` there) must not make it the
225
+ // agent's, or the person loses it from Recent and can't get back in.
226
+
227
+ const expandHome = (p) => p === '~' ? os.homedir() : String(p).startsWith('~/') ? path.join(os.homedir(), p.slice(2)) : p
228
+ const slug = (name) => String(name || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'agent'
229
+
230
+ /** True if `dir` is an agent's own copy of `room`. */
231
+ export function isAgentCopy (dir, room) {
232
+ const base = path.basename(path.resolve(dir))
233
+ return base === `quilt-${room}` || base.startsWith(`quilt-${room}-`)
234
+ }
235
+
236
+ /**
237
+ * A folder a person synced from this computer (the app, or `quilt join`): any synced folder that
238
+ * isn't an agent's copy, or (for `agent`) the agent's own folder: one it synced itself, like a
239
+ * sandbox's project that it rejoins every turn.
240
+ */
241
+ export function personsFolder (dir, agent = null) {
242
+ const saved = readConfig(dir)
243
+ if (!saved || isAgentCopy(dir, saved.room)) return false
244
+ return !(agent && saved.kind === 'agent' && saved.name === agent)
245
+ }
246
+
247
+ /**
248
+ * Where an agent keeps its own copy of a room: "quilt-<room>-<agent>" under this computer's
249
+ * join folder (the app's "Join into" setting, ~/quilt by default), so it never lands inside
250
+ * a person's project, and two agents on one computer never share a copy.
251
+ */
252
+ export function agentCopyFolder (room, agent) {
253
+ const root = path.resolve(expandHome(getSettings().joinDir || '~/quilt'))
254
+ const dir = path.resolve(root, `quilt-${room}-${slug(agent)}`)
255
+ if (path.dirname(dir) !== root) throw new Error(INVALID_INVITE)
256
+ return dir
257
+ }
258
+
259
+ // Recently used folders, for the UI's "rejoin" list.
260
+ const recentFile = () => path.join(quiltHome(), 'recent.json')
261
+
262
+ /** A person's recent folders. An agent's own copies of rooms are its to rejoin, not the app's. */
263
+ export function recentSessions () {
264
+ try {
265
+ return JSON.parse(fs.readFileSync(recentFile(), 'utf8')).filter((r) => !isAgentCopy(r.dir, r.room) && fs.existsSync(path.join(r.dir, '.quilt', 'config.json')))
266
+ } catch {
267
+ return []
268
+ }
269
+ }
270
+
271
+ /** Drops a folder from the recent list (its files and settings stay). */
272
+ export function forgetRecent (dir) {
273
+ try {
274
+ const list = JSON.parse(fs.readFileSync(recentFile(), 'utf8')).filter((r) => r.dir !== dir)
275
+ fs.writeFileSync(recentFile(), JSON.stringify(list, null, 2))
276
+ } catch {}
277
+ }
278
+
279
+ /** Takes a folder's session out of its workspace on this computer: its config and the recent list. */
280
+ export function forgetWorkspace (dir) {
281
+ const saved = readConfig(dir)
282
+ if (saved && saved.workspace) {
283
+ const { workspace, ...rest } = saved
284
+ try { writePrivateJson(path.join(dir, '.quilt', 'config.json'), rest) } catch {}
285
+ }
286
+ rememberWorkspace(dir, '')
287
+ }
288
+
289
+ /** Records which workspace a remembered folder's session is in. */
290
+ export function rememberWorkspace (dir, workspace) {
291
+ try {
292
+ const list = JSON.parse(fs.readFileSync(recentFile(), 'utf8')).map((r) => (r.dir === dir ? { ...r, workspace } : r))
293
+ fs.writeFileSync(recentFile(), JSON.stringify(list, null, 2))
294
+ } catch {}
295
+ }
296
+
297
+ function remember (entry) {
298
+ // An agent's run in a person's folder isn't theirs to put on the list, or to rename there.
299
+ if (entry.kind === 'agent' && !isAgentCopy(entry.dir, entry.room)) return
300
+ try {
301
+ const list = recentSessions().filter((r) => r.dir !== entry.dir)
302
+ list.unshift({ ...entry, lastUsed: Date.now() })
303
+ fs.mkdirSync(path.dirname(recentFile()), { recursive: true })
304
+ fs.writeFileSync(recentFile(), JSON.stringify(list.slice(0, 12), null, 2))
305
+ } catch {}
306
+ }
@@ -0,0 +1,18 @@
1
+ // A change two folders may make at the same moment, such as the same commits
2
+ // pulled into two folders on one branch, is written under an id taken from the
3
+ // change itself: both folders then make the very same edit, and the shared
4
+ // copy keeps it once instead of twice.
5
+ import crypto from 'node:crypto'
6
+
7
+ /** The Yjs client id for one change: the same path, before and after give the same id. */
8
+ export function sameEditClient (...parts) {
9
+ const id = crypto.createHash('sha1').update(parts.map((p) => p ?? '\u0001').join('\u0000')).digest().readUInt32BE(0)
10
+ return id || 1
11
+ }
12
+
13
+ /** Runs fn with the doc's new items made by `client`, then puts the doc's own id back. */
14
+ export function asClient (doc, client, fn) {
15
+ const own = doc.clientID
16
+ doc.clientID = client
17
+ try { return fn() } finally { doc.clientID = own }
18
+ }