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.
- package/LICENSE +85 -0
- package/README.md +630 -2
- package/RELEASES.md +733 -0
- package/assets/logo.svg +1 -0
- package/bin/quilt.js +816 -0
- package/desktop/main.js +315 -0
- package/package.json +163 -7
- package/src/account.js +563 -0
- package/src/admit-policy.js +32 -0
- package/src/agent-join.js +370 -0
- package/src/agent-task-workflow.js +176 -0
- package/src/agents/actions.js +85 -0
- package/src/agents/claude-code.js +180 -0
- package/src/agents/common.js +21 -0
- package/src/agents/cursor-transcripts.js +170 -0
- package/src/agents/cursor.js +404 -0
- package/src/agents/index.js +69 -0
- package/src/ai-vm-signin.js +98 -0
- package/src/ai-vm.js +380 -0
- package/src/api/access.js +75 -0
- package/src/api/activity.js +181 -0
- package/src/api/agent-auth.js +130 -0
- package/src/api/agent-placement.js +157 -0
- package/src/api/audit.js +83 -0
- package/src/api/auth.js +17 -0
- package/src/api/domains.js +30 -0
- package/src/api/file-paths.js +40 -0
- package/src/api/file-store.js +99 -0
- package/src/api/fly-machines.js +76 -0
- package/src/api/github-app.js +172 -0
- package/src/api/http.js +34 -0
- package/src/api/invite-email.js +53 -0
- package/src/api/issues.js +110 -0
- package/src/api/join-text.js +78 -0
- package/src/api/mailer.js +47 -0
- package/src/api/memory-store.js +1083 -0
- package/src/api/org-access.js +54 -0
- package/src/api/permissions.js +71 -0
- package/src/api/relay-cluster.js +251 -0
- package/src/api/relay-machines.js +60 -0
- package/src/api/routes/access-types.js +54 -0
- package/src/api/routes/agent-invites.js +115 -0
- package/src/api/routes/agents.js +157 -0
- package/src/api/routes/ai-vm.js +369 -0
- package/src/api/routes/github.js +380 -0
- package/src/api/routes/grants.js +70 -0
- package/src/api/routes/invites.js +188 -0
- package/src/api/routes/issues.js +47 -0
- package/src/api/routes/join.js +167 -0
- package/src/api/routes/members.js +110 -0
- package/src/api/routes/orgs.js +177 -0
- package/src/api/routes/relay-cluster.js +90 -0
- package/src/api/routes/relay.js +76 -0
- package/src/api/routes/session-invites.js +119 -0
- package/src/api/routes/sessions.js +92 -0
- package/src/api/routes/teams.js +132 -0
- package/src/api/routes/workspace-agents.js +332 -0
- package/src/api/routes/workspace-files.js +277 -0
- package/src/api/routes/workspace-invites.js +196 -0
- package/src/api/routes/workspaces.js +225 -0
- package/src/api/server.js +623 -0
- package/src/api/slugs.js +20 -0
- package/src/api/supabase-store.js +765 -0
- package/src/api/team-access.js +30 -0
- package/src/api/tokens.js +31 -0
- package/src/api/workspace-access.js +62 -0
- package/src/api/workspace-reach.js +60 -0
- package/src/autowork-tools.js +166 -0
- package/src/autowork.js +387 -0
- package/src/blobstore.js +99 -0
- package/src/boxes.js +37 -0
- package/src/branchdocs.js +235 -0
- package/src/branches.js +194 -0
- package/src/catchup.js +111 -0
- package/src/chat-archive.js +85 -0
- package/src/chat-links.js +923 -0
- package/src/clash.js +140 -0
- package/src/cloud-ai.js +50 -0
- package/src/commit-request.js +59 -0
- package/src/commit.js +213 -0
- package/src/connection.js +569 -0
- package/src/control.js +245 -0
- package/src/conversation.js +125 -0
- package/src/doctor.js +156 -0
- package/src/duties.js +303 -0
- package/src/editors.js +228 -0
- package/src/fsutil.js +87 -0
- package/src/git-hook.js +48 -0
- package/src/git.js +147 -0
- package/src/github-commit.js +320 -0
- package/src/gitignore.js +49 -0
- package/src/gitstate.js +900 -0
- package/src/history.js +307 -0
- package/src/hooks.js +371 -0
- package/src/hosted-agents.js +24 -0
- package/src/hosted-client.js +112 -0
- package/src/identity.js +99 -0
- package/src/inbox.js +237 -0
- package/src/integrations.js +221 -0
- package/src/key-cli.js +115 -0
- package/src/largefiles.js +91 -0
- package/src/legacy.js +25 -0
- package/src/mcp-proxy.js +40 -0
- package/src/mcp-room.js +32 -0
- package/src/mcp.js +1261 -0
- package/src/merge3.js +158 -0
- package/src/merges.js +148 -0
- package/src/pass-source.js +176 -0
- package/src/passes.js +64 -0
- package/src/pathrules.js +127 -0
- package/src/persona.js +101 -0
- package/src/presence.js +360 -0
- package/src/private-file.js +41 -0
- package/src/procs.js +70 -0
- package/src/project-rules.js +178 -0
- package/src/protocol.js +92 -0
- package/src/quiet-warnings.js +9 -0
- package/src/relay-ai-vm.js +82 -0
- package/src/relay-ask.js +141 -0
- package/src/relay-ci.js +111 -0
- package/src/relay-clash.js +597 -0
- package/src/relay-cluster.js +454 -0
- package/src/relay-commit.js +887 -0
- package/src/relay-filekey.js +46 -0
- package/src/relay-git.js +274 -0
- package/src/relay-githttp.js +137 -0
- package/src/relay-github.js +109 -0
- package/src/relay-lease.js +137 -0
- package/src/relay-lfs.js +37 -0
- package/src/relay-mcp.js +1679 -0
- package/src/relay-metrics.js +75 -0
- package/src/relay-outbound.js +1144 -0
- package/src/relay-sync.js +719 -0
- package/src/relay-turn-sync.js +307 -0
- package/src/relay-upstream.js +934 -0
- package/src/relay-webhooks.js +111 -0
- package/src/releases.js +131 -0
- package/src/report.js +129 -0
- package/src/runner.js +306 -0
- package/src/same-edit.js +18 -0
- package/src/server.js +4187 -0
- package/src/session-access.js +170 -0
- package/src/session-name.js +15 -0
- package/src/session.js +7361 -0
- package/src/settings.js +46 -0
- package/src/setup.js +184 -0
- package/src/shell-path.js +46 -0
- package/src/status.js +199 -0
- package/src/summarize.js +109 -0
- package/src/sync-words.js +42 -0
- package/src/task-comments.js +92 -0
- package/src/tasks.js +670 -0
- package/src/textdiff.js +20 -0
- package/src/turn-sync-plan.js +142 -0
- package/src/turn-sync.js +191 -0
- package/src/ui/access-form.js +37 -0
- package/src/ui/agent-env-key.js +76 -0
- package/src/ui/agent-guide.js +127 -0
- package/src/ui/agent-kinds.js +175 -0
- package/src/ui/agent-place.js +265 -0
- package/src/ui/app.css +2309 -0
- package/src/ui/app.js +542 -0
- package/src/ui/board.js +465 -0
- package/src/ui/boxes.js +76 -0
- package/src/ui/branches.js +111 -0
- package/src/ui/catchup.js +77 -0
- package/src/ui/changes.js +159 -0
- package/src/ui/chat.js +266 -0
- package/src/ui/cloud-ai-model.js +107 -0
- package/src/ui/cloud-ai.js +221 -0
- package/src/ui/common.js +431 -0
- package/src/ui/csv.js +21 -0
- package/src/ui/diffview.js +74 -0
- package/src/ui/feed-convs.js +39 -0
- package/src/ui/feed.js +174 -0
- package/src/ui/files.js +579 -0
- package/src/ui/fileview.js +123 -0
- package/src/ui/git-rule.js +60 -0
- package/src/ui/home.js +1323 -0
- package/src/ui/index.html +16 -0
- package/src/ui/invite.js +97 -0
- package/src/ui/invites.js +79 -0
- package/src/ui/loom-model.js +516 -0
- package/src/ui/loom.css +349 -0
- package/src/ui/loom.js +753 -0
- package/src/ui/mark.js +104 -0
- package/src/ui/merges.js +158 -0
- package/src/ui/rail-model.js +78 -0
- package/src/ui/rail.js +173 -0
- package/src/ui/releases.js +182 -0
- package/src/ui/schedule.js +156 -0
- package/src/ui/session.js +3394 -0
- package/src/ui/signin.js +75 -0
- package/src/ui/sounds.js +110 -0
- package/src/ui/tool-logo.js +86 -0
- package/src/ui/tree.js +276 -0
- package/src/ui/workspaces.js +542 -0
- package/src/ui-server.js +1272 -0
- package/src/update-check.js +67 -0
- package/src/upstream.js +155 -0
- package/src/webhooks.js +235 -0
- package/src/workspace-cli.js +62 -0
- package/src/workspace-tools.js +340 -0
- 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
|
+
}
|