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
@@ -0,0 +1,404 @@
1
+ // Reads Cursor's AI chat ("composer") history for one project folder. Cursor
2
+ // keeps it in SQLite: the workspace's state.vscdb (and, in newer versions, the
3
+ // global ItemTable or the composerHeaders table) lists the conversations, and
4
+ // the global cursorDiskKV table holds each conversation's message order and
5
+ // each message ("bubble"). Opened read-only.
6
+ //
7
+ // Cursor writes these databases many times a second while it streams a reply,
8
+ // so reads can briefly fail with "database is locked". Those are retried on
9
+ // the next poll; only a storage layout we don't recognize stops the reader.
10
+ import fs from 'node:fs'
11
+ import os from 'node:os'
12
+ import path from 'node:path'
13
+ import { fileURLToPath } from 'node:url'
14
+ import { describeAction } from './actions.js'
15
+ import { capText } from './common.js'
16
+
17
+ const POLL_MS = 1000
18
+ const WORKING_MS = 10000
19
+ const BACKFILL_MS = 60 * 60 * 1000
20
+ const BACKFILL_ENTRIES = 50
21
+ const SETTLE_MS = 2500 // a still-streaming bubble is shared once it stops changing
22
+ const ACTIVE_COUNT = 6 // most recent conversations read on every change
23
+ const HOT_MS = 30 * 60 * 1000 // conversations that changed this recently are always read
24
+ const RESCAN_MS = 30 * 1000 // look for (new) Cursor workspaces for this folder
25
+ const BUSY_TIMEOUT_MS = 200 // node:sqlite is synchronous: never block the process for long
26
+ const BUSY_LOG_AFTER = 30 // consecutive locked polls before saying so
27
+
28
+ export function cursorUserDir (home = os.homedir()) {
29
+ if (process.platform === 'darwin') return path.join(home, 'Library', 'Application Support', 'Cursor', 'User')
30
+ if (process.platform === 'win32') return path.join(process.env.APPDATA || path.join(home, 'AppData', 'Roaming'), 'Cursor', 'User')
31
+ return path.join(home, '.config', 'Cursor', 'User')
32
+ }
33
+
34
+ const caseless = process.platform === 'darwin' || process.platform === 'win32'
35
+ function norm (p) {
36
+ let r = path.resolve(p)
37
+ try { r = fs.realpathSync.native(r) } catch {}
38
+ r = r.replace(/[\\/]+$/, '') || r
39
+ return caseless ? r.toLowerCase() : r
40
+ }
41
+
42
+ /** Local folder a workspace/URI value points at, or null (remote, multi-root, unparseable). */
43
+ function uriPath (u) {
44
+ if (!u) return null
45
+ if (typeof u === 'object') u = u.fsPath || u.external || (u.scheme === 'file' ? u.path : null)
46
+ if (typeof u !== 'string') return null
47
+ if (u.startsWith('file:')) { try { return fileURLToPath(u) } catch { return null } }
48
+ return path.isAbsolute(u) ? u : null
49
+ }
50
+
51
+ /** Every workspaceStorage folder Cursor uses for `dir`, most recently used first. */
52
+ export function findWorkspaces (userDir, dir) {
53
+ const root = path.join(userDir, 'workspaceStorage')
54
+ const want = norm(dir)
55
+ let names = []
56
+ try { names = fs.readdirSync(root) } catch { return [] }
57
+ const found = []
58
+ for (const n of names) {
59
+ try {
60
+ const ws = JSON.parse(fs.readFileSync(path.join(root, n, 'workspace.json'), 'utf8'))
61
+ const folder = uriPath(ws.folder)
62
+ if (!folder || norm(folder) !== want) continue
63
+ const db = path.join(root, n, 'state.vscdb')
64
+ found.push({ dir: path.join(root, n), mtime: fs.statSync(db).mtimeMs })
65
+ } catch {}
66
+ }
67
+ return found.sort((a, b) => b.mtime - a.mtime).map((f) => f.dir)
68
+ }
69
+
70
+ /** The workspaceStorage folder Cursor uses for `dir`, or null. */
71
+ export function findWorkspace (userDir, dir) {
72
+ return findWorkspaces(userDir, dir)[0] || null
73
+ }
74
+
75
+ export function startCursorReader ({ dir, chatDir, onEntries, onState, onLog = () => {}, userDir, pollMs = POLL_MS, settleMs = SETTLE_MS, now = Date.now }) {
76
+ dir = path.resolve(dir)
77
+ const chatRoot = path.resolve(chatDir || dir) // the folder Cursor has open
78
+ userDir = userDir || cursorUserDir()
79
+ let stopped = false
80
+ let timer = null
81
+ let status = null
82
+ let DatabaseSync = null
83
+ let globalDb = null
84
+ const workspaces = new Map() // workspace folder -> db
85
+ let lastScan = 0
86
+ let first = true
87
+ let lastChangeAt = 0
88
+ let running = false // a conversation with unfinishedRunAt is still thinking or working
89
+ let busyCount = 0
90
+ const versions = new Map() // db -> last PRAGMA data_version
91
+ // composerId -> { emitted: Set<bubbleId>, pending: Map<bubbleId, { sig, since, entries }>, listTs, seenAt }
92
+ const composers = new Map()
93
+
94
+ const setState = (s, reason) => {
95
+ if (s === status) return
96
+ status = s
97
+ onState({ tool: 'Cursor', status: s, ...(reason ? { reason } : {}) })
98
+ }
99
+
100
+ const fail = (reason) => {
101
+ setState('unavailable', reason)
102
+ onLog(`Cursor feed unavailable: ${reason}`)
103
+ stop()
104
+ }
105
+
106
+ function closeDbs () {
107
+ for (const db of [globalDb, ...workspaces.values()]) { try { db && db.close() } catch {} }
108
+ globalDb = null
109
+ workspaces.clear()
110
+ versions.clear()
111
+ }
112
+
113
+ function stop () {
114
+ stopped = true
115
+ clearTimeout(timer)
116
+ closeDbs()
117
+ }
118
+
119
+ const openDb = (file) => {
120
+ const db = new DatabaseSync(file, { readOnly: true })
121
+ db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`)
122
+ return db
123
+ }
124
+
125
+ /** Opens any Cursor workspace for this folder not opened yet. False if there is none. */
126
+ function scan (t) {
127
+ lastScan = t
128
+ for (const ws of findWorkspaces(userDir, chatRoot)) {
129
+ if (!workspaces.has(ws)) workspaces.set(ws, openDb(path.join(ws, 'state.vscdb')))
130
+ }
131
+ if (!workspaces.size) return false
132
+ if (!globalDb) globalDb = openDb(path.join(userDir, 'globalStorage', 'state.vscdb'))
133
+ return true
134
+ }
135
+
136
+ async function init () {
137
+ // node:sqlite prints an "experimental" warning on first import; keep the terminal clean.
138
+ const emit = process.emitWarning
139
+ process.emitWarning = (w, ...rest) => { if (!/SQLite/.test(String(w && w.message || w))) emit.call(process, w, ...rest) }
140
+ try {
141
+ ({ DatabaseSync } = await import('node:sqlite'))
142
+ } catch {
143
+ return fail('reading Cursor chats needs Node.js 22.13 or newer')
144
+ } finally {
145
+ process.emitWarning = emit
146
+ }
147
+ setState('idle')
148
+ const loop = () => {
149
+ if (stopped) return
150
+ const t = now()
151
+ try {
152
+ if ((!workspaces.size || t - lastScan > RESCAN_MS) && !scan(t)) {
153
+ timer = setTimeout(loop, pollMs * 5) // Cursor hasn't opened this folder yet
154
+ return
155
+ }
156
+ poll(t)
157
+ busyCount = 0
158
+ } catch (err) {
159
+ if (!transient(err)) return fail(explain(err))
160
+ // Cursor is mid-write, or its files moved: try again with fresh handles.
161
+ closeDbs()
162
+ if (++busyCount === BUSY_LOG_AFTER) onLog(`Cursor feed: storage has been busy for a while (${err.message}); still trying`)
163
+ }
164
+ timer = setTimeout(loop, pollMs)
165
+ }
166
+ loop()
167
+ }
168
+
169
+ function readJson (db, table, key) {
170
+ const row = db.prepare(`SELECT value FROM ${table} WHERE key = ?`).get(key)
171
+ if (!row || row.value == null) return null
172
+ const v = row.value
173
+ return JSON.parse(typeof v === 'string' ? v : Buffer.from(v).toString('utf8'))
174
+ }
175
+
176
+ /** True if another process committed to `db` since the last check. */
177
+ function changed (db) {
178
+ const v = db.prepare('PRAGMA data_version').get()
179
+ const val = v && Object.values(v)[0]
180
+ const was = versions.get(db)
181
+ versions.set(db, val)
182
+ return was !== val
183
+ }
184
+
185
+ /** This folder's conversations: [{ id, ts }], plus the ones open in Cursor right now. */
186
+ function listComposers () {
187
+ const out = new Map()
188
+ const focused = new Set()
189
+ const add = (c) => {
190
+ const id = c && c.composerId
191
+ if (!id || c.isArchived) return
192
+ const ts = c.lastUpdatedAt || c.createdAt || 0
193
+ if (!out.has(id) || out.get(id) < ts) out.set(id, ts)
194
+ }
195
+ const ids = new Set()
196
+ for (const [ws, db] of workspaces) {
197
+ ids.add(path.basename(ws))
198
+ const list = readJson(db, 'ItemTable', 'composer.composerData')
199
+ if (!list) continue
200
+ if (Array.isArray(list.allComposers)) list.allComposers.forEach(add)
201
+ for (const k of ['selectedComposerIds', 'lastFocusedComposerIds']) {
202
+ if (Array.isArray(list[k])) for (const id of list[k]) if (typeof id === 'string') focused.add(id)
203
+ }
204
+ }
205
+ // Newer Cursor versions keep the list globally, tagged with the workspace.
206
+ let headers = null
207
+ try { headers = readJson(globalDb, 'ItemTable', 'composer.composerHeaders') } catch (err) {
208
+ if (!/no such table/i.test(err.message)) throw err
209
+ }
210
+ if (headers && Array.isArray(headers.allComposers)) {
211
+ const want = norm(chatRoot)
212
+ for (const c of headers.allComposers) {
213
+ const w = c && c.workspaceIdentifier
214
+ if (!w) continue
215
+ const folder = uriPath(w.uri) || uriPath(w.folder) || uriPath(w.configPath)
216
+ if (ids.has(w.id) || (folder && norm(folder) === want)) add(c)
217
+ }
218
+ }
219
+ for (const id of focused) if (!out.has(id)) out.set(id, 0)
220
+ // Current Cursor keeps the list in composerHeaders. unfinishedRunAt stays
221
+ // set through a long think, which often writes no new message. Status uses
222
+ // that flag; the transcript reader still shares the messages.
223
+ const unfinished = headerRuns(ids)
224
+ return { list: [...out].map(([id, ts]) => ({ id, ts })), focused, unfinished }
225
+ }
226
+
227
+ function headerRuns (ids) {
228
+ const unfinished = new Set()
229
+ if (!ids.size) return unfinished
230
+ let rows = []
231
+ try {
232
+ const marks = [...ids].map(() => '?').join(', ')
233
+ rows = globalDb.prepare(
234
+ `SELECT composerId, isArchived, value FROM composerHeaders WHERE workspaceId IN (${marks})`
235
+ ).all(...ids)
236
+ } catch (err) {
237
+ if (/no such table/i.test(err.message)) return unfinished
238
+ throw err
239
+ }
240
+ for (const row of rows) {
241
+ if (!row || !row.composerId || row.isArchived) continue
242
+ const meta = headerMeta(row.value)
243
+ if (meta && meta.unfinishedRunAt) unfinished.add(row.composerId)
244
+ }
245
+ return unfinished
246
+ }
247
+
248
+ function poll (t) {
249
+ let dirty = first
250
+ for (const db of [globalDb, ...workspaces.values()]) if (changed(db)) dirty = true
251
+ const out = []
252
+
253
+ if (!dirty) {
254
+ // Nothing was written: only let streaming bubbles that went quiet settle.
255
+ for (const st of composers.values()) settle(st, t, out)
256
+ } else {
257
+ const { list, focused, unfinished } = listComposers()
258
+ running = unfinished.size > 0
259
+ for (const { id, ts } of list) {
260
+ let st = composers.get(id)
261
+ if (!st) {
262
+ st = { emitted: new Set(), pending: new Map(), listTs: ts, seenAt: first ? 0 : t, isNew: !first }
263
+ composers.set(id, st)
264
+ if (first && t - ts > BACKFILL_MS) {
265
+ // Old conversation: remember what's there so only new messages are shared.
266
+ for (const h of headers(readJson(globalDb, 'cursorDiskKV', `composerData:${id}`))) st.emitted.add(h.bubbleId)
267
+ continue
268
+ }
269
+ } else if (ts !== st.listTs) {
270
+ st.listTs = ts
271
+ st.seenAt = t
272
+ }
273
+ }
274
+ const rank = (id) => { const st = composers.get(id); return Math.max(st.listTs, st.seenAt) }
275
+ const recent = list.map((c) => c.id).sort((a, b) => rank(b) - rank(a))
276
+ const active = new Set(recent.slice(0, ACTIVE_COUNT))
277
+ for (const id of recent) {
278
+ const st = composers.get(id)
279
+ if (focused.has(id) || unfinished.has(id) || st.pending.size || t - rank(id) < HOT_MS || (first && t - st.listTs <= BACKFILL_MS)) active.add(id)
280
+ }
281
+ for (const id of active) {
282
+ const fresh = readComposer(id, composers.get(id), t)
283
+ out.push(...(first ? fresh.slice(-BACKFILL_ENTRIES) : fresh))
284
+ }
285
+ }
286
+
287
+ first = false
288
+ setState(running || t - lastChangeAt < WORKING_MS ? 'working' : 'idle')
289
+ if (out.length) onEntries(out)
290
+ }
291
+
292
+ /** New entries from one conversation. Bubbles still being written wait until they settle. */
293
+ function readComposer (id, st, t) {
294
+ const data = readJson(globalDb, 'cursorDiskKV', `composerData:${id}`)
295
+ if (!data) return []
296
+ // The chat's name in Cursor's list: entries carry it, so the Loom and the feed name it the same way.
297
+ const title = typeof data.name === 'string' ? data.name.replace(/\s+/g, ' ').trim().slice(0, 80) : ''
298
+ const hs = headers(data)
299
+ const out = []
300
+ hs.forEach((h, i) => {
301
+ if (st.emitted.has(h.bubbleId)) return
302
+ const bubble = h.inline || readJson(globalDb, 'cursorDiskKV', `bubbleId:${id}:${h.bubbleId}`)
303
+ if (!bubble) return // listed before it was written; picked up next time
304
+ const backfill = first
305
+ const ts = backfill ? (Date.parse(bubble.createdAt) || st.listTs || t) : t
306
+ const entries = toEntries(id, h.bubbleId, bubble, ts, title)
307
+ if (backfill || (i < hs.length - 1 && entries.length)) {
308
+ // Something came after it, so it's finished.
309
+ if (!backfill && !st.pending.has(h.bubbleId)) touch(st, t)
310
+ st.pending.delete(h.bubbleId)
311
+ st.emitted.add(h.bubbleId)
312
+ out.push(...entries)
313
+ return
314
+ }
315
+ // The newest bubble (or one that's still empty) may still be streaming.
316
+ const sig = signature(bubble)
317
+ const p = st.pending.get(h.bubbleId)
318
+ if (!p || p.sig !== sig) {
319
+ st.pending.set(h.bubbleId, { sig, since: t, entries, ts })
320
+ touch(st, t)
321
+ } else {
322
+ p.entries = entries
323
+ }
324
+ })
325
+ settle(st, t, out)
326
+ return out
327
+ }
328
+
329
+ function settle (st, t, out) {
330
+ for (const [bubbleId, p] of st.pending) {
331
+ if (t - p.since < settleMs) continue
332
+ st.pending.delete(bubbleId)
333
+ st.emitted.add(bubbleId)
334
+ out.push(...p.entries.map((e) => ({ ...e, ts: t })))
335
+ }
336
+ }
337
+
338
+ function touch (st, t) {
339
+ st.seenAt = t
340
+ lastChangeAt = t
341
+ }
342
+
343
+ function toEntries (conv, bubbleId, b, ts, title = '') {
344
+ const base = { tool: 'Cursor', conv, ts, ...(title ? { title } : {}) }
345
+ const out = []
346
+ const text = typeof b.text === 'string' ? b.text.trim() : ''
347
+ if (b.type === 1 && text) out.push({ ...base, id: bubbleId, kind: 'prompt', text: capText(text) })
348
+ if (b.type === 2 && text) out.push({ ...base, id: bubbleId, kind: 'reply', text: capText(text) })
349
+ const tool = b.toolFormerData
350
+ if (tool && tool.name) {
351
+ let args = tool.params || tool.rawArgs || {}
352
+ if (typeof args === 'string') { try { args = JSON.parse(args) } catch { args = {} } }
353
+ out.push({ ...base, id: `${bubbleId}:tool`, kind: 'action', text: describeAction(tool.name, args, dir) })
354
+ }
355
+ return out
356
+ }
357
+
358
+ init().catch((err) => fail(explain(err)))
359
+ return { stop }
360
+ }
361
+
362
+ /** A conversation's bubbles in order: [{ bubbleId, inline? }]. */
363
+ function headers (data) {
364
+ if (!data) return []
365
+ if (Array.isArray(data.fullConversationHeadersOnly)) return data.fullConversationHeadersOnly.filter((h) => h && h.bubbleId)
366
+ // Older Cursor versions kept the bubbles inline.
367
+ if (Array.isArray(data.conversation)) return data.conversation.filter((b) => b && b.bubbleId).map((b) => ({ bubbleId: b.bubbleId, inline: b }))
368
+ throw new Error('unexpected composer layout (no fullConversationHeadersOnly)')
369
+ }
370
+
371
+ function headerMeta (value) {
372
+ if (value == null) return null
373
+ try {
374
+ const s = typeof value === 'string' ? value : Buffer.from(value).toString('utf8')
375
+ return JSON.parse(s)
376
+ } catch { return null }
377
+ }
378
+
379
+ function thinkingText (b) {
380
+ const t = b && b.thinking
381
+ if (typeof t === 'string') return t
382
+ if (t && typeof t.text === 'string') return t.text
383
+ return ''
384
+ }
385
+
386
+ function signature (b) {
387
+ const tool = b.toolFormerData || {}
388
+ const args = tool.params || tool.rawArgs || ''
389
+ // Thinking text and tool status change while a reply is still in progress,
390
+ // before any visible message text is written.
391
+ return `${b.type}|${(b.text || '').length}|${b.text || ''}|${thinkingText(b).length}|${tool.name || ''}|${tool.status || ''}|${typeof args === 'string' ? args : JSON.stringify(args)}`
392
+ }
393
+
394
+ function transient (err) {
395
+ const m = `${err && err.code} ${err && err.message}`
396
+ return /SQLITE_BUSY|SQLITE_LOCKED|database is locked|database table is locked|SQLITE_IOERR|disk I\/O error|SQLITE_CANTOPEN|unable to open|ENOENT/i.test(m)
397
+ }
398
+
399
+ function explain (err) {
400
+ const m = String(err && err.message || err)
401
+ if (/no such table/i.test(m)) return `Cursor's storage layout isn't recognized (${m})`
402
+ if (/JSON/i.test(m)) return `Cursor's storage layout isn't recognized (unreadable data)`
403
+ return m
404
+ }
@@ -0,0 +1,69 @@
1
+ // Starts every AI-chat reader for a folder and merges their output. Readers
2
+ // are best-effort: a failure in one never affects syncing or the others.
3
+ import { startClaudeCodeReader } from './claude-code.js'
4
+ import { startCursorReader } from './cursor.js'
5
+ import { startCursorTranscriptReader } from './cursor-transcripts.js'
6
+
7
+ const READERS = [
8
+ ['Claude Code', startClaudeCodeReader],
9
+ ['Cursor', startCursorReader],
10
+ ['Cursor', startCursorTranscriptReader]
11
+ ]
12
+
13
+ /**
14
+ * @returns {{ stop(): void }}
15
+ * onEntries(entries) entries: { id, tool, conv, kind: 'prompt'|'reply'|'action', text, ts, title? }
16
+ * (title: the tool's own name for the conversation, when it keeps one)
17
+ * onState(state) state: { tool, status: 'working'|'idle'|'unavailable', reason? }
18
+ * (one combined state: the tool that's working, else the last one active)
19
+ */
20
+ export function startAgentReaders ({ dir, onEntries, onState, onLog = () => {}, readers = READERS, ...opts }) {
21
+ const states = new Map()
22
+ let lastActive = null
23
+ let combined = ''
24
+
25
+ const report = () => {
26
+ const all = [...states.values()]
27
+ // Until a tool shows activity, don't claim anyone uses it.
28
+ const pick = all.find((s) => s.status === 'working') ||
29
+ (lastActive != null && states.get(lastActive)) ||
30
+ (all.length && all.every((s) => s.status === 'unavailable') ? all[0] : { tool: null, status: 'idle' })
31
+ const unavailable = all.filter((s) => s.status === 'unavailable')
32
+ const out = { ...pick }
33
+ if (pick.status !== 'unavailable' && unavailable.length) out.notes = unavailable.map((s) => `${s.tool}: ${s.reason}`)
34
+ const key = JSON.stringify(out)
35
+ if (key === combined) return
36
+ combined = key
37
+ onState(out)
38
+ }
39
+
40
+ const handles = []
41
+ // Two readers can share a tool name (Cursor's composer db and its transcripts).
42
+ // Keep their states apart so one going idle cannot hide the other still working.
43
+ readers.forEach(([tool, start], i) => {
44
+ const guard = (fn) => (...args) => { try { fn(...args) } catch (err) { onLog(`${tool} feed error: ${err.message}`) } }
45
+ try {
46
+ handles.push(start({
47
+ dir,
48
+ ...opts,
49
+ onLog,
50
+ onEntries: guard((entries) => {
51
+ if (!entries.length) return
52
+ // Do not pin lastActive from entries alone: Claude Code (and others) backfill
53
+ // history on start, which used to leave partners seeing "Claude Code idle"
54
+ // forever even when the person actually uses Cursor / xAI / Grok.
55
+ onEntries(entries)
56
+ report()
57
+ }),
58
+ onState: guard((s) => {
59
+ states.set(i, s)
60
+ if (s.status === 'working') lastActive = i
61
+ report()
62
+ })
63
+ }))
64
+ } catch (err) {
65
+ onLog(`${tool} feed could not start: ${err.message}`)
66
+ }
67
+ })
68
+ return { stop () { for (const h of handles) { try { h.stop() } catch {} } } }
69
+ }
@@ -0,0 +1,98 @@
1
+ // Signing a person's AI tool in on their cloud machine (src/ai-vm.js), with the tool's own login:
2
+ // one table for every tool, like autowork-tools.js, so no tool has a code path of its own. Quilt
3
+ // shows the person only what they need: the link to open, a device code to type there, and
4
+ // whether the page gives them a code to paste back. A token a tool prints stays on the machine.
5
+ import fs from 'node:fs'
6
+ import path from 'node:path'
7
+
8
+ /**
9
+ * Each tool: the command its login is (`exe`, `args`, `env`), what it prints (`urlHost`, a device
10
+ * `code`, a `token` it hands over to be kept), whether the person pastes a code back
11
+ * (`needsCode`), and how to tell it is signed in (`status`).
12
+ */
13
+ export const SIGN_IN = {
14
+ 'Claude Code': {
15
+ // setup-token makes a long-lived token for this machine; the page shows a code to paste back.
16
+ exe: ['claude'],
17
+ args: ['setup-token'],
18
+ env: {},
19
+ urlHost: /^(claude\.ai|console\.anthropic\.com)$/,
20
+ needsCode: true,
21
+ token: /sk-ant-oat01-[A-Za-z0-9_-]{20,}/,
22
+ status: { file: 'claude-token' }
23
+ },
24
+ Codex: {
25
+ exe: ['codex'],
26
+ args: ['login', '--device-auth'],
27
+ env: {},
28
+ urlHost: /^auth\.openai\.com$/,
29
+ code: /\b([A-Z0-9]{4}-[A-Z0-9]{4,6})\b/,
30
+ needsCode: false,
31
+ status: { args: ['login', 'status'] }
32
+ },
33
+ Cursor: {
34
+ exe: ['agent', 'cursor-agent'],
35
+ args: ['login'],
36
+ env: { NO_OPEN_BROWSER: '1' },
37
+ urlHost: /^(www\.)?cursor\.com$/,
38
+ needsCode: false,
39
+ status: { args: ['status'], ok: /logged in/i, notOk: /not logged in|log in|login required/i }
40
+ }
41
+ }
42
+
43
+ // eslint-disable-next-line no-control-regex
44
+ const ANSI = /\u001b\[[0-9;?]*[ -/]*[@-~]|\u001b\][^\u0007]*(\u0007|\u001b\\)|\u001b[()][A-Za-z0-9]|\u001b[=>78]|\r/g
45
+
46
+ /** Terminal output as plain text. */
47
+ export const plain = (text) => String(text || '').replace(ANSI, '')
48
+
49
+ /**
50
+ * What a tool's login printed so far, as the person needs it: { url, code, needsCode } and the
51
+ * `token` it handed over (kept on the machine, never sent anywhere).
52
+ */
53
+ export function readSignIn (tool, text) {
54
+ const t = SIGN_IN[tool]
55
+ const out = { url: '', code: '', needsCode: false, token: '' }
56
+ if (!t) return out
57
+ const s = plain(text)
58
+ for (const m of s.matchAll(/https:\/\/[^\s"'<>]+/g)) {
59
+ try {
60
+ const u = new URL(m[0].replace(/[).,;]+$/, ''))
61
+ if (t.urlHost.test(u.hostname)) { out.url = u.href; break }
62
+ } catch {}
63
+ }
64
+ if (t.code) {
65
+ // The code is printed near the link; a URL's own parts don't count.
66
+ const m = s.replace(/https:\/\/\S+/g, ' ').match(t.code)
67
+ if (m) out.code = m[1]
68
+ }
69
+ out.needsCode = !!(t.needsCode && out.url)
70
+ if (t.token) { const m = s.match(t.token); if (m) out.token = m[0] }
71
+ return out
72
+ }
73
+
74
+ /** Where a tool's token is kept on the machine (only Claude Code hands one over). */
75
+ export const tokenFile = (home, tool) => (SIGN_IN[tool]?.status?.file ? path.join(home, '.quilt', 'ai-vm', SIGN_IN[tool].status.file) : null)
76
+
77
+ /** Keeps a token on the machine's disk, readable by this user only. */
78
+ export function keepToken (home, tool, token) {
79
+ const file = tokenFile(home, tool)
80
+ if (!file || !token) return
81
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 })
82
+ fs.writeFileSync(file, token, { mode: 0o600 })
83
+ }
84
+
85
+ /** The kept token, or ''. */
86
+ export function keptToken (home, tool) {
87
+ const file = tokenFile(home, tool)
88
+ try { return file ? fs.readFileSync(file, 'utf8').trim() : '' } catch { return '' }
89
+ }
90
+
91
+ /** Whether a status command's answer says the tool is signed in. */
92
+ export function statusSaysSignedIn (tool, code, output) {
93
+ const st = SIGN_IN[tool]?.status
94
+ if (!st || !st.args) return false
95
+ const text = plain(output)
96
+ if (code !== 0 || (st.notOk && st.notOk.test(text))) return false
97
+ return st.ok ? st.ok.test(text) : true
98
+ }