heyquilt 0.0.1 → 0.3.106

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 (221) hide show
  1. package/LICENSE +85 -0
  2. package/README.md +633 -2
  3. package/RELEASES.md +862 -0
  4. package/assets/logo.svg +1 -0
  5. package/bin/quilt.js +849 -0
  6. package/desktop/main.js +315 -0
  7. package/package.json +163 -7
  8. package/src/account.js +591 -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 +187 -0
  14. package/src/agents/common.js +21 -0
  15. package/src/agents/cursor-transcripts.js +177 -0
  16. package/src/agents/cursor.js +404 -0
  17. package/src/agents/index.js +69 -0
  18. package/src/ai-vm-signin.js +110 -0
  19. package/src/ai-vm.js +522 -0
  20. package/src/api/access.js +75 -0
  21. package/src/api/activity.js +181 -0
  22. package/src/api/agent-auth.js +136 -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 +133 -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 +1219 -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 +545 -0
  40. package/src/api/relay-machines.js +92 -0
  41. package/src/api/releases.js +87 -0
  42. package/src/api/routes/access-types.js +54 -0
  43. package/src/api/routes/agent-invites.js +115 -0
  44. package/src/api/routes/agents.js +252 -0
  45. package/src/api/routes/ai-vm.js +495 -0
  46. package/src/api/routes/github.js +380 -0
  47. package/src/api/routes/grants.js +70 -0
  48. package/src/api/routes/invites.js +188 -0
  49. package/src/api/routes/issues.js +47 -0
  50. package/src/api/routes/join.js +167 -0
  51. package/src/api/routes/members.js +110 -0
  52. package/src/api/routes/orgs.js +179 -0
  53. package/src/api/routes/relay-cluster.js +90 -0
  54. package/src/api/routes/relay.js +76 -0
  55. package/src/api/routes/secrets.js +448 -0
  56. package/src/api/routes/session-invites.js +119 -0
  57. package/src/api/routes/sessions.js +92 -0
  58. package/src/api/routes/teams.js +132 -0
  59. package/src/api/routes/workspace-agents.js +332 -0
  60. package/src/api/routes/workspace-files.js +277 -0
  61. package/src/api/routes/workspace-invites.js +196 -0
  62. package/src/api/routes/workspaces.js +232 -0
  63. package/src/api/sandbox-keys.js +57 -0
  64. package/src/api/secrets-access.js +93 -0
  65. package/src/api/server.js +654 -0
  66. package/src/api/slugs.js +20 -0
  67. package/src/api/supabase-store.js +875 -0
  68. package/src/api/team-access.js +30 -0
  69. package/src/api/tokens.js +31 -0
  70. package/src/api/workspace-access.js +62 -0
  71. package/src/api/workspace-reach.js +60 -0
  72. package/src/autowork-tools.js +166 -0
  73. package/src/autowork.js +375 -0
  74. package/src/blobstore.js +99 -0
  75. package/src/boxes.js +37 -0
  76. package/src/branchdocs.js +235 -0
  77. package/src/branches.js +194 -0
  78. package/src/catchup.js +111 -0
  79. package/src/chat-archive.js +85 -0
  80. package/src/chat-links.js +923 -0
  81. package/src/clash.js +128 -0
  82. package/src/cloud-ai.js +50 -0
  83. package/src/commit-request.js +59 -0
  84. package/src/commit.js +213 -0
  85. package/src/connection.js +569 -0
  86. package/src/control.js +248 -0
  87. package/src/conversation.js +125 -0
  88. package/src/doctor.js +156 -0
  89. package/src/duties.js +303 -0
  90. package/src/editors.js +228 -0
  91. package/src/fsutil.js +97 -0
  92. package/src/git-hook.js +48 -0
  93. package/src/git.js +147 -0
  94. package/src/github-commit.js +320 -0
  95. package/src/gitignore.js +49 -0
  96. package/src/gitstate.js +924 -0
  97. package/src/history.js +307 -0
  98. package/src/hooks.js +371 -0
  99. package/src/hosted-agents.js +24 -0
  100. package/src/hosted-client.js +112 -0
  101. package/src/identity.js +99 -0
  102. package/src/inbox.js +237 -0
  103. package/src/integrations.js +221 -0
  104. package/src/key-cli.js +115 -0
  105. package/src/largefiles.js +91 -0
  106. package/src/legacy.js +25 -0
  107. package/src/mcp-proxy.js +45 -0
  108. package/src/mcp-room.js +32 -0
  109. package/src/mcp.js +1258 -0
  110. package/src/merge3.js +240 -0
  111. package/src/merges.js +148 -0
  112. package/src/pass-source.js +183 -0
  113. package/src/passes.js +64 -0
  114. package/src/pathrules.js +134 -0
  115. package/src/persona.js +101 -0
  116. package/src/presence.js +360 -0
  117. package/src/private-file.js +41 -0
  118. package/src/procs.js +70 -0
  119. package/src/project-rules.js +178 -0
  120. package/src/protocol.js +92 -0
  121. package/src/quiet-warnings.js +9 -0
  122. package/src/relay-ai-vm.js +82 -0
  123. package/src/relay-ask.js +141 -0
  124. package/src/relay-ci.js +111 -0
  125. package/src/relay-clash.js +214 -0
  126. package/src/relay-cluster.js +454 -0
  127. package/src/relay-commit.js +887 -0
  128. package/src/relay-filekey.js +46 -0
  129. package/src/relay-git.js +274 -0
  130. package/src/relay-githttp.js +166 -0
  131. package/src/relay-github.js +109 -0
  132. package/src/relay-lease.js +137 -0
  133. package/src/relay-lfs.js +37 -0
  134. package/src/relay-mcp.js +1691 -0
  135. package/src/relay-metrics.js +140 -0
  136. package/src/relay-outbound.js +1273 -0
  137. package/src/relay-sync.js +716 -0
  138. package/src/relay-turn-sync.js +506 -0
  139. package/src/relay-upstream.js +804 -0
  140. package/src/relay-webhooks.js +111 -0
  141. package/src/releases.js +138 -0
  142. package/src/report.js +129 -0
  143. package/src/runner.js +307 -0
  144. package/src/same-edit.js +18 -0
  145. package/src/secret-import.js +55 -0
  146. package/src/secret-names.js +26 -0
  147. package/src/secret-run.js +80 -0
  148. package/src/secrets-client.js +56 -0
  149. package/src/server.js +4214 -0
  150. package/src/session-access.js +170 -0
  151. package/src/session-name.js +15 -0
  152. package/src/session.js +7446 -0
  153. package/src/settings.js +46 -0
  154. package/src/setup.js +184 -0
  155. package/src/shell-path.js +46 -0
  156. package/src/status.js +197 -0
  157. package/src/summarize.js +109 -0
  158. package/src/sync-words.js +42 -0
  159. package/src/task-comments.js +92 -0
  160. package/src/tasks.js +670 -0
  161. package/src/textdiff.js +20 -0
  162. package/src/turn-sync-plan.js +145 -0
  163. package/src/turn-sync.js +212 -0
  164. package/src/ui/access-form.js +37 -0
  165. package/src/ui/agent-env-key.js +76 -0
  166. package/src/ui/agent-guide.js +127 -0
  167. package/src/ui/agent-kinds.js +175 -0
  168. package/src/ui/agent-place.js +265 -0
  169. package/src/ui/app.css +2494 -0
  170. package/src/ui/app.js +546 -0
  171. package/src/ui/board.js +465 -0
  172. package/src/ui/boxes.js +76 -0
  173. package/src/ui/branches.js +111 -0
  174. package/src/ui/catchup.js +77 -0
  175. package/src/ui/changes.js +159 -0
  176. package/src/ui/chat.js +266 -0
  177. package/src/ui/cloud-ai-model.js +108 -0
  178. package/src/ui/cloud-ai.js +221 -0
  179. package/src/ui/common.js +433 -0
  180. package/src/ui/csv.js +21 -0
  181. package/src/ui/diffview.js +74 -0
  182. package/src/ui/feed-convs.js +39 -0
  183. package/src/ui/feed.js +174 -0
  184. package/src/ui/files.js +579 -0
  185. package/src/ui/fileview.js +150 -0
  186. package/src/ui/git-rule.js +60 -0
  187. package/src/ui/home.js +1322 -0
  188. package/src/ui/index.html +16 -0
  189. package/src/ui/invite.js +97 -0
  190. package/src/ui/invites.js +79 -0
  191. package/src/ui/loom-model.js +516 -0
  192. package/src/ui/loom.css +365 -0
  193. package/src/ui/loom.js +786 -0
  194. package/src/ui/mark.js +104 -0
  195. package/src/ui/rail-model.js +75 -0
  196. package/src/ui/rail.js +173 -0
  197. package/src/ui/releases.js +182 -0
  198. package/src/ui/sandbox-keys-model.js +53 -0
  199. package/src/ui/sandbox-keys.js +132 -0
  200. package/src/ui/schedule.js +156 -0
  201. package/src/ui/secret-rules.js +255 -0
  202. package/src/ui/secrets-view.js +162 -0
  203. package/src/ui/secrets.js +283 -0
  204. package/src/ui/session.js +3292 -0
  205. package/src/ui/signin.js +75 -0
  206. package/src/ui/sounds.js +110 -0
  207. package/src/ui/tool-logo.js +86 -0
  208. package/src/ui/tree.js +281 -0
  209. package/src/ui/workspaces.js +547 -0
  210. package/src/ui-server.js +1381 -0
  211. package/src/update-check.js +67 -0
  212. package/src/upstream.js +192 -0
  213. package/src/vault/crypto.js +96 -0
  214. package/src/vault/kms.js +61 -0
  215. package/src/vault/main.js +33 -0
  216. package/src/vault/server.js +279 -0
  217. package/src/vault/sigv4.js +38 -0
  218. package/src/webhooks.js +235 -0
  219. package/src/workspace-cli.js +62 -0
  220. package/src/workspace-tools.js +340 -0
  221. package/index.js +0 -2
@@ -0,0 +1,375 @@
1
+ // "Work on its own": when work comes to "<person>'s AI" (a task given to it, a message to
2
+ // it) and none of the person's AI sessions is live, Quilt can start the person's own AI tool
3
+ // in its non-interactive mode, on their computer, in the session's folder — only when they
4
+ // turned it on in Settings. The run talks through Quilt chat: it asks with quilt_message and
5
+ // stops; a reply to it carries the same tool chat on. Every run is logged with its tokens.
6
+ //
7
+ // One AutoWork per app: one run at a time, whatever the session; work that arrives meanwhile
8
+ // waits its turn. The tools themselves are presets in autowork-tools.js.
9
+ import fs from 'node:fs'
10
+ import path from 'node:path'
11
+ import crypto from 'node:crypto'
12
+ import { spawn } from 'node:child_process'
13
+ import { getSettings } from './settings.js'
14
+ import { quiltHome } from './legacy.js'
15
+ import { autoToolFor, findExe } from './autowork-tools.js'
16
+ import { claudeCli, installedEditors, openIn } from './editors.js'
17
+
18
+ export const AUTO_MODES = ['chat', 'open', 'auto']
19
+ const RUN_MS = 30 * 60 * 1000 // a run that takes longer is stopped
20
+ const KEEP_CHATS = 30 // runs whose chat a reply can still carry on
21
+ const KEEP_CHAT_MS = 7 * 24 * 60 * 60 * 1000
22
+ const MAX_OUT = 4 * 1024 * 1024
23
+
24
+ const day = (ts) => new Date(ts).toISOString().slice(0, 10)
25
+ // What tools say when nobody is signed in to them (their own words differ; none of their fixes are repeated).
26
+ const SIGNED_OUT = /not (logged|signed) in|log ?in|sign ?in|authenticat|unauthori[sz]ed|api key|credentials|oauth/i
27
+
28
+ /** This computer's "Work on its own" settings, with defaults. */
29
+ export function autoConfig (s = getSettings()) {
30
+ const mode = AUTO_MODES.includes(s.autoWork) ? s.autoWork : 'chat'
31
+ const preset = autoToolFor(s.tool)
32
+ return {
33
+ mode,
34
+ tool: s.tool || '',
35
+ preset,
36
+ model: String(s.autoModel || '').trim() || (preset ? preset.models[0] : ''),
37
+ changeFiles: !!s.autoFiles,
38
+ cap: Number(s.autoCap) > 0 ? Math.floor(Number(s.autoCap)) : 0,
39
+ // The person's own instructions for every run (their cloud AI's settings).
40
+ prompt: String(s.autoPrompt || '').trim().slice(0, 4000)
41
+ }
42
+ }
43
+
44
+ /** Totals from log lines: { [day]: { [tool]: { runs, input, output, costUsd } } }. */
45
+ export function usageTotals (entries) {
46
+ const out = {}
47
+ for (const e of entries) {
48
+ if (!e || !e.ts) continue
49
+ const d = (out[day(e.ts)] ||= {})
50
+ const t = (d[e.tool] ||= { runs: 0, input: 0, output: 0, costUsd: 0 })
51
+ t.runs++
52
+ t.input += e.input || 0
53
+ t.output += e.output || 0
54
+ t.costUsd += e.costUsd || 0
55
+ }
56
+ return out
57
+ }
58
+
59
+ /** Tokens used on `ts`'s day, every tool together. */
60
+ export function tokensOn (entries, ts) {
61
+ const d = usageTotals(entries)[day(ts)] || {}
62
+ return Object.values(d).reduce((n, t) => n + t.input + t.output, 0)
63
+ }
64
+
65
+ /** What a run is asked to do: the work, and how to talk while nobody watches. */
66
+ export function runPrompt ({ person, job, changeFiles, prompt = '' }) {
67
+ const lines = [
68
+ `You are ${person}'s AI in the Quilt session in this folder, working on your own: nobody is watching this chat, and it isn't a conversation.`,
69
+ ''
70
+ ]
71
+ if (job.kind === 'task') {
72
+ lines.push(`${job.by || 'Someone'} gave ${person}'s AI a task on the board:`, `Task ${job.id}: ${job.title}`, '',
73
+ 'Read it in full with quilt_task, move it to "doing" with quilt_move_task when you start (that briefs you), and to "qa" with qaNotes when it is done and checked.')
74
+ } else if (job.kind === 'message') {
75
+ lines.push(`${job.by || 'Someone'} wrote to ${person}'s AI in Quilt chat:`, '', String(job.text || '').trim(), '',
76
+ `Answer ${job.by || 'them'} with quilt_message. If they ask for work, do it as ${person}'s AI would, and say in chat what you did.`)
77
+ }
78
+ if (prompt) lines.push('', `${person}'s own instructions for you:`, prompt)
79
+ lines.push('', 'Before your first change, read the project\'s rules with quilt_project_rules.')
80
+ if (!changeFiles) lines.push(`${person} hasn't let you change files: read, plan, comment on the task (quilt_comment_task) or ask, and leave the changes to a person or another session.`)
81
+ lines.push(
82
+ 'If you need an answer from someone, ask them with quilt_message and stop: when they reply, this chat continues with their answer.',
83
+ 'When you finish, or stop to wait, end with a sentence or two on where things stand.'
84
+ )
85
+ return lines.join('\n')
86
+ }
87
+
88
+ /** How a reply carries a run on: the message, and for a tool that can't resume, what came before. */
89
+ export function replyPrompt ({ run, reply, resumes }) {
90
+ const said = `${reply.by} replied in Quilt chat: ${reply.text}`
91
+ if (resumes) return `${said}\n\nCarry on with the work.`
92
+ return `${run.prompt}\n\nEarlier, in this work, you said:\n${run.lastText || '(nothing)'}\n\n${said}\n\nCarry on from there.`
93
+ }
94
+
95
+ function defaultSpawn (exe, args, { cwd, onStart }) {
96
+ return new Promise((resolve) => {
97
+ let out = ''; let err = ''
98
+ let child
99
+ try {
100
+ child = spawn(exe, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'], env: process.env, windowsHide: true })
101
+ } catch (e) { resolve({ code: -1, stdout: '', stderr: e.message }); return }
102
+ const timer = setTimeout(() => { try { child.kill() } catch {} }, RUN_MS)
103
+ child.stdout.on('data', (d) => { if (out.length < MAX_OUT) out += d })
104
+ child.stderr.on('data', (d) => { if (err.length < 64 * 1024) err += d })
105
+ child.on('error', (e) => { clearTimeout(timer); resolve({ code: -1, stdout: out, stderr: e.message }) })
106
+ child.on('close', (code) => { clearTimeout(timer); resolve({ code, stdout: out, stderr: err }) })
107
+ if (child.pid && onStart) onStart(child.pid)
108
+ })
109
+ }
110
+
111
+ export class AutoWork {
112
+ /**
113
+ * `settings()`, `spawnRun(exe, args, { cwd, onStart(pid) })`, `exeFor(preset)`, `dir` and
114
+ * `now()` are injectable for tests.
115
+ */
116
+ constructor ({ settings = getSettings, spawnRun = defaultSpawn, exeFor = null, open = null, dir = path.join(quiltHome(), 'autowork'), now = Date.now, cloudAi = () => false } = {}) {
117
+ this.settings = settings
118
+ // Whether the person has a cloud AI (the app asks the API now and then): its work is not this computer's.
119
+ this.cloudAi = cloudAi
120
+ // "Open it in my tool": the person's AI app, opened in the folder with the work as its prompt (editors.js).
121
+ this.open = open || (async (tool, dir, prompt) => {
122
+ const ed = installedEditors().find((e) => e.tool === tool)
123
+ if (!ed) throw new Error(`${tool || 'Your AI tool'} isn't installed on this computer`)
124
+ return openIn(ed.id, dir, { prompt })
125
+ })
126
+ this.spawnRun = spawnRun
127
+ this.exeFor = exeFor || ((p) => (p.id === 'claude' ? claudeCli() : findExe(p.exe)))
128
+ this.dir = dir
129
+ this.now = now
130
+ this.queue = []
131
+ this.running = null
132
+ this.idle = Promise.resolve()
133
+ this.sessions = new Set()
134
+ this.chats = this.loadChats()
135
+ this.toldCap = ''
136
+ }
137
+
138
+ config () { return autoConfig(this.settings()) }
139
+
140
+ /**
141
+ * Watches a person's session: work for their AI, and replies to runs. Agents' sessions work for
142
+ * themselves, except a person's cloud AI (cloud-ai.js), whose session is that person's AI.
143
+ */
144
+ attach (session) {
145
+ if (this.sessions.has(session)) return
146
+ this.sessions.add(session)
147
+ const onInbox = (events) => this.onInbox(session, events)
148
+ const onPersona = (p, events) => this.onReply(session, p, events)
149
+ session.on('inbox', onInbox)
150
+ session.on('persona-inbox', onPersona)
151
+ }
152
+
153
+ /** Whether work for the person's AI should run here now. */
154
+ on () {
155
+ const c = this.config()
156
+ return c.mode === 'auto' && !!c.preset
157
+ }
158
+
159
+ onInbox (session, events) {
160
+ if (!worksFor(session)) return
161
+ const c = this.config()
162
+ if (c.mode === 'open') { this.openTasks(session, events, c); return }
163
+ if (!this.on()) return
164
+ for (const e of events) {
165
+ // A reply to something a run said, written to "<me>'s AI": that run carries on.
166
+ if ((e.kind === 'dm' || e.kind === 'mention') && e.re) {
167
+ const run = this.chats.find((r) => r.room === session.room && r.said && r.said.includes(e.re))
168
+ if (run) { this.enqueue({ kind: 'reply', session, run, reply: { by: e.by, text: e.text, id: e.id } }); continue }
169
+ }
170
+ // A live AI session of theirs takes its own work (the inbox wakes it); this is for when none is.
171
+ // Their cloud AI, when it is in the session, takes it instead of this computer.
172
+ if (e.kind === 'task' && session.forMyAi(e) && !session.leadPersona() && !this.leaveToCloud(session)) {
173
+ if (this.queue.some((j) => j.kind === 'task' && j.id === e.task.id) || (this.running && this.running.job.id === e.task.id)) continue
174
+ this.enqueue({ kind: 'task', session, id: e.task.id, title: e.task.title, by: e.by })
175
+ continue
176
+ }
177
+ // A person's cloud AI answers what is written to it (its inbox only has it while no AI session of theirs is live).
178
+ if (session.aiOf && (e.kind === 'dm' || e.kind === 'mention') && !e.queue && !e.commit && !e.nudge && session.forMyAi(e)) {
179
+ if (this.queue.some((j) => j.kind === 'message' && j.id === e.id)) continue
180
+ this.enqueue({ kind: 'message', session, id: e.id, title: `message from ${e.by}`, by: e.by, text: e.text })
181
+ }
182
+ }
183
+ }
184
+
185
+ /** Whether work for the person's AI is their cloud AI's, not this computer's: it is in the session, or they have one. */
186
+ leaveToCloud (session) {
187
+ if (session.aiOf) return false
188
+ return !!((session.cloudAiLive && session.cloudAiLive()) || this.cloudAi())
189
+ }
190
+
191
+ /** "Open it in my tool": a task for my AI, with no AI session of mine live, opens my tool on it. */
192
+ openTasks (session, events, c) {
193
+ if (session.aiOf) return // a cloud machine has no tool window to open
194
+ for (const e of events) {
195
+ if (e.kind !== 'task' || !session.forMyAi(e) || session.leadPersona() || this.leaveToCloud(session)) continue
196
+ const prompt = runPrompt({ person: session.name, job: { kind: 'task', id: e.task.id, title: e.task.title, by: e.by }, changeFiles: true })
197
+ .replace(', working on your own: nobody is watching this chat, and it isn\'t a conversation.', '.')
198
+ Promise.resolve(this.open(c.tool, session.root, prompt)).catch((err) => this.notice(session, `Couldn't open ${c.tool || 'your AI tool'} for ${e.task.title}: ${err.message}.`))
199
+ }
200
+ }
201
+
202
+ onReply (session, p, events) {
203
+ if (!worksFor(session) || !this.on()) return
204
+ const run = this.chats.find((r) => r.room === session.room && r.via === p.rid)
205
+ if (!run) return
206
+ for (const e of events) {
207
+ if (e.kind !== 'dm' && e.kind !== 'mention') continue
208
+ this.enqueue({ kind: 'reply', session, run, reply: { by: e.by, text: e.text, id: e.id } })
209
+ }
210
+ }
211
+
212
+ enqueue (job) {
213
+ this.queue.push(job)
214
+ this.idle = this.idle.then(() => this.next()).catch(() => {})
215
+ return this.idle
216
+ }
217
+
218
+ async next () {
219
+ const job = this.queue.shift()
220
+ if (!job) return
221
+ this.running = { job }
222
+ try { await this.run(job) } finally { this.running = null }
223
+ }
224
+
225
+ /** Runs one piece of work to its end (or its question). */
226
+ async run (job) {
227
+ const { session } = job
228
+ const c = this.config()
229
+ if (session.stopped) this.sessions.delete(session)
230
+ if (c.mode !== 'auto' || !c.preset || !this.sessions.has(session)) return
231
+ const p = c.preset
232
+ // Over today's cap: the work waits for the person's chat, and they are told once a day.
233
+ if (c.cap && tokensOn(this.log(), this.now()) >= c.cap) {
234
+ if (this.toldCap !== day(this.now())) {
235
+ this.toldCap = day(this.now())
236
+ this.notice(session, `Your AI has used today's ${c.cap.toLocaleString('en-US')} tokens for working on its own, so new work waits for you in your chat until tomorrow. Change the limit in Settings → Your AI.`)
237
+ }
238
+ return
239
+ }
240
+ const exe = this.exeFor(p)
241
+ let run = job.kind === 'reply' ? job.run : null
242
+ const resumes = p.resumes !== false && !!(run && run.chatId)
243
+ if (!run) {
244
+ const prompt = runPrompt({ person: personOf(session), job, changeFiles: c.changeFiles, prompt: c.prompt })
245
+ run = { id: crypto.randomBytes(6).toString('hex'), via: `auto${crypto.randomBytes(6).toString('hex')}`, room: session.room, dir: session.root, tool: p.tool, kind: job.kind, work: job.id, path: job.path || '', title: job.title, by: job.by, prompt, chatId: p.newChatId(), said: [], ts: this.now() }
246
+ }
247
+ if (!exe) {
248
+ this.fail(session, run, `${p.exe} isn't installed on this computer`)
249
+ return
250
+ }
251
+ const o = { model: c.model, changeFiles: c.changeFiles, chatId: run.chatId }
252
+ const args = resumes
253
+ ? p.resume({ ...o, prompt: replyPrompt({ run, reply: job.reply, resumes: true }) })
254
+ : p.start({ ...o, prompt: job.kind === 'reply' ? replyPrompt({ run, reply: job.reply, resumes: false }) : run.prompt })
255
+ const label = run.title || 'work'
256
+ const started = this.now()
257
+ let persona = null
258
+ const r = await this.spawnRun(exe, args, {
259
+ cwd: run.dir,
260
+ onStart: (pid) => { try { persona = session.autoPersona(run.via, { tool: p.tool, label: `on its own: ${label}`, pid }) } catch {} }
261
+ })
262
+ if (!persona) persona = session.autoPersonaOf(run.via)
263
+ const out = p.parse(r.stdout)
264
+ if (out.chatId) run.chatId = out.chatId
265
+ this.record({ ts: started, ms: this.now() - started, room: run.room, run: run.id, tool: p.tool, model: c.model, kind: job.kind, work: run.work || '', title: run.title || '', input: out.usage ? out.usage.input : 0, output: out.usage ? out.usage.output : 0, costUsd: out.usage && out.usage.costUsd ? out.usage.costUsd : 0, reported: !!out.usage, chatId: run.chatId || null, outcome: out.error || r.code ? 'failed' : 'ok' })
266
+ if (out.error || (r.code && !out.text)) {
267
+ // What the tool printed as its result says most; with nothing printed, its error output does.
268
+ const why = (String(r.stdout || '').trim() && out.error) || firstLine(r.stderr) || out.error || `${p.exe} stopped (exit ${r.code})`
269
+ this.fail(session, run, why)
270
+ return
271
+ }
272
+ run.lastText = out.text
273
+ // What the run said in chat, so a reply to any of it carries this chat on.
274
+ const name = persona ? [persona.name, ...persona.aliases] : []
275
+ const mine = session.chat.toArray().filter((m) => m && m.ts >= started && name.includes(m.by)).map((m) => m.id)
276
+ run.said = [...new Set([...run.said, ...mine])].slice(-50)
277
+ // A run that said nothing in chat: its last words go to whoever gave it the work.
278
+ if (!mine.length && out.text && persona) {
279
+ try {
280
+ const to = run.by && run.by !== session.name ? run.by : personOf(session)
281
+ const m = session.say(out.text.slice(0, 3000), { to, via: persona.via })
282
+ if (m && m.id) run.said.push(m.id)
283
+ } catch {}
284
+ }
285
+ this.keepChat(run)
286
+ }
287
+
288
+ /**
289
+ * One plain line in chat for the person; the work stays where it was. A tool that isn't signed in is said so plainly, never with one tool's fix.
290
+ */
291
+ fail (session, run, why) {
292
+ const where = session.aiOf ? 'your cloud AI (Settings → Your AI)' : 'this computer'
293
+ const text = SIGNED_OUT.test(String(why)) ? `${run.tool || 'your AI tool'} isn't signed in on ${where}` : String(why).replace(/[.\s]+$/, '').slice(0, 300)
294
+ this.notice(session, `Couldn't run your AI for ${run.title || 'that work'}: ${text}. It's still waiting for your AI in your chat.`, run)
295
+ session.dropAutoPersona(run.via)
296
+ }
297
+
298
+ notice (session, text, run = null) {
299
+ try {
300
+ const rid = run ? run.via : 'autowork'
301
+ const p = session.autoPersona(rid, { tool: this.config().tool, label: 'on its own' })
302
+ session.say(text, { to: personOf(session), via: p.via })
303
+ if (!run) session.dropAutoPersona(rid)
304
+ } catch (err) { session.log(`Work on its own: ${text} (${err.message})`) }
305
+ }
306
+
307
+ // ------------------------------------------------------------ records --
308
+
309
+ file (name) { return path.join(this.dir, name) }
310
+
311
+ record (entry) {
312
+ try {
313
+ fs.mkdirSync(this.dir, { recursive: true })
314
+ fs.appendFileSync(this.file('runs.jsonl'), JSON.stringify(entry) + '\n')
315
+ } catch {}
316
+ }
317
+
318
+ /** Every logged run, oldest first. */
319
+ log () {
320
+ let text = ''
321
+ try { text = fs.readFileSync(this.file('runs.jsonl'), 'utf8') } catch { return [] }
322
+ return text.split('\n').map((l) => { try { return JSON.parse(l) } catch { return null } }).filter(Boolean)
323
+ }
324
+
325
+ /** For Settings: the config, today's totals per tool, and the latest runs. */
326
+ usage () {
327
+ const log = this.log()
328
+ const today = usageTotals(log)[day(this.now())] || {}
329
+ return { config: publicConfig(this.config()), today, runs: log.slice(-20).reverse() }
330
+ }
331
+
332
+ loadChats () {
333
+ try {
334
+ const list = JSON.parse(fs.readFileSync(this.file('chats.json'), 'utf8'))
335
+ return Array.isArray(list) ? list.filter((r) => r && this.now() - r.ts < KEEP_CHAT_MS) : []
336
+ } catch { return [] }
337
+ }
338
+
339
+ keepChat (run) {
340
+ this.chats = [...this.chats.filter((r) => r.id !== run.id), run].filter((r) => this.now() - r.ts < KEEP_CHAT_MS).slice(-KEEP_CHATS)
341
+ try {
342
+ fs.mkdirSync(this.dir, { recursive: true })
343
+ fs.writeFileSync(this.file('chats.json'), JSON.stringify(this.chats), { mode: 0o600 })
344
+ } catch {}
345
+ }
346
+
347
+ /** How the person carries a logged run's chat on in their tool: { link, command } or null. */
348
+ takeOver (runId) {
349
+ const e = [...this.log()].reverse().find((x) => x.run === runId && x.chatId)
350
+ const preset = e && autoToolFor(e.tool)
351
+ return preset ? preset.takeOver(e.chatId) : null
352
+ }
353
+ }
354
+
355
+ /** Whose AI a session works as: its person's, for a person's cloud AI; else its own member's. */
356
+ const personOf = (session) => session.aiOf || session.name
357
+
358
+ /** Whether "Work on its own" works for this session: a person's, or a person's cloud AI's. */
359
+ const worksFor = (session) => session.kind !== 'agent' || !!session.aiOf
360
+
361
+ function firstLine (s) {
362
+ return String(s || '').split('\n').map((l) => l.trim()).find(Boolean)?.slice(0, 300) || ''
363
+ }
364
+
365
+ /** The settings as Settings shows them. */
366
+ export function publicConfig (c) {
367
+ return { mode: c.mode, tool: c.tool, supported: !!c.preset, models: c.preset ? c.preset.models : [], model: c.model, changeFiles: c.changeFiles, limitsFiles: c.preset ? c.preset.limitsFiles : true, cap: c.cap }
368
+ }
369
+
370
+ let shared = null
371
+ /** The app's one AutoWork. */
372
+ export function autowork () {
373
+ shared ||= new AutoWork()
374
+ return shared
375
+ }
@@ -0,0 +1,99 @@
1
+ // Where the relay keeps large files. The files are already encrypted by the
2
+ // apps; the relay only hands out short-lived links and deletes what's unused.
3
+ import fs from 'node:fs'
4
+ import path from 'node:path'
5
+ import crypto from 'node:crypto'
6
+ import { createClient } from '@supabase/supabase-js'
7
+
8
+ const LINK_MS = 10 * 60 * 1000
9
+ const MAX_REMOVE_ROUNDS = 1000
10
+
11
+ /** Files on the relay's own disk, reached through signed links to the relay. */
12
+ export class DiskStore {
13
+ constructor (dir) {
14
+ this.dir = dir
15
+ this.signing = crypto.randomBytes(32)
16
+ }
17
+
18
+ file (room, id) { return path.join(this.dir, room, id) }
19
+
20
+ sign (room, id, method, exp, size) {
21
+ return crypto.createHmac('sha256', this.signing).update(`${method} ${room}/${id} ${exp} ${size ?? ''}`).digest('hex')
22
+ }
23
+
24
+ link (room, id, method, size) {
25
+ const exp = Date.now() + LINK_MS
26
+ const n = size === undefined ? '' : `&n=${size}`
27
+ return `/blobs/${room}/${id}/data?m=${method}&exp=${exp}&sig=${this.sign(room, id, method, exp, size)}${n}`
28
+ }
29
+
30
+ verify (room, id, method, exp, sig, size) {
31
+ if (!(Number(exp) > Date.now())) return false
32
+ const want = Buffer.from(this.sign(room, id, method, exp, size), 'hex')
33
+ const got = Buffer.from(String(sig || ''), 'hex')
34
+ return got.length === want.length && crypto.timingSafeEqual(got, want)
35
+ }
36
+
37
+ // The declared size is signed into the PUT link, so a client can't upload
38
+ // more than it told the relay it would; GET links carry no size.
39
+ async uploadTarget (room, id, size) { return { method: 'PUT', url: this.link(room, id, 'PUT', size) } }
40
+ async downloadTarget (room, id) { return { url: this.link(room, id, 'GET') } }
41
+ async remove (room, ids) { for (const id of ids) fs.rmSync(this.file(room, id), { force: true }) }
42
+ async removeRoom (room) { fs.rmSync(path.join(this.dir, room), { recursive: true, force: true }) }
43
+ }
44
+
45
+ /** Files in a private Supabase Storage bucket; apps upload and download directly. */
46
+ export class SupabaseStore {
47
+ constructor ({ url, key, bucket }) {
48
+ this.bucket = createClient(url, key, { auth: { persistSession: false, autoRefreshToken: false } }).storage.from(bucket)
49
+ }
50
+
51
+ static path (room, id) { return `${room}/${id}` }
52
+
53
+ // `size` is accepted but ignored: Supabase signed uploads can't bound size; the bucket's file_size_limit caps each object.
54
+ // Write-once: ids come from the content, so an upload never replaces what's stored.
55
+ async uploadTarget (room, id, size) {
56
+ const { data, error } = await this.bucket.createSignedUploadUrl(SupabaseStore.path(room, id), { upsert: false })
57
+ // Storage may refuse to sign at all when the file is already there.
58
+ if (error && alreadyExists(error)) return { exists: true }
59
+ if (error) throw new Error(`storage: ${error.message}`)
60
+ return { method: 'PUT', url: data.signedUrl }
61
+ }
62
+
63
+ async downloadTarget (room, id) {
64
+ const { data, error } = await this.bucket.createSignedUrl(SupabaseStore.path(room, id), LINK_MS / 1000)
65
+ if (error) throw new Error(`storage: ${error.message}`)
66
+ return { url: data.signedUrl }
67
+ }
68
+
69
+ /** Deletes stored files; resolves to how many were deleted. */
70
+ async remove (room, ids) {
71
+ if (!ids.length) return 0
72
+ const { data, error } = await this.bucket.remove(ids.map((id) => SupabaseStore.path(room, id)))
73
+ if (error) throw new Error(`storage: ${error.message}`)
74
+ return (data || []).length
75
+ }
76
+
77
+ // Deleted 1000 at a time. A round that deletes nothing (say, something
78
+ // that isn't a file is in the way) stops it, rather than looping forever.
79
+ async removeRoom (room) {
80
+ for (let round = 0; round < MAX_REMOVE_ROUNDS; round++) {
81
+ const { data, error } = await this.bucket.list(room, { limit: 1000 })
82
+ if (error) throw new Error(`storage: ${error.message}`)
83
+ if (!data.length) return
84
+ if (!await this.remove(room, data.map((f) => f.name))) throw new Error(`storage: could not delete the stored files of ${room}; ${data.length} remain`)
85
+ }
86
+ throw new Error(`storage: gave up deleting the stored files of ${room} after ${MAX_REMOVE_ROUNDS} rounds`)
87
+ }
88
+ }
89
+
90
+ const alreadyExists = (error) => String(error.statusCode) === '409' || /exists|duplicate/i.test(String(error.message))
91
+
92
+ export function makeStore (cfg, dir) {
93
+ // Half set up is a mistake: falling back to the disk would quietly fill it.
94
+ if (!cfg.storageUrl !== !cfg.storageKey) {
95
+ throw new Error('Large-file storage is half set up: set both QUILT_STORAGE_URL and QUILT_STORAGE_KEY to use Supabase Storage, or neither to keep files on the relay\'s disk.')
96
+ }
97
+ if (cfg.storageUrl && cfg.storageKey) return new SupabaseStore({ url: cfg.storageUrl, key: cfg.storageKey, bucket: cfg.storageBucket })
98
+ return new DiskStore(dir)
99
+ }
package/src/boxes.js ADDED
@@ -0,0 +1,37 @@
1
+ // Boxes as the app shows them (docs/superpowers/specs/2026-10-16-git-boxes-design.md): a box is
2
+ // someone's unfinished work on one file. For each, what it changes from the version the viewer's
3
+ // folder has (theirs: from before the box; ours: what git last committed), counted line by line.
4
+ import { lineDiff } from './history.js'
5
+
6
+ const isBin = (v) => !!v && typeof v === 'object' && v.binary === true
7
+ const textOf = (v) => (v == null || isBin(v) ? '' : String(v))
8
+
9
+ /**
10
+ * One box for the app. `before` and `after` are the file's text, null (no file) or { binary: true }.
11
+ * The diff is capped as history's are; added and removed count every line all the same.
12
+ */
13
+ export function boxEntry ({ claim, mine = false, before = null, after = null }) {
14
+ const kind = before == null && after != null ? 'new' : after == null && before != null ? 'deleted' : isBin(before) || isBin(after) ? 'binary' : 'edited'
15
+ let added = 0
16
+ let removed = 0
17
+ let diff = ''
18
+ if (kind !== 'binary') {
19
+ diff = lineDiff(textOf(before), textOf(after))
20
+ for (const line of lineDiff(textOf(before), textOf(after), { context: 0, max: Infinity }).split('\n')) {
21
+ if (line.startsWith('+')) added++
22
+ else if (line.startsWith('-')) removed++
23
+ }
24
+ }
25
+ return {
26
+ path: claim.pattern,
27
+ by: claim.by,
28
+ ...(claim.of ? { of: claim.of } : {}),
29
+ ts: claim.ts || 0,
30
+ mine: !!mine,
31
+ kind,
32
+ added,
33
+ removed,
34
+ diff,
35
+ queue: Array.isArray(claim.queue) ? claim.queue.map((r) => ({ id: r.id, by: r.by })) : []
36
+ }
37
+ }