@threadcrumbs/common 0.0.0-stage → 0.1.1

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 (42) hide show
  1. package/LICENSE.md +34 -0
  2. package/NOTICES.md +33 -0
  3. package/README.md +109 -3
  4. package/harnesses/README.md +213 -0
  5. package/harnesses/antigravity.mjs +269 -0
  6. package/harnesses/claude-code.mjs +578 -0
  7. package/harnesses/codex.mjs +419 -0
  8. package/harnesses/cursor.mjs +199 -0
  9. package/harnesses/hermes.mjs +211 -0
  10. package/harnesses/index.mjs +36 -0
  11. package/harnesses/kilocode.mjs +361 -0
  12. package/harnesses/opencode.mjs +449 -0
  13. package/launch/index.mjs +163 -0
  14. package/launch/macos.mjs +21 -0
  15. package/launch/registry.mjs +52 -0
  16. package/launch/terminal.mjs +213 -0
  17. package/launch/win-terminal.mjs +87 -0
  18. package/launch/windows.mjs +96 -0
  19. package/launch/xdg.mjs +44 -0
  20. package/lib/anthropic-prices.mjs +70 -0
  21. package/lib/claude-desktop.mjs +208 -0
  22. package/lib/claude-transcript.mjs +462 -0
  23. package/lib/cursor-folder.mjs +107 -0
  24. package/lib/fsutil.mjs +191 -0
  25. package/lib/hermes-home.mjs +30 -0
  26. package/lib/jsonl-follower.mjs +45 -0
  27. package/lib/opencode-store.mjs +602 -0
  28. package/lib/sqlite.mjs +29 -0
  29. package/lib/telemetry-fields.mjs +245 -0
  30. package/license/index.mjs +102 -0
  31. package/license/store.mjs +155 -0
  32. package/orgs/index.mjs +157 -0
  33. package/package.json +40 -4
  34. package/telemetry/README.md +148 -0
  35. package/telemetry/antigravity.mjs +268 -0
  36. package/telemetry/claude-code.mjs +370 -0
  37. package/telemetry/codex.mjs +556 -0
  38. package/telemetry/cursor.mjs +276 -0
  39. package/telemetry/hermes.mjs +404 -0
  40. package/telemetry/index.mjs +9 -0
  41. package/telemetry/kilocode.mjs +31 -0
  42. package/telemetry/opencode.mjs +36 -0
@@ -0,0 +1,419 @@
1
+ /**
2
+ * Harness adapter: Codex (OpenAI) — the desktop app, the VS Code extension and the CLI together.
3
+ *
4
+ * Two stores, deliberately merged rather than picked between, the same shape `claude-code.mjs`
5
+ * ended up in:
6
+ *
7
+ * - `~/.codex/state_<n>.sqlite` holds one row per thread — title, cwd, branch, model, effort,
8
+ * archived — which is everything the colony wants and none of it inferred.
9
+ * - `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl` holds the transcript, which is the only
10
+ * source for how big a thread is and whether it is mid-turn, and the only source at all for
11
+ * a session the database has not caught up with.
12
+ *
13
+ * They join on the session id, which appears in the row, in the rollout's filename and in its
14
+ * `session_meta` record. Reading only the database loses transcript size and any CLI session it
15
+ * has not indexed; reading only the transcripts means reconstructing metadata the database
16
+ * already has correct.
17
+ *
18
+ * Read-only, without exception, and the scan starts no subprocess. The only executable this
19
+ * module ever names is the `codex` binary on the user's own PATH, handed to the server as an argv
20
+ * for a terminal — nothing here runs anything. Codex has an archive of its own that only its CLI
21
+ * can set, so archiving here is recorded in the colony alone — see the note on archiving in
22
+ * `harnesses/README.md`.
23
+ */
24
+ import fsp from 'node:fs/promises'
25
+ import path from 'node:path'
26
+ import os from 'node:os'
27
+ import { exists, findExecutable, jsonLines, listDirs, listFiles, num, readHead, readTail } from '../lib/fsutil.mjs'
28
+
29
+ const HOME = os.homedir()
30
+ const CODEX_HOME = process.env.CODEX_HOME || path.join(HOME, '.codex')
31
+ const SESSIONS_DIR = path.join(CODEX_HOME, 'sessions')
32
+ const SESSION_INDEX = path.join(CODEX_HOME, 'session_index.jsonl')
33
+
34
+ const HEAD_BYTES = 128 * 1024
35
+ const TAIL_BYTES = 64 * 1024
36
+ /** Codex writes nothing when it is killed, so a stale `task_started` needs a time bound too. */
37
+ const ACTIVE_WINDOW_MS = 30 * 60 * 1000
38
+
39
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
40
+ const STATE_DB = /^state_(\d+)\.sqlite$/
41
+ const ROLLOUT_FILE = /^rollout-.{19}-([0-9a-f-]{36})(?:_[0-9a-f-]{36})?\.jsonl(\.zst)?$/i
42
+ const SPAWNED_SOURCES = new Set(['subagent', 'guardian_review', 'memory_consolidation'])
43
+ const NO_ROWS = { rows: new Map(), hidden: new Set() }
44
+ const NOISE = /<([a-z][\w-]*)(?:\s[^>]*)?>[\s\S]*?<\/\1>/g
45
+
46
+ /** Prefixed, per the contract in `harnesses/README.md`. */
47
+ const ID = (raw) => `codex:${raw}`
48
+
49
+ /**
50
+ * `node:sqlite` is imported lazily and its absence is survivable.
51
+ *
52
+ * It needs Node 22.13, which `package.json` asks for — but asking is not enforcing, and a top
53
+ * level import would take the whole server down on an older Node rather than costing one
54
+ * harness. This way the transcript half still works and `diagnostic()` explains the rest.
55
+ */
56
+ let sqlitePromise
57
+ const sqliteApi = () => (sqlitePromise ??= import('node:sqlite').catch(() => null))
58
+
59
+ /** The newest schema version, not the most recently touched WAL sibling. */
60
+ async function latestStateDatabase() {
61
+ let entries
62
+ try {
63
+ entries = await fsp.readdir(CODEX_HOME, { withFileTypes: true })
64
+ } catch {
65
+ return ''
66
+ }
67
+ return (
68
+ entries
69
+ .filter((e) => e.isFile() && STATE_DB.test(e.name))
70
+ .map((e) => ({ file: path.join(CODEX_HOME, e.name), version: Number(e.name.match(STATE_DB)[1]) }))
71
+ .sort((a, b) => b.version - a.version)[0]?.file || ''
72
+ )
73
+ }
74
+
75
+ /**
76
+ * Every column is probed before it is named.
77
+ *
78
+ * This is undocumented private state that changes shape between Codex versions — the filename is
79
+ * versioned precisely because it does. A `SELECT` naming a column that has gone throws and costs
80
+ * the whole harness its threads, so every column except `id` and `cwd` is selected only when it exists.
81
+ */
82
+ const column = (columns, name, fallback = "''") => (columns.has(name) ? `t.${name}` : fallback)
83
+
84
+ function timeExpr(columns, ms, secs) {
85
+ if (columns.has(ms) && columns.has(secs)) return `COALESCE(t.${ms}, t.${secs} * 1000)`
86
+ if (columns.has(ms)) return `t.${ms}`
87
+ if (columns.has(secs)) return `t.${secs} * 1000`
88
+ return '0'
89
+ }
90
+
91
+ /**
92
+ * The thread index, keyed by session id, and the ids of the threads a task spawned. Both are empty
93
+ * when there is no readable database.
94
+ */
95
+ async function databaseRows() {
96
+ const [file, sqlite] = await Promise.all([latestStateDatabase(), sqliteApi()])
97
+ if (!file || !sqlite?.DatabaseSync) return NO_ROWS
98
+
99
+ let db
100
+ try {
101
+ db = new sqlite.DatabaseSync(file, { readOnly: true })
102
+ } catch {
103
+ // A WAL database whose shared-memory file cannot be used refuses a read-only open. The
104
+ // transcripts still answer everything the colony needs to draw something.
105
+ return NO_ROWS
106
+ }
107
+ try {
108
+ const tables = new Set(
109
+ db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all().map((r) => r.name)
110
+ )
111
+ if (!tables.has('threads')) return NO_ROWS
112
+ const columns = new Set(db.prepare('PRAGMA table_info(threads)').all().map((r) => r.name))
113
+ if (!['id', 'cwd'].every((n) => columns.has(n))) return NO_ROWS
114
+
115
+ // Threads a task spawned are not conversations anybody had; each would stand on the map as
116
+ // an astronaut nobody talks to.
117
+ const children = new Set()
118
+ if (tables.has('thread_spawn_edges')) {
119
+ const edge = new Set(db.prepare('PRAGMA table_info(thread_spawn_edges)').all().map((r) => r.name))
120
+ if (edge.has('child_thread_id')) {
121
+ for (const r of db.prepare('SELECT child_thread_id FROM thread_spawn_edges').all()) {
122
+ if (typeof r.child_thread_id === 'string') children.add(r.child_thread_id)
123
+ }
124
+ }
125
+ }
126
+
127
+ const unread = new Set()
128
+ if (tables.has('thread_read_receipts')) {
129
+ const receipt = new Set(db.prepare('PRAGMA table_info(thread_read_receipts)').all().map((r) => r.name))
130
+ if (receipt.has('thread_id') && receipt.has('first_unread_turn')) {
131
+ for (const r of db.prepare('SELECT thread_id FROM thread_read_receipts WHERE first_unread_turn IS NOT NULL').all()) {
132
+ if (typeof r.thread_id === 'string') unread.add(r.thread_id)
133
+ }
134
+ }
135
+ }
136
+
137
+ const rows = db
138
+ .prepare(`
139
+ SELECT
140
+ t.id,
141
+ t.cwd,
142
+ ${column(columns, 'name')} AS name,
143
+ ${column(columns, 'title')} AS title,
144
+ ${column(columns, 'preview')} AS preview,
145
+ ${column(columns, 'first_user_message')} AS first_user_message,
146
+ ${column(columns, 'source')} AS source,
147
+ ${column(columns, 'thread_source')} AS thread_source,
148
+ ${column(columns, 'git_branch')} AS git_branch,
149
+ ${column(columns, 'model')} AS model,
150
+ ${column(columns, 'reasoning_effort')} AS reasoning_effort,
151
+ ${column(columns, 'rollout_path')} AS rollout_path,
152
+ ${column(columns, 'archived', '0')} AS archived,
153
+ ${timeExpr(columns, 'created_at_ms', 'created_at')} AS created_at_ms,
154
+ ${timeExpr(columns, 'updated_at_ms', 'updated_at')} AS updated_at_ms
155
+ FROM threads t
156
+ `)
157
+ .all()
158
+ .filter((r) => UUID.test(r.id || ''))
159
+ const spawned = (r) => children.has(r.id) || SPAWNED_SOURCES.has(r.thread_source)
160
+
161
+ return {
162
+ rows: new Map(rows.filter((r) => !spawned(r)).map((r) => [r.id, { ...r, unread: unread.has(r.id) }])),
163
+ hidden: new Set(rows.filter(spawned).map((r) => r.id)),
164
+ }
165
+ } catch {
166
+ return NO_ROWS
167
+ } finally {
168
+ try {
169
+ db.close()
170
+ } catch {
171
+ /* already gone */
172
+ }
173
+ }
174
+ }
175
+
176
+ /** Every rollout transcript on disk, keyed by the session id in its filename. */
177
+ async function scanRollouts() {
178
+ const byId = new Map()
179
+ for (const year of await listDirs(SESSIONS_DIR)) {
180
+ for (const month of await listDirs(year)) {
181
+ for (const day of await listDirs(month)) {
182
+ for (const file of await listFiles(day, (n) => ROLLOUT_FILE.test(n))) {
183
+ const [, id, compressed] = ROLLOUT_FILE.exec(path.basename(file))
184
+ if (!UUID.test(id)) continue
185
+ try {
186
+ const st = await fsp.stat(file)
187
+ const existing = byId.get(id)
188
+ if (existing && existing.mtime >= st.mtimeMs) continue
189
+ byId.set(id, { id, file, size: st.size, mtime: st.mtimeMs, compressed: Boolean(compressed) })
190
+ } catch {
191
+ /* vanished between listing and stat */
192
+ }
193
+ }
194
+ }
195
+ }
196
+ }
197
+ return byId
198
+ }
199
+
200
+ /** `thread_name` per session, when Codex has written one. Optional; transcripts are the truth. */
201
+ async function readIndex() {
202
+ const out = new Map()
203
+ try {
204
+ for (const row of jsonLines(await fsp.readFile(SESSION_INDEX, 'utf8'))) {
205
+ if (row?.id && UUID.test(row.id)) out.set(row.id, row)
206
+ }
207
+ } catch {
208
+ /* no index — every field it carries has another source */
209
+ }
210
+ return out
211
+ }
212
+
213
+ const clean = (v) => String(v || '').replace(/\s+/g, ' ').trim()
214
+
215
+ function contentText(content) {
216
+ if (typeof content === 'string') return content
217
+ if (!Array.isArray(content)) return ''
218
+ return content.map((p) => (typeof p === 'string' ? p : p?.text || p?.input_text || '')).filter(Boolean).join('\n')
219
+ }
220
+
221
+ function promptText(raw) {
222
+ const text = clean(String(raw || '').replace(NOISE, ' '))
223
+ return text && !text.startsWith('<') && !text.startsWith('# AGENTS.md') ? text : ''
224
+ }
225
+
226
+ /** What the head of a transcript knows about itself, for a session the database has not indexed. */
227
+ function readHeadMeta(records) {
228
+ const meta = { cwd: '', gitBranch: '', model: '', effort: '', createdAt: 0, prompt: '', typed: '', spawned: false }
229
+ for (const r of records) {
230
+ const p = r?.payload
231
+ if (!p || typeof p !== 'object') continue
232
+ if (r.type === 'session_meta') {
233
+ meta.cwd ||= p.cwd || ''
234
+ meta.gitBranch ||= p.git?.branch || ''
235
+ meta.createdAt ||= Date.parse(p.timestamp || r.timestamp || '') || 0
236
+ meta.spawned ||= SPAWNED_SOURCES.has(p.thread_source) || p.source?.subagent != null
237
+ } else if (r.type === 'turn_context') {
238
+ meta.cwd = p.cwd || meta.cwd
239
+ meta.model = p.model || meta.model
240
+ meta.effort = p.effort || meta.effort
241
+ } else if (r.type === 'response_item' && p.type === 'message' && p.role === 'user') {
242
+ meta.prompt ||= promptText(contentText(p.content))
243
+ } else if (r.type === 'event_msg' && p.type === 'user_message') {
244
+ meta.typed ||= promptText(p.message)
245
+ } else if (r.type === 'event_msg' && p.type === 'item_completed' && p.item?.type === 'UserMessage') {
246
+ meta.typed ||= promptText(contentText(p.item.content))
247
+ }
248
+ }
249
+ meta.prompt = meta.typed || meta.prompt
250
+ return meta
251
+ }
252
+
253
+ const TURN_STARTED = new Set(['task_started', 'turn_started'])
254
+ const TURN_ENDED = new Set(['task_complete', 'turn_complete', 'turn_aborted'])
255
+
256
+ /**
257
+ * The last lifecycle record. `task_started` with nothing after it is mid-turn; `turn_aborted` is
258
+ * somebody pressing escape, which is not an error and must not redden an astronaut's eyes.
259
+ */
260
+ function readLifecycle(records) {
261
+ let last = null
262
+ for (const r of records) {
263
+ const p = r?.payload
264
+ if (r?.type !== 'event_msg') continue
265
+ if (TURN_STARTED.has(p?.type)) last = { type: 'task_started', error: false }
266
+ else if (TURN_ENDED.has(p?.type)) last = { type: p.type === 'turn_aborted' ? 'turn_aborted' : 'task_complete', error: Boolean(p.error) }
267
+ }
268
+ return last
269
+ }
270
+
271
+ /** Parsing is kept against mtime and size, so an unchanged transcript is read once. */
272
+ const parseCache = new Map()
273
+ async function transcriptFacts(entry, needHead) {
274
+ const cached = parseCache.get(entry.id)
275
+ if (cached && cached.mtime === entry.mtime && cached.size === entry.size && (cached.head || !needHead)) {
276
+ return cached.facts
277
+ }
278
+ const facts = { lifecycle: null, meta: null }
279
+ if (entry.compressed) return facts
280
+ try {
281
+ facts.lifecycle = readLifecycle(jsonLines(await readTail(entry.file, TAIL_BYTES)))
282
+ if (needHead) facts.meta = readHeadMeta(jsonLines(await readHead(entry.file, HEAD_BYTES)))
283
+ } catch {
284
+ /* mid-write, or gone */
285
+ }
286
+ parseCache.set(entry.id, { mtime: entry.mtime, size: entry.size, head: needHead, facts })
287
+ return facts
288
+ }
289
+
290
+ function projectOf(cwd) {
291
+ const dir = typeof cwd === 'string' && path.isAbsolute(cwd) ? cwd : ''
292
+ return { projectPath: dir, project: dir ? path.basename(dir) : 'unknown' }
293
+ }
294
+
295
+ async function scanThreads() {
296
+ const [{ rows, hidden }, rollouts, index] = await Promise.all([databaseRows(), scanRollouts(), readIndex()])
297
+ const ids = new Set([...rows.keys(), ...rollouts.keys()])
298
+ const now = Date.now()
299
+ const out = []
300
+
301
+ for (const id of ids) {
302
+ if (hidden.has(id)) continue
303
+ const row = rows.get(id)
304
+ const entry = rollouts.get(id)
305
+ // The head is only worth reading for a session the database cannot describe.
306
+ const facts = entry ? await transcriptFacts(entry, !row) : { lifecycle: null, meta: null }
307
+ const meta = facts.meta || {}
308
+ if (!row && meta.spawned) continue
309
+
310
+ const cwd = row?.cwd || meta.cwd || ''
311
+ const { projectPath, project } = projectOf(cwd)
312
+ const prompt = clean(row?.preview || row?.first_user_message || meta.prompt || '')
313
+ const title = clean(row?.name) || clean(index.get(id)?.thread_name) || clean(row?.title) || prompt || 'Untitled thread'
314
+ const lastActivityAt = Math.max(num(row?.updated_at_ms), entry?.mtime || 0)
315
+
316
+ out.push({
317
+ id: ID(id),
318
+ title: title.slice(0, 120),
319
+ preview: prompt.slice(0, 240),
320
+ project,
321
+ projectPath,
322
+ // Codex has no worktree concept of its own, and guessing one from the path would put a
323
+ // branch name on a thread that never had one.
324
+ worktree: '',
325
+ cwd,
326
+ gitBranch: row?.git_branch || meta.gitBranch || '',
327
+ model: row?.model || meta.model || '',
328
+ effort: row?.reasoning_effort || meta.effort || '',
329
+ createdAt: num(row?.created_at_ms) || meta.createdAt || entry?.mtime || 0,
330
+ lastActivityAt,
331
+ // Codex records no focus history, so "have you looked at this" is unknowable — not false.
332
+ lastFocusedAt: 0,
333
+ unread: row?.unread === true,
334
+ running: facts.lifecycle?.type === 'task_started' && now - lastActivityAt < ACTIVE_WINDOW_MS,
335
+ hasError: facts.lifecycle?.type === 'task_complete' && facts.lifecycle.error,
336
+ starred: false,
337
+ routine: '',
338
+ prState: '',
339
+ archived: row?.archived === 1 || row?.archived === true,
340
+ // Bytes, like every other harness: the field is a shared log scale across the whole map,
341
+ // and a token count would make Codex buildings taller than Claude ones for the same work.
342
+ sizeBytes: entry?.size || 0,
343
+ source: row?.source === 'vscode' ? 'vscode' : 'cli',
344
+ canOpen: true,
345
+ ref: { sessionId: id, cwd },
346
+ })
347
+ }
348
+ return out
349
+ }
350
+
351
+ /**
352
+ * Where the `codex` CLI is, for a page that would rather have a terminal than the app. PATH
353
+ * first, then the places `npm i -g` and Homebrew put a binary that a server started from a
354
+ * launcher would not see — never inside an application bundle.
355
+ */
356
+ const CLI_DIRS = [
357
+ path.join(HOME, '.local', 'bin'),
358
+ path.join(HOME, '.npm-global', 'bin'),
359
+ '/opt/homebrew/bin',
360
+ '/usr/local/bin',
361
+ '/usr/bin',
362
+ ]
363
+ const cliBinary = () => findExecutable('codex', CLI_DIRS)
364
+
365
+ const APP = Object.freeze({ name: 'Codex', download: 'https://developers.openai.com/codex/app' })
366
+
367
+ /**
368
+ * `codex://threads/<id>` is registered by the Codex desktop app; the OS opener does the rest.
369
+ * `codex resume <id>` is the CLI's own way back into the same session, offered alongside for a
370
+ * page that prefers a terminal. Nothing is run here — the server decides whether it is.
371
+ */
372
+ async function openThread(ref) {
373
+ const { sessionId: id, cwd } = ref || {}
374
+ if (typeof id !== 'string' || !UUID.test(id)) {
375
+ return { ok: false, error: 'No openable Codex session id on that thread' }
376
+ }
377
+ const bin = await cliBinary()
378
+ const command = bin ? { argv: [bin, 'resume', id], cwd: typeof cwd === 'string' ? cwd : '' } : undefined
379
+ return { ok: true, url: `codex://threads/${id}`, command, app: APP }
380
+ }
381
+
382
+ async function newSession(dir) {
383
+ const bin = await cliBinary()
384
+ return {
385
+ ok: true,
386
+ url: `codex://threads/new?${new URLSearchParams({ path: dir })}`,
387
+ command: bin ? { argv: [bin], cwd: dir } : undefined,
388
+ app: APP,
389
+ }
390
+ }
391
+
392
+ /** Claim the machine if either store is there — a CLI-only install has no database. */
393
+ async function detect() {
394
+ return (await exists(SESSIONS_DIR)) || Boolean(await latestStateDatabase())
395
+ }
396
+
397
+ /**
398
+ * Why a present Codex might still look thin. Without this the Node case is invisible: the
399
+ * database is simply skipped, every thread loses its title and model, and nothing says why.
400
+ */
401
+ async function diagnostic() {
402
+ if (!(await latestStateDatabase())) return ''
403
+ if (!(await sqliteApi())?.DatabaseSync) {
404
+ return `Codex threads need Node 22.13 or newer for their titles and models (running ${process.versions.node})`
405
+ }
406
+ return ''
407
+ }
408
+
409
+ export default {
410
+ id: 'codex',
411
+ name: 'Codex',
412
+ app: APP,
413
+ detect,
414
+ diagnostic,
415
+ scanThreads,
416
+ openThread,
417
+ newSession,
418
+ paths: { CODEX_HOME, SESSIONS_DIR },
419
+ }
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Harness adapter: Cursor (Anysphere) — agent transcripts.
3
+ *
4
+ * Cursor writes one JSONL per agent session at
5
+ * `~/.cursor/projects/<encoded-cwd>/agent-transcripts/<uuid>/<uuid>.jsonl`. The records are
6
+ * plainer than most: `{ role, message }` for each turn and, in some versions, a
7
+ * `{ type: 'turn_ended', status }` marker closing each one. There is no title, no cwd, no model
8
+ * and no branch anywhere in the file — the encoded directory name and the first user message are
9
+ * the whole of the metadata.
10
+ *
11
+ * Not covered: the composer / sidebar threads. Their bodies are not in these files — the
12
+ * per-workspace `state.vscdb` holds only pane layout, and the global one is a couple of
13
+ * gigabytes on a working machine and open read-write by the editor. Reading that on a
14
+ * five-second poll is its own piece of work, and guessing at its shape would be worse than
15
+ * leaving it out and saying so.
16
+ *
17
+ * Read-only, no subprocess, and nothing is ever read from inside `Cursor.app`.
18
+ */
19
+ import fsp from 'node:fs/promises'
20
+ import path from 'node:path'
21
+ import os from 'node:os'
22
+ import { decodeProjectDir } from '../lib/cursor-folder.mjs'
23
+ import { exists, jsonLines, listDirs, listFiles, readHead, readTail } from '../lib/fsutil.mjs'
24
+
25
+ export { decodeFolder, encodeFolderName } from '../lib/cursor-folder.mjs'
26
+
27
+ const HOME = os.homedir()
28
+ const PROJECTS = process.env.THREADCRUMBS_CURSOR_PROJECTS_DIR || path.join(HOME, '.cursor', 'projects')
29
+ const TRANSCRIPTS = 'agent-transcripts'
30
+
31
+ const HEAD_BYTES = 96 * 1024
32
+ const TAIL_BYTES = 32 * 1024
33
+ /** Cursor writes nothing when it is killed, so an unclosed turn needs a time bound as well. */
34
+ const ACTIVE_WINDOW_MS = 30 * 60 * 1000
35
+
36
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
37
+ const APP = Object.freeze({ name: 'Cursor', download: 'https://cursor.com/downloads' })
38
+ const FLAT_TRANSCRIPT = /^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i
39
+
40
+ /** Prefixed, per the contract in `harnesses/README.md`. */
41
+ const ID = (raw) => `cursor:${raw}`
42
+
43
+ /**
44
+ * Cursor wraps a prompt in tags of its own — a timestamp, a note about attached images, the
45
+ * query itself. Only the query is something a person typed, and it is the only part worth
46
+ * putting on a card.
47
+ */
48
+ function userText(record) {
49
+ const parts = record?.message?.content
50
+ const raw = Array.isArray(parts)
51
+ ? parts.map((p) => (typeof p === 'string' ? p : p?.text || '')).join('\n')
52
+ : String(parts || '')
53
+ const query = /<user_query>\s*([\s\S]*?)\s*<\/user_query>/.exec(raw)
54
+ const text = query ? query[1] : raw.replace(/<[a-z_]+>[\s\S]*?<\/[a-z_]+>/gi, ' ')
55
+ return text.replace(/\s+/g, ' ').trim()
56
+ }
57
+
58
+ /** `Tuesday, Sep 8, 2026, 4:08 PM (UTC-7)` if the first turn carried one. */
59
+ function stamp(record) {
60
+ const parts = record?.message?.content
61
+ const raw = Array.isArray(parts) ? parts.map((p) => p?.text || '').join('\n') : ''
62
+ const m = /<timestamp>(.*?)<\/timestamp>/.exec(raw)
63
+ const t = m ? Date.parse(m[1].replace(/\s*\(UTC[^)]*\)\s*$/, '')) : NaN
64
+ return Number.isNaN(t) ? 0 : t
65
+ }
66
+
67
+ /** Every transcript on disk, with the project directory it sits under. */
68
+ async function scanTranscripts() {
69
+ const out = []
70
+ const add = async (id, file, projectDir) => {
71
+ try {
72
+ const st = await fsp.stat(file)
73
+ if (!st.size) return
74
+ out.push({ id, file, dirName: path.basename(projectDir), size: st.size, mtime: st.mtimeMs, born: st.birthtimeMs })
75
+ } catch {
76
+ /* vanished between listing and stat */
77
+ }
78
+ }
79
+ for (const projectDir of await listDirs(PROJECTS)) {
80
+ const root = path.join(projectDir, TRANSCRIPTS)
81
+ for (const sessionDir of await listDirs(root)) {
82
+ const id = path.basename(sessionDir)
83
+ if (!UUID.test(id)) continue
84
+ for (const file of await listFiles(sessionDir, (n) => n.endsWith('.jsonl'))) await add(id, file, projectDir)
85
+ }
86
+ for (const file of await listFiles(root, (n) => FLAT_TRANSCRIPT.test(n))) {
87
+ await add(FLAT_TRANSCRIPT.exec(path.basename(file))[1], file, projectDir)
88
+ }
89
+ }
90
+ return out
91
+ }
92
+
93
+ /** Parsing is kept against mtime and size, so an unchanged transcript is read once. */
94
+ const cache = new Map()
95
+ async function facts(entry) {
96
+ const hit = cache.get(entry.id)
97
+ if (hit && hit.mtime === entry.mtime && hit.size === entry.size) return hit.value
98
+ const value = { prompt: '', startedAt: 0, closed: true, modern: false, errored: false, modelsTurn: false }
99
+ try {
100
+ for (const r of jsonLines(await readHead(entry.file, HEAD_BYTES))) {
101
+ if (r?.role !== 'user') continue
102
+ value.prompt = userText(r)
103
+ value.startedAt = stamp(r)
104
+ break
105
+ }
106
+ const tail = jsonLines(await readTail(entry.file, TAIL_BYTES))
107
+ // Only some Cursor versions write `turn_ended`. Treating "no marker" as "mid-turn" would light
108
+ // up every unmarked thread on the map, so a file without markers is judged by its last record.
109
+ const ended = tail.filter((r) => r?.type === 'turn_ended')
110
+ value.modern = ended.length > 0
111
+ const last = tail[tail.length - 1]
112
+ value.closed = last?.type === 'turn_ended'
113
+ value.errored = ended.length > 0 && ended[ended.length - 1].status !== 'success'
114
+ const content = last?.message?.content
115
+ const calling = Array.isArray(content) && content.some((c) => c?.type === 'tool_use')
116
+ value.modelsTurn = last?.role === 'user' || (last?.role === 'assistant' && calling)
117
+ } catch {
118
+ /* mid-write, or gone */
119
+ }
120
+ cache.set(entry.id, { mtime: entry.mtime, size: entry.size, value })
121
+ return value
122
+ }
123
+
124
+ async function scanThreads() {
125
+ const entries = await scanTranscripts()
126
+ const now = Date.now()
127
+ const threads = []
128
+
129
+ for (const entry of entries) {
130
+ const f = await facts(entry)
131
+ const projectPath = await decodeProjectDir(entry.dirName)
132
+ const prompt = f.prompt
133
+ const fresh = now - entry.mtime < ACTIVE_WINDOW_MS
134
+ const running = fresh && (f.modern ? !f.closed : f.modelsTurn)
135
+ const handedBack = fresh && !running
136
+ threads.push({
137
+ id: ID(entry.id),
138
+ title: (prompt || 'Untitled thread').slice(0, 120),
139
+ preview: prompt.slice(0, 240),
140
+ project: path.basename(projectPath) || 'unknown',
141
+ projectPath,
142
+ // Cursor records no worktree, and inferring one from the path would put a branch name on
143
+ // a thread that never had one.
144
+ worktree: '',
145
+ cwd: projectPath,
146
+ gitBranch: '',
147
+ model: '',
148
+ effort: '',
149
+ createdAt: f.startedAt || entry.born || entry.mtime,
150
+ lastActivityAt: entry.mtime,
151
+ // No focus history, so "have you read this" is unknowable rather than false.
152
+ lastFocusedAt: 0,
153
+ unread: handedBack && !f.errored,
154
+ running,
155
+ hasError: f.errored,
156
+ starred: false,
157
+ routine: '',
158
+ prState: '',
159
+ archived: false,
160
+ sizeBytes: entry.size,
161
+ source: 'agent',
162
+ canOpen: Boolean(projectPath),
163
+ ref: { sessionId: entry.id, cwd: projectPath },
164
+ })
165
+ }
166
+ return threads
167
+ }
168
+
169
+ /**
170
+ * Cursor registers `cursor://`, but only for files and folders — nothing found so far addresses
171
+ * a single agent thread, and inventing a route would be a link that silently does nothing.
172
+ * So Open takes you to the repo in Cursor, which is one click from the thread, and the page
173
+ * is told that is what happened rather than left to wonder why the thread did not appear.
174
+ */
175
+ function openThread(ref) {
176
+ const shown = newSession(ref?.cwd)
177
+ if (!shown.ok) return { ok: false, error: 'Cursor has no link to a single thread, and this one has no folder to open either.' }
178
+ return { ...shown, note: 'Cursor has no link to a single thread — opened the repo in Cursor; pick it from the agent list.' }
179
+ }
180
+
181
+ /** `cursor://file/<abs>` is answered by the installed app; the OS opener does the finding. */
182
+ function newSession(dir) {
183
+ const abs = String(dir || '').replace(/\\/g, '/')
184
+ if (!abs.startsWith('/')) return { ok: false, error: 'That folder is not somewhere Cursor can open' }
185
+ return { ok: true, url: `cursor://file${abs.split('/').map(encodeURIComponent).join('/')}`, app: APP }
186
+ }
187
+
188
+ const detect = () => exists(PROJECTS)
189
+
190
+ export default {
191
+ id: 'cursor',
192
+ name: 'Cursor',
193
+ app: APP,
194
+ detect,
195
+ scanThreads,
196
+ openThread,
197
+ newSession,
198
+ paths: { PROJECTS },
199
+ }