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/bin/quilt.js ADDED
@@ -0,0 +1,816 @@
1
+ #!/usr/bin/env node
2
+ import '../src/quiet-warnings.js'
3
+ import fs from 'node:fs'
4
+ import os from 'node:os'
5
+ import path from 'node:path'
6
+ import { parseArgs } from 'node:util'
7
+ import { adoptLegacyEnv } from '../src/legacy.js'
8
+
9
+ adoptLegacyEnv()
10
+
11
+ const HELP = `quilt: real-time pair vibe coding with any AI tool
12
+
13
+ Usage:
14
+ quilt ui Open the app in your browser (start, join, chat)
15
+ quilt login [--no-browser] Sign in to your heyquilt.com account
16
+ quilt logout Sign this computer out
17
+ quilt whoami Show which account this computer is signed in to
18
+ quilt serve [--port 4321] [--data ./quilt-data] Run a relay server (see docs/hosting.md)
19
+ quilt api [--port 8787] [--memory] Run the accounts API (needs SUPABASE_URL etc.; --memory for local testing)
20
+ quilt agent join <link> --name <name> Join Quilt as an agent with an invite link from the website
21
+ [--agent-id <id>] (an agent that joined before comes back as itself)
22
+ [--here] (keeps its keys in this project's .quilt/: a sandbox that wipes home)
23
+ [--session <invite> --print-env] (keeps nothing: prints the sandbox's QUILT_AGENT_KEY instead)
24
+ quilt agent env --name <name> --session <invite> A new QUILT_AGENT_KEY for a sandbox, for an agent joined on this computer
25
+ quilt agent whoami --name <name> Show who a joined agent is
26
+ quilt join Rejoin this folder's last session, or start a new one
27
+ quilt join <invite-link> Join a partner's session in this folder
28
+ quilt sync [--dir <folder>] Agents in a sandbox: sync this folder with the session once and exit
29
+ (needs QUILT_AGENT_KEY; run it at the start and end of every turn).
30
+ With the key, status, messages, say, tasks, task, history, claim,
31
+ release, focus, commits, commit-request, tool and mcp work too
32
+ quilt setup Connect Claude Code / Cursor / others via MCP
33
+ quilt status Show collaborators, claims, activity, chat
34
+ quilt history [path] [--by name] [--since 2h] [--task id] [--diff] [-n 30]
35
+ Who changed what, when, and for which task
36
+ quilt chat Live chat (messages, DMs, files) in this terminal
37
+ quilt say [@name] <message> Message everyone, or one person with @name
38
+ quilt send <file> [@name] [message] Send a file (not added to the project)
39
+ quilt messages [--all] [--with name] [--grep text] [--before id] [-n 30]
40
+ Show unread messages, or read back: the conversation
41
+ with one person, a search, earlier messages
42
+ quilt get <message-id> [dest] Download a shared file again
43
+ quilt git-token Agents: a short-lived GitHub token for this folder's repository,
44
+ for gh or GitHub's API (GH_TOKEN=$(quilt git-token) gh pr create);
45
+ git itself signs in through Quilt without it
46
+ quilt commit-request <message> [--files a,b] [--task id]
47
+ Ask a person to commit for you (when agents may not push here)
48
+ quilt commits Open commit requests and recent commits
49
+ quilt github Whether agents can push here through GitHub, and the link that connects it
50
+ quilt focus <what you're doing> Tell collaborators what you're working on
51
+ quilt claim <path|glob> [reason] Mark files as yours for now
52
+ quilt release <path|glob|*> Release a claim
53
+ quilt chat-link [name] [--minutes N] Owner: a link for a chat-only AI (ChatGPT, claude.ai, Grok); 10 minutes
54
+ quilt chat-link extend <name> <minutes> Owner: keep a chat link working for that long from now
55
+ quilt github-token [--clear] Owner: a read-only GitHub token (read from stdin) so the relay brings in commits while everyone's offline
56
+ quilt invite Print this session's invite code
57
+ quilt workspace list|files|get|put|write|mkdir|mv|rm The workspace library (quilt workspace for help)
58
+ quilt stop Shut down everything quilt is running (relay, app, syncs)
59
+ quilt doctor [folder] [--watch 30] Check what quilt can see of your Claude Code / Cursor chats
60
+ quilt mcp Run the MCP server (used by AI tools)
61
+ quilt ai-vm Run a person's AI on its own cloud machine (Quilt starts it there)
62
+ quilt hook Claude Code hook (installed by quilt setup; reads the event on stdin)
63
+
64
+ Join options:
65
+ --agent <name> Join as a Quilt agent saved with \`quilt agent join\` (default: your account)
66
+ --tool <tool> What you're coding with, e.g. claude, cursor (shown to others)
67
+ --dir <folder> Project folder (default: current folder)
68
+ --room <name> --secret <secret> Join/create a specific room instead of using an invite
69
+ --prefer local On first join, keep your local version of files that differ
70
+ (default: take the session's version and back yours up)
71
+ `
72
+
73
+ const cmd = process.argv[2]
74
+ const argv = process.argv.slice(3)
75
+
76
+ async function main () {
77
+ if (await withAgentKey()) return
78
+ switch (cmd) {
79
+ case 'serve': return serve()
80
+ case 'api': return apiCmd()
81
+ case 'agent': return agentCmd()
82
+ case 'ui': return ui()
83
+ case 'login': return login()
84
+ case 'logout': return logout()
85
+ case 'whoami': return whoami()
86
+ case 'join': return join()
87
+ case 'sync': return syncCmd()
88
+ case 'setup': return doSetup()
89
+ case 'mcp': return (await import('../src/mcp.js')).runMcp()
90
+ case 'ai-vm': try { await (await import('../src/ai-vm.js')).runAiVm() } catch (err) { fail(err.message) } return
91
+ case 'hook': process.exitCode = await (await import('../src/hooks.js')).runHook(); return
92
+ case 'status': return status()
93
+ case 'history': return history()
94
+ case 'say': return say()
95
+ case 'send': return sendFile()
96
+ case 'messages': case 'inbox': return messages()
97
+ case 'get': return getFile()
98
+ case 'commit-request': return commitRequest()
99
+ case 'commits': return commits()
100
+ case 'github': return githubStatusCmd()
101
+ case 'git-token': return gitToken()
102
+ case 'git-credential': return gitCredential()
103
+ case 'git-pre-push': return gitPrePush()
104
+ case 'chat': return chat()
105
+ case 'focus': return simple('/focus', { text: argv.join(' ') }, () => 'focus updated')
106
+ case 'claim': return simple('/claim', { pattern: argv[0], note: argv.slice(1).join(' ') }, (r) =>
107
+ `claimed ${argv[0]}` + (r.overlapping?.length ? `\nwarning: overlaps ${r.overlapping.map((c) => `${c.by}'s ${c.pattern}`).join(', ')}` : ''))
108
+ case 'release': return simple('/release', { pattern: argv[0] || '*' }, (r) => `released ${r.released} claim(s)`)
109
+ case 'chat-link': {
110
+ if (argv[0] === 'extend') {
111
+ if (!argv[1] || !Number(argv[2])) fail('usage: quilt chat-link extend <name> <minutes>')
112
+ return simple('/chat-link/extend', { who: argv[1], minutes: Number(argv[2]) }, (r) => `${r.name}'s chat link now works until ${new Date(r.expiresAt).toLocaleString()}.`)
113
+ }
114
+ const i = argv.indexOf('--minutes')
115
+ const minutes = i >= 0 ? Number(argv[i + 1]) : undefined
116
+ const name = argv.filter((a, k) => !(i >= 0 && (k === i || k === i + 1))).join(' ')
117
+ return simple('/chat-link', { name, minutes }, (r) => `Chat link for ${r.name}, until ${new Date(r.expiresAt).toLocaleTimeString()}:\n\n ${r.url}\n\nPaste it into ChatGPT, claude.ai or Grok with "Open this link and follow it to join our Quilt session."\nAnyone with it can act as ${r.name}. Extend it with \`quilt chat-link extend ${r.name} <minutes>\`; once it runs out, make a new one.`)
118
+ }
119
+ case 'github-token': {
120
+ // Read from stdin, never the command line (where shell history and ps would keep it).
121
+ if (argv[0] === '--clear') return simple('/github-token', { token: '' }, () => 'The relay no longer has a GitHub token for this session.')
122
+ if (argv.length) fail('usage: quilt github-token [--clear] (the token is read from stdin: pbpaste | quilt github-token)')
123
+ if (process.stdin.isTTY) process.stderr.write('Paste a read-only GitHub token (contents: read) and press Enter: ')
124
+ const token = await new Promise((resolve) => {
125
+ let buf = ''
126
+ process.stdin.setEncoding('utf8')
127
+ process.stdin.on('data', (d) => { buf += d; if (process.stdin.isTTY && buf.includes('\n')) { process.stdin.pause(); resolve(buf) } })
128
+ process.stdin.on('end', () => resolve(buf))
129
+ })
130
+ if (!token.trim()) fail('no token given')
131
+ return simple('/github-token', { token: token.trim() }, () => 'The relay has the token: it brings in commits from your private repository while nobody\'s folder is online.')
132
+ }
133
+ case 'invite': return invite()
134
+ case 'workspace': case 'workspaces': return workspaceCmd()
135
+ case 'stop': return stopAll()
136
+ case 'doctor': {
137
+ const i = argv.indexOf('--watch')
138
+ const secs = i >= 0 ? Number(argv[i + 1]) || 30 : 0
139
+ const dir = argv.find((a, k) => !a.startsWith('--') && !(i >= 0 && k === i + 1))
140
+ return (await import('../src/doctor.js')).doctor({ dir, watchSeconds: secs })
141
+ }
142
+ case '-v': case '--version': case 'version':
143
+ console.log(`quilt ${(await import('../src/releases.js')).currentVersion()}`); return
144
+ case undefined: case '-h': case '--help': case 'help':
145
+ process.stdout.write(HELP); return
146
+ default:
147
+ console.error(`unknown command: ${cmd}\n`); process.stdout.write(HELP); process.exit(1)
148
+ }
149
+ }
150
+
151
+ async function serve () {
152
+ const { values } = parseArgs({ args: argv, options: { port: { type: 'string' }, host: { type: 'string' }, data: { type: 'string' }, key: { type: 'string' } } })
153
+ const { startServer } = await import('../src/server.js')
154
+ const port = Number(values.port || process.env.PORT || 4321)
155
+ const dataDir = path.resolve(values.data || process.env.QUILT_DATA || './quilt-data')
156
+ const srv = await startServer({ port, host: values.host || process.env.QUILT_HOST || '0.0.0.0', dataDir, ...(values.key ? { relayKey: values.key } : {}) })
157
+ // One bad request must never take every session down with it.
158
+ process.on('uncaughtException', (err) => console.error('relay: unexpected error, carrying on:', err))
159
+ process.on('unhandledRejection', (err) => console.error('relay: unexpected rejection, carrying on:', err))
160
+ const c = srv.config
161
+ console.log(`quilt relay listening on :${srv.port} (data: ${dataDir})`)
162
+ console.log(` sign-in: ${c.passPublicKey ? 'a pass from the accounts API is required; new sessions are limited per account' : 'off (set QUILT_PASS_PUBLIC_KEY to require it)'}`)
163
+ console.log(` dashboard: ${c.apiUrl && c.relayApiSecret ? `reports who is in which session to ${c.apiUrl}` : 'off (set QUILT_API_URL and RELAY_API_SECRET to report sessions to the dashboard)'}`)
164
+ if (!c.passPublicKey) console.log(` new sessions: ${c.relayKey ? 'need the relay key' : 'open to anyone who can reach this relay (set QUILT_RELAY_KEY to restrict)'}`)
165
+ console.log(` limits: ${Math.round(c.maxRoomBytes / 1048576)} MB per session, ${Math.round(c.maxRoomFileBytes / 1048576)} MB of shared files, ${c.maxConnsPerIp} connections per address, idle sessions removed after ${c.roomTtlDays} days`)
166
+ console.log(`for development, point Quilt at it with QUILT_SERVER=ws://<this-host>:${srv.port}`)
167
+ const { registerProcess } = await import('../src/procs.js')
168
+ registerProcess('relay', { port: srv.port, dataDir })
169
+ const shutdown = async () => { await srv.close(); process.exit(0) }
170
+ process.on('SIGINT', shutdown)
171
+ process.on('SIGTERM', shutdown)
172
+ }
173
+
174
+ async function apiCmd () {
175
+ const { values } = parseArgs({ args: argv, options: { port: { type: 'string' }, host: { type: 'string' }, memory: { type: 'boolean' } } })
176
+ const { startApi } = await import('../src/api/server.js')
177
+ const { makeFileStore } = await import('../src/api/file-store.js')
178
+ const env = process.env
179
+ let store, verifyUser, mailer
180
+ if (values.memory) {
181
+ const { createMemoryStore } = await import('../src/api/memory-store.js')
182
+ store = createMemoryStore(); store.addUser('local', { name: 'Local user' })
183
+ verifyUser = async (t) => (t === 'local' ? { userId: 'local', email: '' } : null)
184
+ console.log('in-memory mode: use "Authorization: Bearer local" as the signed-in user')
185
+ const { createConsoleMailer } = await import('../src/api/mailer.js')
186
+ mailer = createConsoleMailer(console.log)
187
+ } else {
188
+ for (const k of ['SUPABASE_URL', 'SUPABASE_SERVICE_ROLE_KEY', 'QUILT_SITE_URL', 'SMTP_URL', 'SMTP_FROM', 'PASS_SIGNING_KEY']) if (!env[k]) fail(`${k} is not set`)
189
+ const { createSupabaseStore } = await import('../src/api/supabase-store.js')
190
+ const { createUserVerifier } = await import('../src/api/auth.js')
191
+ store = createSupabaseStore({ url: env.SUPABASE_URL, serviceKey: env.SUPABASE_SERVICE_ROLE_KEY })
192
+ verifyUser = createUserVerifier({ supabaseUrl: env.SUPABASE_URL })
193
+ const { createSmtpMailer } = await import('../src/api/mailer.js')
194
+ mailer = createSmtpMailer({ url: env.SMTP_URL, from: env.SMTP_FROM })
195
+ }
196
+ let passKey = env.PASS_SIGNING_KEY || ''
197
+ if (values.memory && !passKey) {
198
+ const { newPassKeys } = await import('../src/passes.js')
199
+ const keys = newPassKeys()
200
+ passKey = keys.privateKey
201
+ console.log(`pass signing key made for this run; start a local relay with QUILT_PASS_PUBLIC_KEY=${keys.publicKey}`)
202
+ }
203
+ // In memory anyone may use "Bearer local", so only listen on this machine unless asked.
204
+ const host = values.host || (values.memory ? '127.0.0.1' : '0.0.0.0')
205
+ const port = Number(values.port || env.PORT || 8787)
206
+ const api = await startApi({
207
+ port, host, store, verifyUser, mailer, passKey,
208
+ reportKey: env.QUILT_REPORT_KEY || '',
209
+ // The relay signs its presence reports with this (scripts/relay-api-secret.mjs).
210
+ relaySecret: env.RELAY_API_SECRET || '',
211
+ // Quilt's GitHub App (GITHUB_APP_*), for agents' commits; off when not set.
212
+ github: (await import('../src/api/github-app.js')).appConfig(env),
213
+ // Fly's Machines API for the relay (RELAY_FLY_API_TOKEN, QUILT_RELAY_APP): heavy sessions get a relay machine of their own.
214
+ relayFly: (await import('../src/api/relay-machines.js')).relayFlyFromEnv(env),
215
+ // Where agents reach this API (invite links point here).
216
+ apiUrl: env.QUILT_API_PUBLIC_URL || (values.memory ? `http://${host}:${port}` : 'https://api.heyquilt.com'),
217
+ siteUrl: env.QUILT_SITE_URL || 'http://localhost:3000',
218
+ trustProxy: /^(1|true|yes)$/i.test(env.QUILT_TRUST_PROXY || ''), log: console.log,
219
+ workspaces: env.QUILT_WORKSPACES === '1' || env.QUILT_WORKSPACES === 'true',
220
+ // People's cloud AI machines (FLY_API_TOKEN, AI_VM_APP, AI_VM_IMAGE, AI_VM_REGION); off when not set.
221
+ fly: (await import('../src/api/fly-machines.js')).flyFromEnv(env),
222
+ fileStore: makeFileStore(
223
+ { storageUrl: env.QUILT_STORAGE_URL, storageKey: env.QUILT_STORAGE_KEY, storageBucket: env.QUILT_STORAGE_WS_BUCKET },
224
+ values.memory ? fs.mkdtempSync(path.join(os.tmpdir(), 'quilt-api-files-')) : path.resolve(env.QUILT_API_DATA || './quilt-api-data')
225
+ )
226
+ })
227
+ console.log(`quilt accounts API listening on ${api.url}`)
228
+ if (!env.RELAY_API_SECRET) console.log('RELAY_API_SECRET is not set: the relay cannot report sessions for the dashboard')
229
+ if (!env.FLY_API_TOKEN || !env.AI_VM_APP || !env.AI_VM_IMAGE) console.log('FLY_API_TOKEN, AI_VM_APP or AI_VM_IMAGE is not set: people cannot connect a cloud AI')
230
+ const shutdown = async () => { await api.close(); process.exit(0) }
231
+ process.on('SIGINT', shutdown); process.on('SIGTERM', shutdown)
232
+ }
233
+
234
+ async function agentCmd () {
235
+ const usage = 'usage: quilt agent join <link> --name <name> [--agent-id <id>] [--provider <p>] [--type <t>] [--description <d>] [--here]\n' +
236
+ ' quilt agent join <link> --name <name> --session <session invite link> --print-env\n' +
237
+ ' quilt agent env --name <name> --session <session invite link> [--key-name <label>]\n' +
238
+ ' quilt agent whoami --name <name>'
239
+ const [sub, ...rest] = argv
240
+ let parsed = { values: {}, positionals: [] }
241
+ try {
242
+ parsed = parseArgs({ args: rest, allowPositionals: true, options: { name: { type: 'string' }, 'agent-id': { type: 'string' }, provider: { type: 'string' }, type: { type: 'string' }, description: { type: 'string' }, here: { type: 'boolean' }, 'print-env': { type: 'boolean' }, session: { type: 'string' }, 'key-name': { type: 'string' } } })
243
+ } catch { fail(usage) }
244
+ const { values, positionals } = parsed
245
+ if (!values.name || !((sub === 'join' && positionals[0]) || sub === 'whoami' || sub === 'env')) fail(usage)
246
+ const { agentJoin, agentJoinUnsaved, makeEnvKey, savedAgentEnvKey, agentWhoami, describeAgent } = await import('../src/agent-join.js')
247
+ // A sandbox's QUILT_AGENT_KEY goes to stdout alone (pipe it where it belongs); what to do with it, to stderr.
248
+ const printEnv = (envKey) => {
249
+ console.log(`QUILT_AGENT_KEY=${envKey}`)
250
+ console.error(`Put it in your sandbox's environment or secrets settings as QUILT_AGENT_KEY (once). The agent then runs \`npx heyquilt sync\` at the start and end of every turn. It works until it is revoked on heyquilt.com (Agents, ${values.name}'s keys). Keep it secret: it signs in as ${values.name}.`)
251
+ }
252
+ try {
253
+ if (sub === 'join' && values['print-env']) {
254
+ if (!values.session) fail('--print-env needs --session <session invite link>: the key says which session the sandbox works in.')
255
+ if (values.here) fail('--print-env keeps nothing on disk; leave out --here.')
256
+ const joined = await agentJoinUnsaved({ link: positionals[0], name: values.name, agentId: values['agent-id'], provider: values.provider, type: values.type, description: values.description })
257
+ console.error(`${joined.rejoined ? 'Joined Quilt again as' : 'Joined Quilt as'} ${values.name} (agent id ${joined.agentId}). Nothing was saved on this computer.`)
258
+ printEnv(await makeEnvKey({ api: joined.api, accessKey: joined.accessKey, session: values.session, keyName: values['key-name'] }))
259
+ } else if (sub === 'join') await agentJoin({ link: positionals[0], name: values.name, agentId: values['agent-id'], provider: values.provider, type: values.type, description: values.description, here: !!values.here })
260
+ else if (sub === 'env') printEnv(await savedAgentEnvKey({ name: values.name, session: values.session, keyName: values['key-name'] }))
261
+ else console.log(describeAgent(await agentWhoami({ name: values.name })))
262
+ } catch (err) {
263
+ fail(err.message)
264
+ }
265
+ }
266
+
267
+ async function join () {
268
+ const { values, positionals } = parseArgs({
269
+ args: argv,
270
+ allowPositionals: true,
271
+ options: {
272
+ room: { type: 'string' }, secret: { type: 'string' },
273
+ tool: { type: 'string' }, dir: { type: 'string' }, prefer: { type: 'string' }, agent: { type: 'string' }
274
+ }
275
+ })
276
+ const { runSession, decodeInvite, newConn, readConfig, personsFolder, agentCopyFolder } = await import('../src/runner.js')
277
+ const { sessionPasses } = await import('../src/pass-source.js')
278
+ const { clearAccount, readAccount, resumeAccount } = await import('../src/account.js')
279
+ // Every session signs in: as this computer's account, or as a saved agent. A computer
280
+ // linked before signs itself back in when its sign-in was lost.
281
+ if (!values.agent && !readAccount()) await resumeAccount().catch(() => null)
282
+ let auth
283
+ try { auth = sessionPasses({ agent: values.agent || null }) } catch (err) { fail(err.message) }
284
+ const whyStopped = (err) => {
285
+ if (!err.signedOut || values.agent) return err.message
286
+ clearAccount()
287
+ return 'This computer was signed out. Run quilt login again.'
288
+ }
289
+ let dir = path.resolve(values.dir || '.')
290
+ const saved = readConfig(dir) || {}
291
+
292
+ const { unsupportedRelay } = await import('../src/settings.js')
293
+ let conn
294
+ if (positionals[0]) {
295
+ try { conn = decodeInvite(positionals[0]) } catch (err) { fail(err.message) }
296
+ } else if (values.room) {
297
+ conn = newConn()
298
+ conn.room = values.room
299
+ if (values.secret || process.env.QUILT_SECRET) conn.secret = values.secret || process.env.QUILT_SECRET
300
+ } else if (saved.server && !unsupportedRelay(saved.server)) {
301
+ conn = { server: saved.server, room: saved.room, secret: saved.secret, ...(saved.viewSecret ? { viewSecret: saved.viewSecret } : {}) }
302
+ } else {
303
+ if (saved.server) console.log("This folder's last session ran on your computer's own relay, which Quilt no longer supports. Starting a new session.")
304
+ conn = newConn()
305
+ console.log('starting a new session')
306
+ }
307
+
308
+ // An agent never takes over a folder a person synced from this computer (they'd lose it
309
+ // from the app's Recent list and couldn't get back in): it keeps its own copy of the room.
310
+ if (auth.kind === 'agent' && personsFolder(dir, auth.name)) {
311
+ const copy = agentCopyFolder(conn.room, auth.name)
312
+ console.log(`${dir} is a person's own copy of a session on this computer and stays theirs: syncing ${copy} instead.`)
313
+ dir = copy
314
+ }
315
+
316
+ const stamp = () => new Date().toLocaleTimeString()
317
+ console.log(`quilt: syncing ${dir}`)
318
+ let run
319
+ try {
320
+ let tool = values.tool || saved.tool
321
+ if (!tool && auth.kind === 'agent') {
322
+ try {
323
+ const { agentWhoami } = await import('../src/agent-join.js')
324
+ const { toolLabel } = await import('../src/agents/common.js')
325
+ const me = await agentWhoami({ name: auth.name })
326
+ const provider = me?.agent?.provider
327
+ if (provider) {
328
+ const labeled = toolLabel(provider)
329
+ const known = new Set(['Claude Code', 'Cursor', 'Codex', 'xAI', 'Windsurf', 'Zed', 'GitHub Copilot', 'Aider'])
330
+ if (known.has(labeled)) tool = labeled
331
+ else tool = labeled || tool
332
+ }
333
+ } catch {}
334
+ }
335
+ run = await runSession({
336
+ dir,
337
+ conn,
338
+ name: auth.name,
339
+ tool,
340
+ prefer: values.prefer === 'local' ? 'local' : 'remote',
341
+ kind: auth.kind,
342
+ passes: auth.passes,
343
+ identity: auth.identity,
344
+ inviteServer: saved.room === conn.room ? saved.inviteServer : undefined,
345
+ onLog: (m) => console.log(`[${stamp()}] ${m}`),
346
+ onDebug: process.env.QUILT_DEBUG ? (m) => console.log(`[${stamp()}] debug: ${m}`) : undefined,
347
+ onFatal: (err) => fail(whyStopped(err))
348
+ })
349
+ } catch (err) {
350
+ fail(whyStopped(err))
351
+ }
352
+ console.log(` room ${conn.room} on ${conn.server} as "${run.session.name}"`)
353
+
354
+ const { registerProcess } = await import('../src/procs.js')
355
+ registerProcess('sync', { dir })
356
+ console.log(`\nInvite your partner by sending them this link:\n\n ${run.invite}\n\nThey paste it into quilt (Join a session), or run \`quilt join <link>\` in an empty (or matching) folder.\n`)
357
+ console.log('Tip: run `quilt setup` once so your AI tools can see each other. Ctrl+C to stop.\n')
358
+
359
+ const stop = async () => {
360
+ console.log('\nstopping…')
361
+ await run.stop()
362
+ process.exit(0)
363
+ }
364
+ process.on('SIGINT', stop)
365
+ process.on('SIGTERM', stop)
366
+ }
367
+
368
+ /**
369
+ * A sandbox with a QUILT_AGENT_KEY and no quilt join running: the session's commands, and
370
+ * `quilt mcp`, go to the hosted tools as the key's agent (src/key-cli.js, src/mcp-proxy.js).
371
+ * Returns whether the command was run here.
372
+ */
373
+ async function withAgentKey () {
374
+ const { KEY_COMMANDS, KEY_USAGE, runKeyCommand } = await import('../src/key-cli.js')
375
+ if (!KEY_COMMANDS.has(cmd) && cmd !== 'mcp') return false
376
+ const keyOnly = ['tasks', 'task', 'tool'].includes(cmd)
377
+ const { findDaemon } = await import('../src/control.js')
378
+ if (findDaemon()) {
379
+ if (keyOnly) fail(`quilt ${cmd} is for an agent in a sandbox (QUILT_AGENT_KEY). With quilt join running here, your AI has the quilt_* tools: run \`quilt setup\` once.`)
380
+ return false
381
+ }
382
+ const key = await envKey()
383
+ if (!key) {
384
+ if (keyOnly) fail(`quilt ${cmd} needs QUILT_AGENT_KEY, the key for this agent set in your sandbox's environment or secrets settings (heyquilt.com: Agents, then Sandbox key).\n\n${KEY_USAGE}`)
385
+ return false
386
+ }
387
+ const { hostedClient } = await import('../src/hosted-client.js')
388
+ const client = hostedClient({ key })
389
+ if (cmd === 'mcp') {
390
+ await (await import('../src/mcp-proxy.js')).runMcpProxy({ key, client })
391
+ return true
392
+ }
393
+ const stdin = cmd === 'tool' && argv[1] === '-' ? await readStdin() : ''
394
+ process.exitCode = await runKeyCommand(cmd, argv, { client, stdin })
395
+ return true
396
+ }
397
+
398
+ /** The sandbox's QUILT_AGENT_KEY (src/ui/agent-env-key.js), or null; a malformed one stops here. */
399
+ async function envKey () {
400
+ const { agentEnvKeyFromEnv } = await import('../src/ui/agent-env-key.js')
401
+ try { return agentEnvKeyFromEnv(process.env) } catch (err) { fail(err.message) }
402
+ }
403
+
404
+ /** `quilt sync`: one shot, for an agent in a sandbox (src/turn-sync.js). */
405
+ async function syncCmd () {
406
+ let values
407
+ try { ({ values } = parseArgs({ args: argv, options: { dir: { type: 'string' } } })) } catch { fail('usage: quilt sync [--dir <folder>]') }
408
+ const dir = path.resolve(values.dir || '.')
409
+ const key = await envKey()
410
+ if (!key) fail('quilt sync needs QUILT_AGENT_KEY: the key for this agent, set once in your sandbox\'s environment or secrets settings. The person you work for makes it on heyquilt.com (Agents, then Sandbox key on your agent), or runs `quilt agent env --name <agent> --session <invite link>`. On a computer of your own, run `quilt join` instead: it syncs live.')
411
+ const { findDaemon } = await import('../src/control.js')
412
+ const live = findDaemon(dir)
413
+ if (live) fail(`quilt join is already syncing ${live.dir} live, so there is nothing for quilt sync to do here.`)
414
+ const { turnSync, describeSync } = await import('../src/turn-sync.js')
415
+ const { hostedClient } = await import('../src/hosted-client.js')
416
+ const client = hostedClient({ key })
417
+ try {
418
+ console.log(describeSync(await turnSync({ dir, key, client, log: (m) => console.log(m) }), { name: key.name }))
419
+ } catch (err) { fail(err.message) }
420
+ // What waits for the agent: said right away, so a turn starts with it.
421
+ try {
422
+ const inbox = await client.call('quilt_inbox', {})
423
+ if (inbox && !/^Nothing new for you/.test(inbox)) console.log(`\n${inbox}`)
424
+ } catch {}
425
+ }
426
+
427
+ async function ui () {
428
+ const { values } = parseArgs({ args: argv, options: { port: { type: 'string' }, 'no-open': { type: 'boolean' }, preview: { type: 'boolean' } } })
429
+ const { startUi } = await import('../src/ui-server.js')
430
+ const { registerProcess, stopProcesses } = await import('../src/procs.js')
431
+ ;(await import('../src/integrations.js')).registerOnStart((line) => console.log(line))
432
+ const app = await startUi({
433
+ port: Number(values.port || 7420),
434
+ preview: !!values.preview,
435
+ // The app's "Shut down" button: stop every other quilt process, then this one.
436
+ onShutdown: async () => {
437
+ console.log('\nshutting down everything…')
438
+ await stopProcesses()
439
+ await app.close()
440
+ process.exit(0)
441
+ }
442
+ })
443
+ registerProcess('app', { port: app.port, url: app.url })
444
+ console.log(`quilt is running at:\n\n ${app.url}\n`)
445
+ console.log('Keep this terminal open while you work. Ctrl+C to stop, or `quilt stop` to shut everything down.')
446
+ if (!values['no-open']) openBrowser(app.url)
447
+ const stop = async () => { console.log('\nstopping…'); await app.close(); process.exit(0) }
448
+ process.on('SIGINT', stop)
449
+ process.on('SIGTERM', stop)
450
+ }
451
+
452
+ async function login () {
453
+ const { values } = parseArgs({ args: argv, options: { 'no-browser': { type: 'boolean' } } })
454
+ const { readAccount, saveAccount, startLink, waitForLink, accountFromProfile, resumeAccount } = await import('../src/account.js')
455
+ const { loadIdentity } = await import('../src/identity.js')
456
+ const current = readAccount()
457
+ if (current) return console.log(`Already signed in as ${current.account.name} (${current.account.email}). Run quilt logout first to switch accounts.`)
458
+ const identity = loadIdentity()
459
+ // Linked before: signs straight back in, no browser.
460
+ const back = await resumeAccount({ identity, asked: true }).catch(() => null)
461
+ if (back) return console.log(`Signed in as ${back.account.name} (${back.account.email}).`)
462
+ let link
463
+ try { link = await startLink({ identity }) } catch (err) { fail(err.message) }
464
+ console.log(`To sign in, open this page and approve this computer:\n\n ${link.verificationUrl}\n\nCheck it shows the code ${link.userCode}. Waiting…`)
465
+ if (!values['no-browser']) openBrowser(link.verificationUrl)
466
+ let r
467
+ try { r = await waitForLink({ identity, link }) } catch (err) { fail(err.expired ? 'The code expired. Run quilt login again.' : err.message) }
468
+ const account = accountFromProfile(r.profile)
469
+ saveAccount({ token: r.token, account, signedInAt: Date.now() })
470
+ console.log(`Signed in as ${account.name} (${account.email}).`)
471
+ ;(await import('../src/integrations.js')).registerOnStart((line) => console.log(line))
472
+ }
473
+
474
+ async function logout () {
475
+ const { readAccount, signOut } = await import('../src/account.js')
476
+ const current = readAccount()
477
+ if (!current) return console.log('Not signed in')
478
+ await signOut({ token: current.token })
479
+ console.log(`Signed out of ${current.account.email}.`)
480
+ }
481
+
482
+ async function whoami () {
483
+ const { readAccount } = await import('../src/account.js')
484
+ const current = readAccount()
485
+ if (!current) {
486
+ console.log('Not signed in')
487
+ process.exitCode = 1
488
+ return
489
+ }
490
+ console.log(`${current.account.name} (${current.account.email})`)
491
+ }
492
+
493
+ async function openBrowser (url) {
494
+ const { spawn } = await import('node:child_process')
495
+ const [cmd, args] = process.platform === 'darwin' ? ['open', [url]]
496
+ : process.platform === 'win32' ? ['cmd', ['/c', 'start', '', url]]
497
+ : ['xdg-open', [url]]
498
+ try { spawn(cmd, args, { stdio: 'ignore', detached: true }).on('error', () => {}).unref() } catch {}
499
+ }
500
+
501
+ async function doSetup () {
502
+ const { setup } = await import('../src/setup.js')
503
+ const changed = setup(process.cwd())
504
+ if (!changed.length) return console.log('already set up')
505
+ console.log('updated:\n' + changed.map((c) => ` - ${c}`).join('\n'))
506
+ console.log('\nAn AI tool that was already open picks up the "quilt" MCP server when it restarts.')
507
+ }
508
+
509
+ async function daemonOrFail () {
510
+ const { findDaemon, call } = await import('../src/control.js')
511
+ const d = findDaemon()
512
+ if (!d) fail('quilt is not running here. Start it with `quilt join` in the project folder.')
513
+ return { d, call }
514
+ }
515
+
516
+ async function status () {
517
+ const { d, call } = await daemonOrFail()
518
+ console.log((await call(d, 'GET', '/status')).markdown)
519
+ }
520
+
521
+ async function history () {
522
+ const { values, positionals } = parseArgs({ args: argv, allowPositionals: true, options: { by: { type: 'string' }, since: { type: 'string' }, task: { type: 'string' }, diff: { type: 'boolean' }, n: { type: 'string', short: 'n' } } })
523
+ const { formatHistory } = await import('../src/history.js')
524
+ await simple('/history', { path: positionals[0], by: values.by, since: values.since, task: values.task, limit: Number(values.n || 30) }, (r) => formatHistory(r.entries, { withDiff: !!values.diff }))
525
+ }
526
+
527
+ async function simple (route, body, format) {
528
+ const { d, call } = await daemonOrFail()
529
+ try { console.log(format(await call(d, 'POST', route, body))) } catch (err) { fail(err.message) }
530
+ }
531
+
532
+ /** Splits "@bob rest of message" into { to: 'bob', rest: [...] }. */
533
+ function splitRecipient (args) {
534
+ const i = args.indexOf('--to')
535
+ if (i !== -1) return { to: args[i + 1], rest: [...args.slice(0, i), ...args.slice(i + 2)] }
536
+ if (args[0] && args[0].startsWith('@') && args[0].length > 1) return { to: args[0].slice(1), rest: args.slice(1) }
537
+ return { to: undefined, rest: args }
538
+ }
539
+
540
+ async function say () {
541
+ const flag = (f) => { const i = argv.indexOf(f); if (i === -1) return false; argv.splice(i, 1); return true }
542
+ const everyone = flag('--everyone')
543
+ const also = flag('--also')
544
+ const { to, rest } = splitRecipient(argv)
545
+ if (!rest.length) fail('usage: quilt say [@name] <message> (a message to everyone names who it is for with @Name, or add --everyone)')
546
+ await simple('/say', { text: rest.join(' '), to, everyone, also }, (r) =>
547
+ to ? `sent to ${to}${r.recipientOnline ? '' : ' (offline, they will see it when they reconnect)'}` : 'sent')
548
+ }
549
+
550
+ async function workspaceCmd () {
551
+ const { WORKSPACE_USAGE, runWorkspaceCommand } = await import('../src/workspace-cli.js')
552
+ const [sub, ...rest] = argv
553
+ if (!sub || sub === 'help' || sub === '--help' || sub === '-h') return console.log(WORKSPACE_USAGE)
554
+ const { localWorkspaceAccess } = await import('../src/mcp.js')
555
+ const { findDaemon } = await import('../src/control.js')
556
+ const here = process.env.QUILT_DIR || process.cwd()
557
+ // Whoever this folder's session is (an agent's copy names its agent), from anywhere inside it.
558
+ const projectDirs = () => [here, findDaemon(here)?.dir].filter((d) => d && d !== os.homedir())
559
+ let r
560
+ try {
561
+ r = await runWorkspaceCommand(sub, rest, localWorkspaceAccess({ projectDirs }))
562
+ } catch (err) { fail(err.message) }
563
+ if (!r.ok) fail(r.text.replace(/^Error: /, ''))
564
+ console.log(r.text)
565
+ }
566
+
567
+ async function sendFile () {
568
+ const [file, ...more] = argv
569
+ if (!file) fail('usage: quilt send <file> [@name] [message]')
570
+ if (!fs.existsSync(file)) fail(`no such file: ${file}`)
571
+ const { to, rest } = splitRecipient(more)
572
+ await simple('/send', { path: path.resolve(file), to, text: rest.join(' ') }, (r) =>
573
+ `sent ${r.file.name}${to ? ` to ${to}` : ''}`)
574
+ }
575
+
576
+ async function messages () {
577
+ const { values } = parseArgs({ args: argv, options: { all: { type: 'boolean' }, with: { type: 'string' }, grep: { type: 'string' }, before: { type: 'string' }, n: { type: 'string', short: 'n' } } })
578
+ const { d, call } = await daemonOrFail()
579
+ if (values.with || values.grep || values.before) {
580
+ // Reading back (conversation.js): the kept chat, past the room's newest 500, with message ids.
581
+ const { renderConversation } = await import('../src/conversation.js')
582
+ const r = await call(d, 'POST', '/conversation', { with: values.with, q: values.grep, before: values.before, limit: Number(values.n || 30) })
583
+ return console.log(renderConversation(r, { me: r.me, with: values.with, q: values.grep }).replace(/quilt_conversation|call again with before: "([^"]+)"/g, (m, id) => id ? `run again with --before ${id}` : m))
584
+ }
585
+ const { renderMessage } = await import('../src/status.js')
586
+ const all = values.all
587
+ const { messages } = await call(d, 'POST', '/messages', { unreadOnly: !all, with: values.with, limit: Number(values.n || 50) })
588
+ const me = (await call(d, 'GET', '/status')).me.name
589
+ if (!messages.length) return console.log(all ? 'no messages yet' : 'no unread messages (use --all to see history)')
590
+ for (const m of messages) console.log(plain(renderMessage(m, me)))
591
+ }
592
+
593
+ async function commitRequest () {
594
+ const { values, positionals } = parseArgs({ args: argv, allowPositionals: true, options: { files: { type: 'string' }, task: { type: 'string' } } })
595
+ const files = values.files ? values.files.split(',').map((f) => f.trim()).filter(Boolean) : undefined
596
+ await simple('/commit-request', { message: positionals.join(' '), files, task: values.task }, (r) =>
597
+ `asked for a commit [${r.id}] of ${r.files.length} file${r.files.length === 1 ? '' : 's'}: ${r.files.slice(0, 12).join(', ')}${r.files.length > 12 ? ', …' : ''}\n${r.committer}`)
598
+ }
599
+
600
+ // git in an agent's folder runs these (installGitHelpers, gitstate.js); `--dir` is the folder, whose
601
+ // running session answers. They never print anything but what git expects, and never fail a git
602
+ // command when Quilt isn't running: git then behaves as it would without Quilt.
603
+ function helperDir () {
604
+ const i = argv.indexOf('--dir')
605
+ const dir = i >= 0 ? argv[i + 1] : process.cwd()
606
+ if (i >= 0) argv.splice(i, 2)
607
+ return dir
608
+ }
609
+ async function helperDaemon (dir) {
610
+ const { findDaemon, call } = await import('../src/control.js')
611
+ const d = findDaemon(dir)
612
+ return d ? { d, call } : null
613
+ }
614
+ const readStdin = () => new Promise((resolve) => { let s = ''; process.stdin.setEncoding('utf8'); process.stdin.on('data', (c) => { s += c }); process.stdin.on('end', () => resolve(s)); process.stdin.on('error', () => resolve(s)) })
615
+
616
+ /**
617
+ * git's credential helper protocol: `get` answers with the session's token for the host; store and erase
618
+ * are nothing to do. `--fallback` (a person's folder, git for everyone): when no Quilt session answers
619
+ * (the app is closed) or it has no token, the person's own helpers are asked instead, as git would have
620
+ * without Quilt: `git credential fill` from outside the folder, so only their global and system config count.
621
+ */
622
+ async function gitCredential () {
623
+ const dir = helperDir()
624
+ const fallback = argv.includes('--fallback')
625
+ if (fallback) argv.splice(argv.indexOf('--fallback'), 1)
626
+ if (argv[0] !== 'get') return
627
+ const input = await readStdin()
628
+ const fields = Object.fromEntries(input.split('\n').filter((l) => l.includes('=')).map((l) => { const i = l.indexOf('='); return [l.slice(0, i), l.slice(i + 1)] }))
629
+ try {
630
+ const h = await helperDaemon(dir)
631
+ if (h) {
632
+ const r = await h.call(h.d, 'POST', '/git/credential', { host: fields.host || '', protocol: fields.protocol || '' })
633
+ if (r && r.username && r.password) return process.stdout.write(`username=${r.username}\npassword=${r.password}\n`)
634
+ }
635
+ } catch {}
636
+ if (fallback) await ownCredential(input)
637
+ }
638
+
639
+ /** The person's own credentials for what git asked (`input`, git's credential fields), or nothing: never a prompt. */
640
+ async function ownCredential (input) {
641
+ const os = await import('node:os')
642
+ const { execFile } = await import('node:child_process')
643
+ const away = os.tmpdir()
644
+ const out = await new Promise((resolve) => {
645
+ const c = execFile('git', ['credential', 'fill'], { cwd: away, encoding: 'utf8', timeout: 60000, env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_ASKPASS: '', SSH_ASKPASS: '', GIT_CEILING_DIRECTORIES: path.dirname(away) } }, (err, stdout) => resolve(err ? '' : stdout))
646
+ c.stdin.end(input.endsWith('\n\n') ? input : `${input.replace(/\n*$/, '')}\n\n`)
647
+ })
648
+ const lines = out.split('\n').filter((l) => /^(username|password)=/.test(l))
649
+ if (lines.length === 2) process.stdout.write(`${lines.join('\n')}\n`)
650
+ }
651
+
652
+ /** The pre-push hook: refs on stdin (local ref, local sha, remote ref, remote sha), the remote's name and URL as arguments. Exits 1 with the reason when the push may not go. */
653
+ async function gitPrePush () {
654
+ const dir = helperDir()
655
+ const [remote = 'origin'] = argv
656
+ const input = await readStdin()
657
+ const refs = input.split('\n').filter(Boolean).map((l) => { const [localRef, localSha, remoteRef, remoteSha] = l.trim().split(/\s+/); return { localRef, localSha, remoteRef, remoteSha } })
658
+ try {
659
+ const h = await helperDaemon(dir)
660
+ if (!h) return
661
+ const r = await h.call(h.d, 'POST', '/git/push-check', { remote, refs })
662
+ if (r && r.ok === false) { process.stderr.write(`Quilt: ${r.why || 'this push is not allowed here'}\n`); process.exitCode = 1 }
663
+ } catch {}
664
+ }
665
+
666
+ /** A short-lived token for this folder's repository (agents), for gh or GitHub's API. */
667
+ async function gitToken () {
668
+ const { d, call } = await daemonOrFail()
669
+ const r = await call(d, 'POST', '/git/credential', { host: 'github.com' }).catch((err) => fail(err.message))
670
+ if (!r || !r.password) fail('No GitHub token for this folder: it needs to be an agent\'s folder in a session that follows a GitHub repository, with GitHub connected (quilt github says).')
671
+ console.log(r.password)
672
+ }
673
+
674
+ async function githubStatusCmd () {
675
+ const { d, call } = await daemonOrFail()
676
+ const st = await call(d, 'GET', '/status')
677
+ const mine = (st.branches || []).find((b) => st.git && b.name === st.git.key) || (st.branches || []).find((b) => b.default)
678
+ const g = mine && mine.relay && mine.relay.github
679
+ const repo = mine && mine.relay && mine.relay.repo
680
+ if (!repo) return console.log('This session does not follow a GitHub repository (no member\'s folder is a clone of one), so there is nothing to connect.')
681
+ const words = { ok: `Git is on for everyone here, through @${g && g.login}'s connection: people and agents push with git, nothing to set up`, 'not-connected': 'not connected: nobody in the session has connected GitHub yet', 'not-installed': 'Quilt is not installed on this repository on GitHub yet', 'no-access': `no connected GitHub account (@${g && g.login}) may write to it`, 'not-github': 'not on GitHub' }
682
+ console.log(`${repo}: ${g ? words[g.state] || g.state : 'not checked yet'}`)
683
+ if (g && g.state === 'ok') return
684
+ const { readAccount } = await import('../src/account.js')
685
+ const token = readAccount()?.token
686
+ if (!token) return console.log('Sign in (quilt login), then run this again for the link that connects GitHub.')
687
+ const { githubConnectUrl } = await import('../src/account.js')
688
+ try { console.log(`Connect it (one click, once, by someone whose GitHub account can write to the repository):\n ${await githubConnectUrl({ token, repo })}`) } catch (err) { console.log(`Could not get the link: ${err.message}`) }
689
+ }
690
+
691
+ async function commits () {
692
+ const { d, call } = await daemonOrFail()
693
+ const { describeCommitRequests } = await import('../src/commit.js')
694
+ const c = await call(d, 'GET', '/commits')
695
+ console.log(plain(describeCommitRequests(c, { canCommit: !!c.canCommit })))
696
+ }
697
+
698
+ async function getFile () {
699
+ if (!argv[0]) fail('usage: quilt get <message-id> [dest]')
700
+ await simple('/get', { id: argv[0], dest: argv[1] ? path.resolve(argv[1]) : undefined }, (r) => `saved to ${r.path}`)
701
+ }
702
+
703
+ const plain = (md) => md.replace(/\*\*/g, '').replace(/`/g, '')
704
+
705
+ async function chat () {
706
+ const { d, call } = await daemonOrFail()
707
+ const { renderMessage } = await import('../src/status.js')
708
+ const readline = await import('node:readline')
709
+ const st = await call(d, 'GET', '/status')
710
+ const me = st.me.name
711
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout, prompt: '> ' })
712
+ const print = (line) => {
713
+ readline.clearLine(process.stdout, 0)
714
+ readline.cursorTo(process.stdout, 0)
715
+ console.log(line)
716
+ rl.prompt(true)
717
+ }
718
+
719
+ console.log(`chatting in room ${st.room} as ${me}. Online: ${st.peers.map((p) => p.name).join(', ') || 'nobody else yet'}`)
720
+ console.log('type a message, "@name msg" for a direct message, "/send <file> [@name] [msg]", "/get <id> [dest]", "/who", "/quit"\n')
721
+ const { messages: backlog } = await call(d, 'POST', '/messages', { limit: 20 })
722
+ for (const m of backlog) console.log(plain(renderMessage(m, me)))
723
+
724
+ // Live messages over server-sent events.
725
+ const ctrl = new AbortController()
726
+ ;(async () => {
727
+ try {
728
+ const res = await fetch(`http://127.0.0.1:${d.port}/events`, { headers: { authorization: `Bearer ${d.token}` }, signal: ctrl.signal })
729
+ const decoder = new TextDecoder()
730
+ let buf = ''
731
+ for await (const chunk of res.body) {
732
+ buf += decoder.decode(chunk, { stream: true })
733
+ let idx
734
+ while ((idx = buf.indexOf('\n\n')) !== -1) {
735
+ const block = buf.slice(0, idx)
736
+ buf = buf.slice(idx + 2)
737
+ const event = (block.match(/^event: (.*)$/m) || [])[1]
738
+ const data = (block.match(/^data: (.*)$/m) || [])[1]
739
+ if (!event || !data) continue
740
+ const payload = JSON.parse(data)
741
+ if (event === 'message' && payload.by !== me) print(plain(renderMessage({ ...payload, unread: false }, me)))
742
+ else if (event === 'log' && !payload.startsWith('💬')) print(` · ${payload}`)
743
+ }
744
+ }
745
+ if (!ctrl.signal.aborted) { print('lost connection to quilt'); process.exit(1) }
746
+ } catch (err) {
747
+ if (!ctrl.signal.aborted) { print(`lost connection to quilt: ${err.message}`); process.exit(1) }
748
+ }
749
+ })()
750
+
751
+ rl.prompt()
752
+ rl.on('line', async (line) => {
753
+ line = line.trim()
754
+ try {
755
+ if (!line) {
756
+ // nothing
757
+ } else if (line === '/quit' || line === '/exit') {
758
+ return rl.close()
759
+ } else if (line === '/who') {
760
+ const s = await call(d, 'GET', '/status')
761
+ print(s.peers.length ? s.peers.map((p) => ` ${p.name} (${p.tool})${p.focus ? `: ${p.focus}` : ''}`).join('\n') : ' nobody else is online')
762
+ } else if (line.startsWith('/send ')) {
763
+ const [file, ...more] = line.slice(6).trim().split(/\s+/)
764
+ const { to, rest } = splitRecipient(more)
765
+ const r = await call(d, 'POST', '/send', { path: path.resolve(file), to, text: rest.join(' ') })
766
+ print(` sent ${r.file.name}${to ? ` to ${to}` : ''}`)
767
+ } else if (line.startsWith('/get ')) {
768
+ const [id, dest] = line.slice(5).trim().split(/\s+/)
769
+ const r = await call(d, 'POST', '/get', { id, dest: dest ? path.resolve(dest) : undefined })
770
+ print(` saved to ${r.path}`)
771
+ } else if (line.startsWith('/')) {
772
+ print(' unknown command')
773
+ } else {
774
+ const { to, rest } = splitRecipient(line.split(' '))
775
+ // A person typing here, not an AI: what they send needs no @Name.
776
+ const r = await call(d, 'POST', '/say', { text: rest.join(' '), to, everyone: true })
777
+ if (to && !r.recipientOnline) print(` (${to} is offline; they'll see it when they reconnect)`)
778
+ }
779
+ } catch (err) {
780
+ print(` error: ${err.message}`)
781
+ }
782
+ rl.prompt()
783
+ })
784
+ rl.on('close', () => { ctrl.abort(); process.exit(0) })
785
+ }
786
+
787
+ async function invite () {
788
+ const { encodeInvite } = await import('../src/runner.js')
789
+ const { unsupportedRelay } = await import('../src/settings.js')
790
+ let dir = process.cwd()
791
+ while (true) {
792
+ const f = path.join(dir, '.quilt', 'config.json')
793
+ if (fs.existsSync(f)) {
794
+ const c = JSON.parse(fs.readFileSync(f, 'utf8'))
795
+ // Never hand out an invite to a relay Quilt won't connect to (or let anyone else connect to).
796
+ if (unsupportedRelay(c.server)) fail("This folder's last session ran on your computer's own relay, which Quilt no longer supports. Run quilt join here to start a new session.")
797
+ return console.log(encodeInvite({ ...c, server: c.inviteServer && !unsupportedRelay(c.inviteServer) ? c.inviteServer : c.server }))
798
+ }
799
+ if (path.dirname(dir) === dir) fail('no session configured in this folder')
800
+ dir = path.dirname(dir)
801
+ }
802
+ }
803
+
804
+ async function stopAll () {
805
+ const { stopProcesses, describeProcess } = await import('../src/procs.js')
806
+ const stopped = await stopProcesses()
807
+ if (!stopped.length) return console.log('nothing to stop: quilt is not running')
808
+ console.log('stopped:\n' + stopped.map((p) => ` - ${describeProcess(p)}`).join('\n'))
809
+ }
810
+
811
+ function fail (msg) {
812
+ console.error(msg)
813
+ process.exit(1)
814
+ }
815
+
816
+ main().catch((err) => fail(err.stack || err.message))