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/ai-vm.js ADDED
@@ -0,0 +1,380 @@
1
+ // `quilt ai-vm`: the one process on a person's cloud AI machine (docs/superpowers/specs/
2
+ // 2026-10-17-ai-vm.md). It signs in to Quilt as the person's agent with the one-time link the
3
+ // machine was started with (its keys are made and kept here only), takes its settings and what
4
+ // waits for it from the accounts API (routes/ai-vm.js), signs the person's AI tool in with the
5
+ // tool's own login (ai-vm-signin.js), joins the sessions the person joins, and runs "Work on its
6
+ // own" (autowork.js) there as "<person>'s AI". With nothing to do for a while it asks to be
7
+ // suspended; anything for it resumes the machine where it was.
8
+ import fs from 'node:fs'
9
+ import path from 'node:path'
10
+ import http from 'node:http'
11
+ import { spawn as spawnChild, execFile } from 'node:child_process'
12
+ import { quiltHome } from './legacy.js'
13
+ import { saveSettings, relayUrl } from './settings.js'
14
+ import { parseInvite } from './ui/invite.js'
15
+ import { SIGN_IN, readSignIn, keepToken, keptToken, statusSaysSignedIn } from './ai-vm-signin.js'
16
+ import { tokensOn } from './autowork.js'
17
+ import { writePrivateJson } from './private-file.js'
18
+
19
+ export const AGENT_NAME = 'cloud-ai'
20
+ export const IDLE_MS = 10 * 60 * 1000
21
+ const REPORT_MS = 60 * 1000
22
+ const RETRY_MS = 5 * 1000
23
+ const SIGN_IN_MS = 15 * 60 * 1000 // a sign-in nobody finishes is stopped
24
+ const ROOM = /^[A-Za-z0-9_-]{1,64}$/
25
+
26
+ /**
27
+ * Runs a tool's login in a terminal (the tools draw their sign-in for one), so on Linux under
28
+ * `script`; elsewhere (tests, a computer) with plain pipes. Returns { onData, write, kill, exited }.
29
+ */
30
+ export function spawnLogin (exe, args, { env = {}, useScript = process.platform === 'linux' } = {}) {
31
+ const quote = (s) => `'${String(s).replace(/'/g, "'\\''")}'`
32
+ // A wide terminal, so a long link is printed on one line.
33
+ const [cmd, argv] = useScript
34
+ ? ['script', ['-q', '-f', '-e', '-c', `stty cols 2000 rows 50 2>/dev/null; exec ${[exe, ...args].map(quote).join(' ')}`, '/dev/null']]
35
+ : [exe, args]
36
+ const child = spawnChild(cmd, argv, { env: { ...process.env, ...env, TERM: 'xterm-256color', BROWSER: 'echo' }, stdio: ['pipe', 'pipe', 'pipe'] })
37
+ const listeners = []
38
+ const onChunk = (d) => { for (const l of listeners) l(String(d)) }
39
+ child.stdout.on('data', onChunk)
40
+ child.stderr.on('data', onChunk)
41
+ const exited = new Promise((resolve) => { child.on('error', () => resolve(-1)); child.on('close', (code) => resolve(code ?? -1)) })
42
+ return {
43
+ onData: (fn) => listeners.push(fn),
44
+ write: (text) => { try { child.stdin.write(text) } catch {} },
45
+ kill: () => { try { child.kill() } catch {} },
46
+ exited
47
+ }
48
+ }
49
+
50
+ /** A command's exit code and output (for the tools' status commands); -1 when it can't run. */
51
+ function runQuiet (exe, args) {
52
+ return new Promise((resolve) => {
53
+ execFile(exe, args, { timeout: 30_000, env: process.env }, (err, stdout, stderr) => resolve({ code: err ? (typeof err.code === 'number' ? err.code : -1) : 0, output: `${stdout || ''}${stderr || ''}` }))
54
+ })
55
+ }
56
+
57
+ export class AiVm {
58
+ /**
59
+ * `api`: the accounts API. `home`: the machine's home folder (on its volume). Everything that
60
+ * reaches outside is injectable for tests: `fetch`, `login` (spawnLogin), `status` (runQuiet),
61
+ * `join` (runSession), `agentJoin`/`agentAccess` (agent-join.js), `autowork`, `which`.
62
+ */
63
+ constructor ({ api, joinLink = '', home = process.env.HOME, fetch: fetchImpl = globalThis.fetch, login = spawnLogin, status = runQuiet, join = null, agent = null, autowork = null, which = null, now = Date.now, log = console.log, idleMs = IDLE_MS, reportMs = REPORT_MS, port = 8080, waitParam = '1' }) {
64
+ this.api = String(api || '').replace(/\/+$/, '')
65
+ this.joinLink = joinLink
66
+ this.home = home
67
+ this.fetch = fetchImpl
68
+ this.login = login
69
+ this.status = status
70
+ this.joinImpl = join
71
+ this.agentImpl = agent
72
+ this.autoworkImpl = autowork
73
+ this.which = which || ((names) => names[0])
74
+ this.now = now
75
+ this.log = log
76
+ this.idleMs = idleMs
77
+ this.reportMs = reportMs
78
+ this.port = port
79
+ this.waitParam = waitParam
80
+ this.config = null
81
+ this.signedIn = false
82
+ this.signIn = null // the step the person sees
83
+ this.signing = null // the login running now
84
+ this.runs = new Map() // room -> { run, link }
85
+ this.lastActivity = now()
86
+ this.stopped = false
87
+ this.timers = []
88
+ this.dir = path.join(quiltHome(), 'ai-vm')
89
+ }
90
+
91
+ // ------------------------------------------------------------ the API --
92
+
93
+ async agent () {
94
+ if (!this.agentImpl) {
95
+ const m = await import('./agent-join.js')
96
+ this.agentImpl = { join: (o) => m.agentJoin({ ...o, fetch: this.fetch }), access: (o) => m.agentAccess({ ...o, fetch: this.fetch }), readAgent: m.readAgent }
97
+ }
98
+ return this.agentImpl
99
+ }
100
+
101
+ /** Joins Quilt as the person's agent with the machine's one-time link, unless it already has. */
102
+ async ensureAgent () {
103
+ const a = await this.agent()
104
+ try { a.readAgent({ name: AGENT_NAME }); return } catch {}
105
+ if (!this.joinLink) throw new Error('This machine has no Quilt agent and no link to join with (QUILT_AI_VM_JOIN).')
106
+ await a.join({ link: this.joinLink, name: AGENT_NAME, provider: 'Quilt cloud AI', type: 'cloud AI', description: "A person's AI on its own cloud machine", log: (l) => this.log(l) })
107
+ }
108
+
109
+ async call (method, route, body) {
110
+ const a = await this.agent()
111
+ const saved = await a.access({ name: AGENT_NAME })
112
+ const res = await this.fetch(`${this.api}${route}`, {
113
+ method,
114
+ headers: { authorization: `Bearer ${saved.accessKey}`, ...(body ? { 'content-type': 'application/json' } : {}) },
115
+ body: body ? JSON.stringify(body) : undefined
116
+ })
117
+ const data = await res.json().catch(() => null)
118
+ if (!res.ok) throw Object.assign(new Error((data && data.error) || `the API answered ${res.status}`), { status: res.status })
119
+ return data
120
+ }
121
+
122
+ // ------------------------------------------------------------- startup --
123
+
124
+ async start () {
125
+ fs.mkdirSync(this.dir, { recursive: true })
126
+ await this.ensureAgent()
127
+ // A kept Claude token signs Claude's runs in (it never leaves this machine).
128
+ const token = keptToken(this.home, 'Claude Code')
129
+ if (token) process.env.CLAUDE_CODE_OAUTH_TOKEN = token
130
+ if (this.port !== null) await this.serveHealth()
131
+ await this.takeNext(false)
132
+ for (const [room, link] of Object.entries(this.followed())) await this.follow(link).catch((err) => this.log(`could not rejoin ${room}: ${err.message}`))
133
+ await this.report().catch(() => {})
134
+ this.loop()
135
+ this.timers.push(setInterval(() => { this.report().catch(() => {}) }, this.reportMs))
136
+ this.timers.push(setInterval(() => { this.idleCheck().catch(() => {}) }, Math.min(30_000, Math.max(50, this.idleMs / 4))))
137
+ for (const t of this.timers) t.unref?.()
138
+ }
139
+
140
+ async stop () {
141
+ this.stopped = true
142
+ for (const t of this.timers) clearInterval(t)
143
+ if (this.signing) this.signing.kill()
144
+ for (const { run } of this.runs.values()) await run.stop().catch(() => {})
145
+ this.runs.clear()
146
+ if (this.health) await new Promise((resolve) => this.health.close(resolve))
147
+ }
148
+
149
+ /** Asks the API for what waits, over and over; a failure waits a moment and asks again. */
150
+ async loop () {
151
+ while (!this.stopped) {
152
+ try { await this.takeNext(true) } catch (err) {
153
+ if (this.stopped) return
154
+ this.log(`could not reach Quilt: ${err.message}`)
155
+ await new Promise((resolve) => setTimeout(resolve, RETRY_MS))
156
+ }
157
+ }
158
+ }
159
+
160
+ /** One look at what waits: settings, a sign-in to start, a code, sessions to join or leave. */
161
+ async takeNext (wait) {
162
+ const n = await this.call('GET', `/v1/ai-vm/next${wait ? `?wait=${this.waitParam}` : ''}`)
163
+ if (this.stopped) return n
164
+ const changedTool = !this.config || this.config.tool !== n.config.tool
165
+ this.applyConfig(n.config)
166
+ const busy = n.signIn || n.code || n.follow.length || n.leave.length
167
+ if (busy) this.lastActivity = this.now()
168
+ if (changedTool) {
169
+ this.signedIn = await this.checkSignedIn()
170
+ this.signIn = null
171
+ // A machine that isn't signed in starts the sign-in by itself, so the app has a link to show.
172
+ if (!this.signedIn && !n.signIn) await this.startSignIn()
173
+ }
174
+ if (n.signIn) await this.startSignIn()
175
+ if (n.code) this.giveCode(n.code)
176
+ for (const link of n.follow) await this.follow(link).catch((err) => this.log(`could not join: ${err.message}`))
177
+ for (const room of n.leave) await this.leave(room).catch((err) => this.log(`could not leave ${room}: ${err.message}`))
178
+ if (busy || changedTool) await this.report().catch(() => {})
179
+ return n
180
+ }
181
+
182
+ /** The person's settings, as this machine's own "Work on its own" settings. */
183
+ applyConfig (c) {
184
+ this.config = c
185
+ saveSettings({ tool: c.tool, autoWork: 'auto', autoFiles: true, autoModel: c.model || undefined, autoCap: c.cap || undefined, autoPrompt: c.prompt || undefined })
186
+ }
187
+
188
+ // ------------------------------------------------------------- sign-in --
189
+
190
+ async checkSignedIn () {
191
+ const tool = this.config && this.config.tool
192
+ const t = SIGN_IN[tool]
193
+ if (!t) return false
194
+ if (t.status.file) return !!keptToken(this.home, tool)
195
+ const exe = this.which(t.exe)
196
+ if (!exe) return false
197
+ const r = await this.status(exe, t.status.args)
198
+ return statusSaysSignedIn(tool, r.code, r.output)
199
+ }
200
+
201
+ /** Runs the tool's own login; what the person must see goes to the app through the API. */
202
+ async startSignIn () {
203
+ const tool = this.config && this.config.tool
204
+ const t = SIGN_IN[tool]
205
+ if (!t) return
206
+ if (this.signing) this.signing.kill()
207
+ const exe = this.which(t.exe)
208
+ if (!exe) { this.setStep({ step: 'failed', error: `${tool} isn't installed on this machine` }); return }
209
+ let out = ''
210
+ let shown = ''
211
+ const proc = this.login(exe, t.args, { env: t.env })
212
+ this.signing = proc
213
+ this.lastActivity = this.now()
214
+ this.setStep({ step: 'starting' })
215
+ const timer = setTimeout(() => proc.kill(), SIGN_IN_MS)
216
+ timer.unref?.()
217
+ proc.onData((d) => {
218
+ out = (out + d).slice(-64 * 1024)
219
+ const r = readSignIn(tool, out)
220
+ if (r.token) {
221
+ keepToken(this.home, tool, r.token)
222
+ process.env.CLAUDE_CODE_OAUTH_TOKEN = r.token
223
+ }
224
+ const key = `${r.url}\n${r.code}`
225
+ if (r.url && key !== shown && this.signIn?.step !== 'waiting') {
226
+ shown = key
227
+ this.setStep({ step: r.code ? 'code' : 'link', url: r.url, code: r.code, needsCode: r.needsCode })
228
+ }
229
+ })
230
+ proc.exited.then(async (code) => {
231
+ clearTimeout(timer)
232
+ if (this.signing !== proc) return
233
+ this.signing = null
234
+ this.signedIn = await this.checkSignedIn()
235
+ this.lastActivity = this.now()
236
+ this.setStep(this.signedIn ? { step: 'done' } : { step: 'failed', error: `${tool}'s sign-in stopped (exit ${code}); start it again` })
237
+ })
238
+ }
239
+
240
+ /** The code the person pasted, typed into the tool's login. */
241
+ giveCode (code) {
242
+ if (!this.signing) { this.log('a sign-in code came with no sign-in running'); return }
243
+ this.signing.write(`${code}\r`)
244
+ this.setStep({ ...(this.signIn || {}), step: 'waiting', needsCode: false })
245
+ }
246
+
247
+ setStep (step) {
248
+ this.signIn = { url: '', code: '', needsCode: false, error: '', ...step }
249
+ this.report().catch(() => {})
250
+ }
251
+
252
+ // ------------------------------------------------------------ sessions --
253
+
254
+ followedFile () { return path.join(this.dir, 'sessions.json') }
255
+ /** The sessions this machine follows: room -> link (kept on its own disk, never by the API). */
256
+ followed () { try { return JSON.parse(fs.readFileSync(this.followedFile(), 'utf8')) || {} } catch { return {} } }
257
+ saveFollowed (map) { writePrivateJson(this.followedFile(), map) }
258
+
259
+ async join (opts) {
260
+ if (this.joinImpl) return this.joinImpl(opts)
261
+ const { runSession } = await import('./runner.js')
262
+ return runSession(opts)
263
+ }
264
+
265
+ async autowork () {
266
+ if (this.autoworkImpl) return this.autoworkImpl
267
+ const { autowork } = await import('./autowork.js')
268
+ this.autoworkImpl = autowork()
269
+ return this.autoworkImpl
270
+ }
271
+
272
+ /** Joins a session the person joined (again, after a restart), as their cloud AI. */
273
+ async follow (link) {
274
+ const invite = parseInvite(link, { allowRelay: () => true })
275
+ const room = invite.room
276
+ if (this.runs.has(room)) return
277
+ const { sessionPasses } = await import('./pass-source.js')
278
+ const auth = sessionPasses({ agent: AGENT_NAME })
279
+ const dir = path.join(this.home, 'projects', room)
280
+ const run = await this.join({
281
+ dir,
282
+ conn: { server: invite.relay || relayUrl(), room, secret: invite.secret },
283
+ name: auth.name,
284
+ tool: (this.config && this.config.tool) || 'Quilt cloud AI',
285
+ kind: 'agent',
286
+ passes: auth.passes,
287
+ identity: auth.identity,
288
+ agentFeed: false,
289
+ onLog: (line) => this.log(`[${room}] ${line}`),
290
+ onFatal: (err) => { this.log(`[${room}] stopped: ${err.message}`); this.runs.delete(room) }
291
+ })
292
+ this.runs.set(room, { run, link })
293
+ // What reaches it in the session counts as activity: the machine stays up while there is work.
294
+ if (run.session && run.session.on) {
295
+ run.session.on('inbox', () => { this.lastActivity = this.now() })
296
+ run.session.on('message', () => { this.lastActivity = this.now() })
297
+ }
298
+ this.saveFollowed({ ...this.followed(), [room]: link })
299
+ this.lastActivity = this.now()
300
+ this.log(`joined ${room}`)
301
+ }
302
+
303
+ /** Leaves a session the person left for good, and frees its folder. */
304
+ async leave (room) {
305
+ if (!ROOM.test(String(room))) return
306
+ const r = this.runs.get(room)
307
+ this.runs.delete(room)
308
+ if (r) await r.run.stop().catch(() => {})
309
+ const kept = this.followed()
310
+ delete kept[room]
311
+ this.saveFollowed(kept)
312
+ fs.rmSync(path.join(this.home, 'projects', room), { recursive: true, force: true })
313
+ this.log(`left ${room}`)
314
+ }
315
+
316
+ // --------------------------------------------------- reports and idle --
317
+
318
+ async report () {
319
+ const work = await this.autowork()
320
+ const log = work.log ? work.log() : []
321
+ const today = log.filter((e) => new Date(e.ts).toISOString().slice(0, 10) === new Date(this.now()).toISOString().slice(0, 10))
322
+ const sessions = [...this.runs.entries()].map(([room, { run }]) => ({ room, name: (run.session && run.session.sessionName) || '' }))
323
+ return this.call('POST', '/v1/ai-vm/report', {
324
+ signedIn: this.signedIn,
325
+ ...(this.signIn ? { signIn: this.signIn } : {}),
326
+ sessions,
327
+ usageToday: { tokens: tokensOn(log, this.now()), runs: today.length }
328
+ })
329
+ }
330
+
331
+ /** Whether anything runs or waits here now. */
332
+ async busy () {
333
+ if (this.signing) return true
334
+ const work = await this.autowork()
335
+ return !!(work.running || (work.queue && work.queue.length))
336
+ }
337
+
338
+ /** Nothing to do for a while: ask to be suspended. The machine resumes here when woken. */
339
+ async idleCheck () {
340
+ if (this.stopped) return false
341
+ if (await this.busy()) { this.lastActivity = this.now(); return false }
342
+ if (this.now() - this.lastActivity < this.idleMs) return false
343
+ this.lastActivity = this.now()
344
+ try {
345
+ await this.call('POST', '/v1/ai-vm/idle', {})
346
+ this.log('idle: suspending')
347
+ return true
348
+ } catch (err) {
349
+ if (err.status !== 409) this.log(`could not suspend: ${err.message}`)
350
+ return false
351
+ }
352
+ }
353
+
354
+ serveHealth () {
355
+ this.health = http.createServer((req, res) => {
356
+ if (req.url !== '/healthz') { res.writeHead(404); return res.end() }
357
+ res.writeHead(200, { 'content-type': 'application/json' })
358
+ res.end(JSON.stringify({ ok: true, tool: this.config?.tool || '', signedIn: this.signedIn, sessions: [...this.runs.keys()] }))
359
+ })
360
+ return new Promise((resolve) => this.health.listen(this.port, '0.0.0.0', resolve))
361
+ }
362
+ }
363
+
364
+ /** Finds the first of `names` on PATH. */
365
+ export function onPath (names, { env = process.env } = {}) {
366
+ for (const n of names) for (const d of String(env.PATH || '').split(path.delimiter)) if (d && fs.existsSync(path.join(d, n))) return path.join(d, n)
367
+ return null
368
+ }
369
+
370
+ /** `quilt ai-vm`, from the machine's environment. */
371
+ export async function runAiVm (env = process.env) {
372
+ if (!env.QUILT_API) throw new Error('QUILT_API is not set: this command runs on a cloud AI machine Quilt started.')
373
+ const vm = new AiVm({ api: env.QUILT_API, joinLink: env.QUILT_AI_VM_JOIN || '', home: env.HOME, which: (names) => onPath(names), port: Number(env.PORT || 8080) })
374
+ const shutdown = async () => { await vm.stop(); process.exit(0) }
375
+ process.on('SIGINT', shutdown)
376
+ process.on('SIGTERM', shutdown)
377
+ await vm.start()
378
+ console.log('quilt ai-vm: running')
379
+ return vm
380
+ }
@@ -0,0 +1,75 @@
1
+ // Access types and grants for the API's routes: finding a type (a built-in, or one of
2
+ // the owner's own) and what a grant comes to.
3
+ import { UUID } from './http.js'
4
+ import { builtinType, effectiveAccess, FALLBACK_TYPE } from '../session-access.js'
5
+ import { workspaceAccess } from './workspace-access.js'
6
+ import { orgGrantsFor } from './org-access.js'
7
+
8
+ // The owner's access, as a pass carries it. The relay keeps its own record of who owns a
9
+ // room; this only says the API agrees.
10
+ export const OWNER_ACCESS = Object.freeze({ owner: true, files: 'edit', folders: Object.freeze([]), foldersExcept: Object.freeze([]), talk: true })
11
+
12
+ /** A built-in, or one of `ownerAccount`'s own types; null otherwise (someone else's, or none). */
13
+ export async function ownType (store, ownerAccount, typeId) {
14
+ const id = String(typeId || '')
15
+ const b = builtinType(id)
16
+ if (b) return b
17
+ if (!UUID.test(id)) return null
18
+ const t = await store.accessTypeById(id)
19
+ return t && t.ownerAccount === ownerAccount ? t : null
20
+ }
21
+
22
+ /** A grant's type: View only when its own is gone (deleted while this was read). */
23
+ export async function typeOfGrant (store, grant) {
24
+ return builtinType(grant.typeId) || (UUID.test(grant.typeId) && await store.accessTypeById(grant.typeId)) || builtinType(FALLBACK_TYPE)
25
+ }
26
+
27
+ /** A grant as the owner sees it: its type's name and what it comes to. */
28
+ export async function grantView (store, grant) {
29
+ const type = await typeOfGrant(store, grant)
30
+ return { account: grant.account, typeId: type.id, typeName: type.name, tighten: grant.tighten || {}, access: effectiveAccess(type, grant.tighten), updatedAt: grant.updatedAt }
31
+ }
32
+
33
+ /**
34
+ * What `account` may do in `room`, for its pass: the owner's access, its grant's, or null
35
+ * (no grant: the owner lets them in, or not). `email` is a person's confirmed sign-in
36
+ * address: an open invite to it becomes their grant now, and the invite is used.
37
+ */
38
+ export async function roomAccess (store, room, account, email = '') {
39
+ return (await roomAccessFrom(store, room, account, email)).access
40
+ }
41
+
42
+ /**
43
+ * roomAccess, and where it comes from, for the session's member list: { kind: 'owner' },
44
+ * { kind: 'grant' } (let in here), { kind: 'workspace', via, workspace } or { kind: 'org',
45
+ * workspace, org }. { access: null, from: null } when there's none.
46
+ */
47
+ export async function roomAccessFrom (store, room, account, email = '') {
48
+ const none = { access: null, from: null }
49
+ const session = await store.sessionByRoom(room)
50
+ if (!session) return none
51
+ if (session.ownerAccount === account) return { access: OWNER_ACCESS, from: { kind: 'owner' } }
52
+ if (email) await store.claimEmailInvites(room, email.toLowerCase(), account)
53
+ const grant = await store.grantFor(room, account)
54
+ if (grant) {
55
+ await store.useAccountInvites(room, account)
56
+ return { access: effectiveAccess(await typeOfGrant(store, grant), grant.tighten), from: { kind: 'grant' } }
57
+ }
58
+ // No grant of its own: a session inside a workspace admits the workspace's people, but
59
+ // only when its owner (as the relay reports it) put it there. Anyone else's link, or one
60
+ // made before the relay has named an owner, lets nobody in.
61
+ if (!session.workspaceId || !session.ownerAccount || session.ownerAccount !== session.workspaceLinkedBy) return none
62
+ // A workspace's agents are invited to its sessions (sent the link), never let in by it:
63
+ // like anyone with a link they wait, and the session owner lets them in (a grant, above).
64
+ if (account.startsWith('agent:')) return none
65
+ const ws = await store.workspaceById(session.workspaceId)
66
+ if (!ws) return none
67
+ const wa = await workspaceAccess(store, ws, account, { orgGrants: ws.orgId ? await orgGrantsFor(store, ws.orgId, account) : null })
68
+ if (!wa) return none
69
+ const access = effectiveAccess(builtinType(wa.access === 'edit' ? 'builtin:edit' : 'builtin:view'), {})
70
+ if (wa.via === 'org') {
71
+ const org = ws.orgId ? await store.orgById(ws.orgId) : null
72
+ return { access, from: { kind: 'org', workspace: ws.name || '', org: org?.name || '' } }
73
+ }
74
+ return { access, from: { kind: 'workspace', via: wa.via, workspace: ws.name || '' } }
75
+ }
@@ -0,0 +1,181 @@
1
+ // Time spent in sessions, from the visits the relay reports (see routes/relay.js).
2
+ // Pure: the API's routes hand it rows from the store and the time now. Times are
3
+ // epoch ms; an interval is [start, end) and a list of them is kept sorted and merged.
4
+
5
+ export const DAY_MS = 24 * 60 * 60 * 1000
6
+ export const MAX_SESSIONS = 100
7
+ export const MAX_PEOPLE_TOP = 3
8
+ export const MAX_COLLABORATORS = 30
9
+ export const MAX_VISITS = 20
10
+
11
+ /** Sorted, overlapping and touching intervals joined, empty ones dropped. */
12
+ export function merge (intervals) {
13
+ const sorted = intervals.filter(([s, e]) => e > s).sort((a, b) => a[0] - b[0])
14
+ const out = []
15
+ for (const [s, e] of sorted) {
16
+ const last = out[out.length - 1]
17
+ if (last && s <= last[1]) last[1] = Math.max(last[1], e)
18
+ else out.push([s, e])
19
+ }
20
+ return out
21
+ }
22
+
23
+ /** Where two merged lists overlap, as a merged list. */
24
+ export function intersect (a, b) {
25
+ const out = []
26
+ let i = 0
27
+ let j = 0
28
+ while (i < a.length && j < b.length) {
29
+ const s = Math.max(a[i][0], b[j][0])
30
+ const e = Math.min(a[i][1], b[j][1])
31
+ if (e > s) out.push([s, e])
32
+ if (a[i][1] < b[j][1]) i++
33
+ else j++
34
+ }
35
+ return out
36
+ }
37
+
38
+ /** A merged list cut to [from, to). */
39
+ export function clip (list, from, to) {
40
+ return list.map(([s, e]) => [Math.max(s, from), Math.min(e, to)]).filter(([s, e]) => e > s)
41
+ }
42
+
43
+ export const total = (list) => list.reduce((n, [s, e]) => n + (e - s), 0)
44
+
45
+ /** A visit as an interval: an open visit runs until now. */
46
+ const span = (v, now) => [v.startedAt, v.endedAt == null ? now : Math.min(v.endedAt, now)]
47
+
48
+ const formatters = new Map()
49
+ function partsIn (t, tz) {
50
+ let f = formatters.get(tz)
51
+ if (!f) {
52
+ f = new Intl.DateTimeFormat('en-US', { timeZone: tz, hourCycle: 'h23', weekday: 'short', year: 'numeric', month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric', second: 'numeric' })
53
+ formatters.set(tz, f)
54
+ }
55
+ const p = Object.fromEntries(f.formatToParts(new Date(t)).map((x) => [x.type, x.value]))
56
+ return { year: +p.year, month: +p.month, day: +p.day, hour: +p.hour, minute: +p.minute, second: +p.second, weekday: p.weekday }
57
+ }
58
+
59
+ /** Whether `tz` is an IANA time zone this runtime knows ("Europe/Paris", "UTC"). */
60
+ export function isTimeZone (tz) {
61
+ if (typeof tz !== 'string' || !tz || tz.length > 64) return false
62
+ try { new Intl.DateTimeFormat('en-US', { timeZone: tz }).format(0); return true } catch { return false }
63
+ }
64
+
65
+ /** How far `tz` is ahead of UTC at `t`, in ms. */
66
+ export function zoneOffset (t, tz) {
67
+ const p = partsIn(t, tz)
68
+ return Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second) - Math.floor(t / 1000) * 1000
69
+ }
70
+
71
+ /** 00:00 on a calendar day in `tz` (month 1-12; days past the month's end roll over). */
72
+ export function midnightIn (year, month, day, tz) {
73
+ const guess = Date.UTC(year, month - 1, day)
74
+ const first = guess - zoneOffset(guess, tz)
75
+ return guess - zoneOffset(first, tz)
76
+ }
77
+
78
+ const WEEKDAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
79
+
80
+ /** Monday 00:00 of the week `now` falls in, in `tz`. */
81
+ export function weekStart (now, tz) {
82
+ const p = partsIn(now, tz)
83
+ return midnightIn(p.year, p.month, p.day - WEEKDAYS.indexOf(p.weekday), tz)
84
+ }
85
+
86
+ /** The 1st of the month `now` falls in, 00:00 in `tz`. */
87
+ export function monthStart (now, tz) {
88
+ const p = partsIn(now, tz)
89
+ return midnightIn(p.year, p.month, 1, tz)
90
+ }
91
+
92
+ /** The newest name an account went by in these rows. */
93
+ function latest (rows) {
94
+ return rows.reduce((a, b) => (b.startedAt > a.startedAt ? b : a))
95
+ }
96
+
97
+ /**
98
+ * The dashboard: for `me` ('person:<id>'), each session in `sessions` I was in, my
99
+ * time there and the people and agents whose visits overlapped mine; plus my time
100
+ * collaborating this week and who I worked with most this month (both in `tz`).
101
+ * `visits` are every visit in those sessions. Nobody I never overlapped with appears.
102
+ */
103
+ export function summarize ({ me, sessions, visits, now, tz = 'UTC' }) {
104
+ const byRoom = new Map()
105
+ for (const v of visits) {
106
+ if (!byRoom.has(v.room)) byRoom.set(v.room, [])
107
+ byRoom.get(v.room).push(v)
108
+ }
109
+ const collab = [] // time I overlapped anyone, across every session
110
+ const together = new Map() // account -> { name, kind, spans } across every session
111
+ const rows = []
112
+ for (const s of sessions) {
113
+ const inRoom = byRoom.get(s.room) || []
114
+ const mineRows = inRoom.filter((v) => v.account === me)
115
+ if (!mineRows.length) continue
116
+ const mine = merge(mineRows.map((v) => span(v, now)))
117
+ const others = new Map()
118
+ for (const v of inRoom) {
119
+ if (v.account === me) continue
120
+ if (!others.has(v.account)) others.set(v.account, [])
121
+ others.get(v.account).push(v)
122
+ }
123
+ collab.push(...intersect(mine, merge(inRoom.filter((v) => v.account !== me).map((v) => span(v, now)))))
124
+ const people = []
125
+ for (const [account, theirs] of others) {
126
+ const both = intersect(mine, merge(theirs.map((v) => span(v, now))))
127
+ const ms = total(both)
128
+ if (ms <= 0) continue
129
+ const last = latest(theirs)
130
+ people.push({ account, name: last.accountName, kind: last.kind, togetherMs: ms, lastTogetherAt: both[both.length - 1][1] })
131
+ const t = together.get(account) || { name: last.accountName, kind: last.kind, at: 0, spans: [] }
132
+ if (last.startedAt >= t.at) Object.assign(t, { name: last.accountName, kind: last.kind, at: last.startedAt })
133
+ t.spans.push(...both)
134
+ together.set(account, t)
135
+ }
136
+ people.sort((a, b) => b.togetherMs - a.togetherMs || a.name.localeCompare(b.name))
137
+ const ownerRows = s.ownerAccount ? inRoom.filter((v) => v.account === s.ownerAccount) : []
138
+ const open = inRoom.some((v) => v.endedAt == null)
139
+ rows.push({
140
+ room: s.room,
141
+ name: s.name || '',
142
+ owner: { name: ownerRows.length ? latest(ownerRows).accountName : '' },
143
+ mine: !!s.ownerAccount && s.ownerAccount === me,
144
+ createdAt: s.createdAt,
145
+ lastActiveAt: open ? now : s.lastActiveAt,
146
+ myTotalMs: total(mine),
147
+ people
148
+ })
149
+ }
150
+ rows.sort((a, b) => b.lastActiveAt - a.lastActiveAt || a.room.localeCompare(b.room))
151
+ const week = weekStart(now, tz)
152
+ const month = monthStart(now, tz)
153
+ const topCollaborators = [...together].map(([account, t]) => ({ account, name: t.name, kind: t.kind, ms: total(clip(merge(t.spans), month, now)) }))
154
+ .filter((c) => c.ms > 0)
155
+ .sort((a, b) => b.ms - a.ms || a.name.localeCompare(b.name))
156
+ .slice(0, MAX_PEOPLE_TOP)
157
+ return {
158
+ totals: { collaboratingThisWeek: total(clip(merge(collab), week, now)), topCollaborators },
159
+ sessions: rows.slice(0, MAX_SESSIONS)
160
+ }
161
+ }
162
+
163
+ /** Everyone I've overlapped with in these sessions, most recent first (for invites). */
164
+ export function collaborators (sessions) {
165
+ const seen = new Map()
166
+ for (const s of sessions) {
167
+ for (const p of s.people) {
168
+ const was = seen.get(p.account)
169
+ if (!was || p.lastTogetherAt > was.lastTogetherAt) seen.set(p.account, { account: p.account, name: p.name, kind: p.kind, lastTogetherAt: p.lastTogetherAt })
170
+ }
171
+ }
172
+ return [...seen.values()].sort((a, b) => b.lastTogetherAt - a.lastTogetherAt).slice(0, MAX_COLLABORATORS)
173
+ }
174
+
175
+ /** My last visits to one session, newest first. */
176
+ export function myVisits ({ me, visits }) {
177
+ return visits.filter((v) => v.account === me)
178
+ .sort((a, b) => b.startedAt - a.startedAt)
179
+ .slice(0, MAX_VISITS)
180
+ .map((v) => ({ startedAt: v.startedAt, endedAt: v.endedAt ?? null }))
181
+ }