@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.
- package/LICENSE.md +34 -0
- package/NOTICES.md +33 -0
- package/README.md +109 -3
- package/harnesses/README.md +213 -0
- package/harnesses/antigravity.mjs +269 -0
- package/harnesses/claude-code.mjs +578 -0
- package/harnesses/codex.mjs +419 -0
- package/harnesses/cursor.mjs +199 -0
- package/harnesses/hermes.mjs +211 -0
- package/harnesses/index.mjs +36 -0
- package/harnesses/kilocode.mjs +361 -0
- package/harnesses/opencode.mjs +449 -0
- package/launch/index.mjs +163 -0
- package/launch/macos.mjs +21 -0
- package/launch/registry.mjs +52 -0
- package/launch/terminal.mjs +213 -0
- package/launch/win-terminal.mjs +87 -0
- package/launch/windows.mjs +96 -0
- package/launch/xdg.mjs +44 -0
- package/lib/anthropic-prices.mjs +70 -0
- package/lib/claude-desktop.mjs +208 -0
- package/lib/claude-transcript.mjs +462 -0
- package/lib/cursor-folder.mjs +107 -0
- package/lib/fsutil.mjs +191 -0
- package/lib/hermes-home.mjs +30 -0
- package/lib/jsonl-follower.mjs +45 -0
- package/lib/opencode-store.mjs +602 -0
- package/lib/sqlite.mjs +29 -0
- package/lib/telemetry-fields.mjs +245 -0
- package/license/index.mjs +102 -0
- package/license/store.mjs +155 -0
- package/orgs/index.mjs +157 -0
- package/package.json +40 -4
- package/telemetry/README.md +148 -0
- package/telemetry/antigravity.mjs +268 -0
- package/telemetry/claude-code.mjs +370 -0
- package/telemetry/codex.mjs +556 -0
- package/telemetry/cursor.mjs +276 -0
- package/telemetry/hermes.mjs +404 -0
- package/telemetry/index.mjs +9 -0
- package/telemetry/kilocode.mjs +31 -0
- 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
|
+
}
|