@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,556 @@
1
+ import fsp from 'node:fs/promises'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+ import { exists, jsonLines, listDirs, listFiles } from '../lib/fsutil.mjs'
5
+ import { jsonlFollower } from '../lib/jsonl-follower.mjs'
6
+ import { columnsOf, tablesOf, withReadOnly } from '../lib/sqlite.mjs'
7
+ import {
8
+ DETAIL_LIMITS,
9
+ RECENT_MS,
10
+ activityDetail,
11
+ addFileChange,
12
+ clip,
13
+ clipBlock,
14
+ diffHunk,
15
+ emptyActivity,
16
+ endCall,
17
+ fileList,
18
+ keepNewest,
19
+ nextPlan,
20
+ number,
21
+ planItems,
22
+ planSummary,
23
+ relativePath,
24
+ settleCalls,
25
+ startCall,
26
+ text,
27
+ titleFromPrompt,
28
+ toolInput,
29
+ toolTarget,
30
+ } from '../lib/telemetry-fields.mjs'
31
+
32
+ const ACTIVE_WINDOW_MS = 30 * 60 * 1000
33
+ const SUBAGENT_WINDOW_MS = 10 * 60 * 1000
34
+ const ROLLOUT_FILE = /^rollout-.{19}-([0-9a-f-]{36})(?:_[0-9a-f-]{36})?\.jsonl$/i
35
+ const STATE_DB = /^state_(\d+)\.sqlite$/
36
+ const CALLS = new Set(['function_call', 'custom_tool_call', 'local_shell_call', 'tool_search_call'])
37
+ const OUTPUTS = new Set(['function_call_output', 'custom_tool_call_output', 'tool_search_output'])
38
+ const AGENT_TOOLS = new Set(['spawn_agent', 'wait_agent', 'send_input'])
39
+ const QUESTION_TOOLS = new Set(['request_user_input', 'request_permissions'])
40
+ const INTERNAL_SOURCES = new Set(['guardian_review', 'memory_consolidation'])
41
+ const PATCH_APPLIED = /Success\. Updated the following files/
42
+ const PATCH_FILE = /^\*\*\* (?:Update|Add|Delete) File: (.+)$/
43
+ const NOISE = /<([a-z][\w-]*)(?:\s[^>]*)?>[\s\S]*?<\/\1>/g
44
+
45
+ function contentText(content) {
46
+ if (typeof content === 'string') return content
47
+ if (!Array.isArray(content)) return ''
48
+ return content.map((p) => (typeof p === 'string' ? p : text(p?.text) || text(p?.input_text))).filter(Boolean).join('\n')
49
+ }
50
+
51
+ function promptText(raw) {
52
+ const prompt = text(raw).replace(NOISE, ' ').trim()
53
+ return prompt && !prompt.startsWith('<') && !prompt.startsWith('# AGENTS.md') ? prompt : ''
54
+ }
55
+
56
+ function parseArgs(raw) {
57
+ if (raw && typeof raw === 'object') return raw
58
+ try {
59
+ return JSON.parse(text(raw) || '{}')
60
+ } catch {
61
+ return null
62
+ }
63
+ }
64
+
65
+ function outputText(output) {
66
+ if (Array.isArray(output)) return contentText(output)
67
+ const raw = text(output)
68
+ try {
69
+ const parsed = JSON.parse(raw)
70
+ return typeof parsed?.output === 'string' ? parsed.output : raw
71
+ } catch {
72
+ return raw
73
+ }
74
+ }
75
+
76
+ function exitFailed(output) {
77
+ if (typeof output !== 'string') return null
78
+ try {
79
+ const code = JSON.parse(output)?.metadata?.exit_code
80
+ return Number.isFinite(code) ? code !== 0 : null
81
+ } catch {
82
+ return null
83
+ }
84
+ }
85
+
86
+ const patchFile = (patch) => patch.split('\n').map((line) => PATCH_FILE.exec(line)?.[1]).find(Boolean)?.trim() || ''
87
+
88
+ function callTarget(item, args, patch, cwd) {
89
+ if (patch) {
90
+ const file = patchFile(patch)
91
+ return file ? toolTarget({ path: file }, cwd) : ''
92
+ }
93
+ if (item.type === 'local_shell_call') return toolTarget({ command: item.action?.command }, cwd)
94
+ if (item.type === 'custom_tool_call') return toolTarget(text(item.input), cwd)
95
+ return toolTarget(args ?? parseArgs(item.arguments), cwd)
96
+ }
97
+
98
+ function callInput(item, args, patch) {
99
+ if (patch) return null
100
+ if (item.type === 'local_shell_call') return toolInput({ command: item.action?.command })
101
+ if (item.type === 'custom_tool_call') return toolInput(text(item.input))
102
+ return toolInput(args ?? parseArgs(item.arguments))
103
+ }
104
+
105
+ function latestCall(calls) {
106
+ let latest = null
107
+ for (const call of calls.values()) {
108
+ if (!latest || call.startedAt >= latest.startedAt) latest = { name: call.name, startedAt: call.startedAt }
109
+ }
110
+ return latest
111
+ }
112
+
113
+ function emptyRollout() {
114
+ return {
115
+ threadId: '',
116
+ parentId: '',
117
+ subagent: false,
118
+ internal: false,
119
+ cwd: '',
120
+ gitBranch: '',
121
+ model: '',
122
+ effort: '',
123
+ startedAt: 0,
124
+ lastAt: 0,
125
+ firstPrompt: '',
126
+ prompts: 0,
127
+ typed: false,
128
+ fallbackPrompt: '',
129
+ fallbackPrompts: 0,
130
+ open: false,
131
+ failed: false,
132
+ calls: new Map(),
133
+ patches: new Map(),
134
+ lines: { added: 0, removed: 0 },
135
+ tokens: null,
136
+ context: null,
137
+ plan: null,
138
+ history: [],
139
+ contextParts: null,
140
+ lastTotal: -1,
141
+ modelTokens: new Map(),
142
+ activity: emptyActivity(),
143
+ files: new Map(),
144
+ promptList: [],
145
+ fallbackPromptList: [],
146
+ errors: [],
147
+ }
148
+ }
149
+
150
+ function applyRecord(state, record) {
151
+ const at = Date.parse(record.timestamp)
152
+ if (at) {
153
+ if (!state.startedAt) state.startedAt = at
154
+ if (at > state.lastAt) state.lastAt = at
155
+ }
156
+ const payload = record.payload
157
+ if (!payload || typeof payload !== 'object') return
158
+ if (record.type === 'session_meta') applyMeta(state, payload)
159
+ else if (record.type === 'turn_context') applySettings(state, payload.model, payload.effort, payload.cwd)
160
+ else if (record.type === 'response_item') applyItem(state, payload, at || state.lastAt)
161
+ else if (record.type === 'event_msg') applyEvent(state, payload, at || state.lastAt)
162
+ }
163
+
164
+ function applyMeta(state, meta) {
165
+ const subagent = meta.source?.subagent
166
+ const spawn = subagent?.thread_spawn
167
+ state.threadId ||= text(meta.id).toLowerCase()
168
+ state.parentId ||= (text(meta.parent_thread_id) || text(spawn?.parent_thread_id)).toLowerCase()
169
+ state.subagent ||= meta.thread_source === 'subagent' || Boolean(spawn)
170
+ state.internal ||= INTERNAL_SOURCES.has(meta.thread_source) || (subagent != null && !spawn)
171
+ state.cwd ||= text(meta.cwd)
172
+ state.gitBranch ||= text(meta.git?.branch)
173
+ }
174
+
175
+ function applySettings(state, model, effort, cwd) {
176
+ if (model) state.model = text(model)
177
+ if (effort) state.effort = text(effort)
178
+ if (cwd) state.cwd = text(cwd)
179
+ }
180
+
181
+ function countPrompt(state, raw, at) {
182
+ const prompt = promptText(raw)
183
+ if (!prompt) return
184
+ state.prompts++
185
+ state.firstPrompt ||= prompt
186
+ keepNewest(state.promptList, { at, text: clipBlock(prompt, DETAIL_LIMITS.promptText) }, DETAIL_LIMITS.prompts)
187
+ }
188
+
189
+ function applyItem(state, item, at) {
190
+ if (item.type === 'message' && item.role === 'user') {
191
+ const prompt = promptText(contentText(item.content))
192
+ if (prompt) {
193
+ state.fallbackPrompts++
194
+ state.fallbackPrompt ||= prompt
195
+ keepNewest(state.fallbackPromptList, { at, text: clipBlock(prompt, DETAIL_LIMITS.promptText) }, DETAIL_LIMITS.prompts)
196
+ }
197
+ return
198
+ }
199
+ const id = text(item.call_id)
200
+ if (!id) return
201
+ if (CALLS.has(item.type)) {
202
+ let name = `${text(item.namespace)}${text(item.name)}`
203
+ if (item.type === 'local_shell_call') name = 'shell'
204
+ if (item.type === 'tool_search_call') name = 'tool_search'
205
+ state.calls.set(id, { name, startedAt: at })
206
+ const args = item.type === 'function_call' ? parseArgs(item.arguments) : null
207
+ if (item.type === 'function_call' && !item.namespace && item.name === 'update_plan') state.plan = nextPlan(state.plan, 'update_plan', args)
208
+ const patch = item.name === 'apply_patch' ? (item.type === 'custom_tool_call' ? text(item.input) : text(args?.input)) : ''
209
+ if (patch) state.patches.set(id, patch)
210
+ const file = patch ? patchFile(patch) : ''
211
+ startCall(state.activity, {
212
+ id,
213
+ name,
214
+ target: callTarget(item, args, patch, state.cwd),
215
+ input: callInput(item, args, patch),
216
+ path: file ? relativePath(file, state.cwd) : null,
217
+ startedAt: at,
218
+ })
219
+ return
220
+ }
221
+ if (!OUTPUTS.has(item.type)) return
222
+ state.calls.delete(id)
223
+ const patch = state.patches.get(id)
224
+ if (patch === undefined) {
225
+ const failed = exitFailed(item.output)
226
+ endCall(state.activity, id, at, failed, failed ? outputText(item.output) : null)
227
+ return
228
+ }
229
+ state.patches.delete(id)
230
+ const output = outputText(item.output)
231
+ const applied = PATCH_APPLIED.test(output)
232
+ endCall(state.activity, id, at, !applied, applied ? null : output)
233
+ if (applied) addLines(state, patch)
234
+ }
235
+
236
+ function addLines(state, patch) {
237
+ const counts = new Map()
238
+ let entry = null
239
+ let hunk = null
240
+ for (const line of patch.split('\n')) {
241
+ const header = PATCH_FILE.exec(line)
242
+ if (header) {
243
+ const file = header[1].trim()
244
+ entry = counts.get(file) || { added: 0, removed: 0, hunks: [] }
245
+ counts.set(file, entry)
246
+ hunk = null
247
+ continue
248
+ }
249
+ if (line.startsWith('@@')) {
250
+ hunk = null
251
+ continue
252
+ }
253
+ if (line.startsWith('***')) continue
254
+ if (line.startsWith('+')) {
255
+ state.lines.added++
256
+ if (entry) entry.added++
257
+ } else if (line.startsWith('-')) {
258
+ state.lines.removed++
259
+ if (entry) entry.removed++
260
+ } else if (!line.startsWith(' ')) continue
261
+ if (!entry) continue
262
+ if (!hunk) {
263
+ hunk = []
264
+ entry.hunks.push(hunk)
265
+ }
266
+ hunk.push(line)
267
+ }
268
+ for (const [name, count] of counts) {
269
+ addFileChange(state.files, name, count.added, count.removed, count.hunks.map((lines) => diffHunk({ lines })))
270
+ }
271
+ }
272
+
273
+ function applyEvent(state, event, at) {
274
+ switch (event.type) {
275
+ case 'task_started':
276
+ case 'turn_started':
277
+ state.open = true
278
+ state.failed = false
279
+ state.calls.clear()
280
+ settleCalls(state.activity)
281
+ break
282
+ case 'task_complete':
283
+ case 'turn_complete':
284
+ state.open = false
285
+ state.failed = event.error != null
286
+ state.calls.clear()
287
+ settleCalls(state.activity)
288
+ if (event.error != null) {
289
+ const message = text(event.error?.message) || text(event.error) || 'The turn failed'
290
+ keepNewest(state.errors, { at, message: clip(message, DETAIL_LIMITS.errorText) }, DETAIL_LIMITS.errors)
291
+ }
292
+ break
293
+ case 'turn_aborted':
294
+ state.open = false
295
+ state.failed = false
296
+ state.calls.clear()
297
+ settleCalls(state.activity)
298
+ break
299
+ case 'token_count':
300
+ applyTokens(state, event.info, at)
301
+ break
302
+ case 'user_message':
303
+ state.typed = true
304
+ countPrompt(state, event.message, at)
305
+ break
306
+ case 'item_completed':
307
+ if (event.item?.type !== 'UserMessage') break
308
+ state.typed = true
309
+ countPrompt(state, contentText(event.item.content), at)
310
+ break
311
+ case 'thread_settings_applied': {
312
+ const settings = event.thread_settings
313
+ if (settings) applySettings(state, settings.model, settings.reasoning_effort, settings.cwd)
314
+ break
315
+ }
316
+ }
317
+ }
318
+
319
+ function applyTokens(state, info, at) {
320
+ if (!info) return
321
+ const total = info.total_token_usage
322
+ if (total) {
323
+ const cached = number(total.cached_input_tokens)
324
+ const next = {
325
+ input: Math.max(0, number(total.input_tokens) - cached),
326
+ output: number(total.output_tokens),
327
+ cacheRead: cached,
328
+ cacheWrite: number(total.cache_write_input_tokens),
329
+ }
330
+ if (state.model) {
331
+ const sums = state.modelTokens.get(state.model) || { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
332
+ for (const key of Object.keys(next)) sums[key] += Math.max(0, next[key] - (state.tokens?.[key] || 0))
333
+ state.modelTokens.set(state.model, sums)
334
+ }
335
+ state.tokens = next
336
+ }
337
+ const last = info.last_token_usage
338
+ if (!last) return
339
+ state.context = { used: number(last.total_tokens), limit: number(info.model_context_window) }
340
+ if (last.input_tokens != null) {
341
+ const cached = number(last.cached_input_tokens)
342
+ state.contextParts = { input: Math.max(0, number(last.input_tokens) - cached), cacheRead: cached, cacheWrite: 0, output: number(last.output_tokens) }
343
+ }
344
+ const sum = number(total?.total_tokens)
345
+ if (sum === state.lastTotal) return
346
+ state.lastTotal = sum
347
+ keepNewest(state.history, { at, context: number(last.total_tokens), output: number(last.output_tokens) }, DETAIL_LIMITS.history)
348
+ }
349
+
350
+ export function codex({
351
+ home = process.env.CODEX_HOME || path.join(os.homedir(), '.codex'),
352
+ sqliteHome = process.env.CODEX_SQLITE_HOME || home,
353
+ now = Date.now,
354
+ } = {}) {
355
+ const sessionsDir = path.join(home, 'sessions')
356
+ const locksDir = path.join(home, 'thread-writer-locks')
357
+ const indexFile = path.join(home, 'session_index.jsonl')
358
+ const rollouts = jsonlFollower({ empty: emptyRollout, apply: applyRecord })
359
+ let index = { mtime: -1, names: new Map() }
360
+ let known = new Map()
361
+ let childrenOf = new Map()
362
+
363
+ async function findRollouts(locks) {
364
+ const cutoff = now() - RECENT_MS
365
+ const byId = new Map()
366
+ for (const year of await listDirs(sessionsDir)) {
367
+ for (const month of await listDirs(year)) {
368
+ for (const day of await listDirs(month)) {
369
+ for (const file of await listFiles(day, (n) => ROLLOUT_FILE.test(n))) {
370
+ const id = ROLLOUT_FILE.exec(path.basename(file))[1].toLowerCase()
371
+ const stat = await fsp.stat(file).catch(() => null)
372
+ if (!stat || (stat.mtimeMs < cutoff && !locks.has(id))) continue
373
+ const existing = byId.get(id)
374
+ if (existing && existing.stat.mtimeMs >= stat.mtimeMs) continue
375
+ byId.set(id, { id, file, stat })
376
+ }
377
+ }
378
+ }
379
+ }
380
+ return [...byId.values()]
381
+ }
382
+
383
+ async function latestStateDb() {
384
+ const files = await listFiles(sqliteHome, (n) => STATE_DB.test(n))
385
+ return files.sort((a, b) => Number(STATE_DB.exec(path.basename(b))[1]) - Number(STATE_DB.exec(path.basename(a))[1]))[0] || ''
386
+ }
387
+
388
+ async function threadRecords() {
389
+ const stat = await fsp.stat(indexFile).catch(() => null)
390
+ if (stat && stat.mtimeMs !== index.mtime) {
391
+ const names = new Map()
392
+ for (const row of jsonLines(await fsp.readFile(indexFile, 'utf8').catch(() => ''))) {
393
+ if (row?.id && row.thread_name) names.set(String(row.id).toLowerCase(), text(row.thread_name))
394
+ }
395
+ index = { mtime: stat.mtimeMs, names }
396
+ }
397
+ const names = new Map(index.names)
398
+ const archived = new Set()
399
+ const read = new Map()
400
+ await withReadOnly(await latestStateDb(), (db) => {
401
+ const tables = tablesOf(db)
402
+ if (!tables.has('threads')) return
403
+ const columns = columnsOf(db, 'threads')
404
+ if (columns.has('name')) {
405
+ for (const row of db.prepare("SELECT id, name FROM threads WHERE name IS NOT NULL AND name != ''").all()) {
406
+ names.set(String(row.id).toLowerCase(), text(row.name))
407
+ }
408
+ }
409
+ if (columns.has('archived')) {
410
+ for (const row of db.prepare('SELECT id FROM threads WHERE archived = 1').all()) archived.add(String(row.id).toLowerCase())
411
+ }
412
+ if (tables.has('thread_read_receipts') && ['thread_id', 'first_unread_turn'].every((c) => columnsOf(db, 'thread_read_receipts').has(c))) {
413
+ for (const row of db.prepare('SELECT thread_id, first_unread_turn FROM thread_read_receipts').all()) {
414
+ read.set(String(row.thread_id).toLowerCase(), row.first_unread_turn != null)
415
+ }
416
+ }
417
+ })
418
+ return { names, archived, read }
419
+ }
420
+
421
+ async function scanSessions() {
422
+ const at = now()
423
+ const locking = await exists(locksDir)
424
+ const locks = new Set((await listFiles(locksDir, (n) => n.endsWith('.lock'))).map((f) => path.basename(f, '.lock').toLowerCase()))
425
+ const [found, { names, archived, read: unread }] = await Promise.all([findRollouts(locks), threadRecords()])
426
+ const read = []
427
+ for (const rollout of found) {
428
+ try {
429
+ read.push({ ...rollout, state: await rollouts.read(rollout.file, rollout.stat) })
430
+ } catch {}
431
+ }
432
+ rollouts.sweep()
433
+ known = new Map(read.map((r) => [r.id, r.file]))
434
+ childrenOf = new Map()
435
+ for (const { id, state } of read) {
436
+ if (state.subagent && state.parentId) childrenOf.set(state.parentId, [...(childrenOf.get(state.parentId) || []), id])
437
+ }
438
+
439
+ const running = new Map()
440
+ for (const { id, stat, state } of read) {
441
+ if (!state.subagent || !state.parentId || !state.open) continue
442
+ if (!locks.has(id) && at - stat.mtimeMs > SUBAGENT_WINDOW_MS) continue
443
+ running.set(state.parentId, (running.get(state.parentId) || 0) + 1)
444
+ }
445
+
446
+ return read
447
+ .filter(({ id, state }) => !state.subagent && !state.internal && !archived.has(id))
448
+ .map(({ id, stat, state }) => {
449
+ const lastActivityAt = Math.max(state.lastAt, Math.floor(stat.mtimeMs))
450
+ const fresh = at - lastActivityAt < ACTIVE_WINDOW_MS
451
+ const live = locking ? locks.has(id) : state.open && fresh
452
+ return toSession({ id, state, names, live, turnLive: state.open && (locking ? live : fresh), fresh, locking, unread: unread.get(id), subagents: running.get(id) || 0, lastActivityAt })
453
+ })
454
+ }
455
+
456
+ async function readKnown(id) {
457
+ const file = known.get(id)
458
+ const stat = file ? await fsp.stat(file).catch(() => null) : null
459
+ if (!stat) return null
460
+ const state = await rollouts.read(file, stat).catch(() => null)
461
+ return state ? { file, stat, state } : null
462
+ }
463
+
464
+ async function sessionDetail(key) {
465
+ const id = String(key).replace(/^codex:/, '').toLowerCase()
466
+ const found = await readKnown(id)
467
+ if (!found || found.state.subagent || found.state.internal) return null
468
+ const { file, state } = found
469
+ const subagents = []
470
+ for (const childId of childrenOf.get(id) || []) {
471
+ const child = await readKnown(childId)
472
+ if (!child) continue
473
+ const c = child.state
474
+ const lastActivityAt = Math.max(c.lastAt, Math.floor(child.stat.mtimeMs))
475
+ const status = c.open && now() - lastActivityAt < ACTIVE_WINDOW_MS ? 'working' : c.failed ? 'error' : 'done'
476
+ subagents.push({
477
+ id: childId,
478
+ name: titleFromPrompt(c.typed ? c.firstPrompt : c.fallbackPrompt) || 'Subagent',
479
+ type: '',
480
+ status,
481
+ model: c.model,
482
+ tokens: c.tokens ? { ...c.tokens } : null,
483
+ tool: status === 'working' ? latestCall(c.calls) : null,
484
+ startedAt: c.startedAt,
485
+ lastActivityAt,
486
+ })
487
+ }
488
+ return {
489
+ id: `codex:${id}`,
490
+ sessionId: id,
491
+ source: file,
492
+ models: [...state.modelTokens].map(([model, tokens]) => ({ model, tokens: { ...tokens }, costUsd: null })),
493
+ contextParts: state.contextParts ? { ...state.contextParts } : null,
494
+ history: state.history.map((p) => ({ ...p })),
495
+ compactions: null,
496
+ ...activityDetail(state.activity),
497
+ plan: planItems(state.plan),
498
+ subagents: subagents.sort((a, b) => a.startedAt - b.startedAt),
499
+ files: fileList(state.files, state.cwd),
500
+ prompts: (state.typed ? state.promptList : state.fallbackPromptList).map((p) => ({ ...p })),
501
+ errors: state.errors.map((e) => ({ ...e })),
502
+ }
503
+ }
504
+
505
+ return {
506
+ id: 'codex',
507
+ name: 'Codex',
508
+ detect: () => exists(sessionsDir),
509
+ watchRoots: () => [sessionsDir, locksDir],
510
+ scanSessions,
511
+ sessionDetail,
512
+ }
513
+ }
514
+
515
+ function toSession({ id, state, names, live, turnLive, fresh, locking, unread, subagents, lastActivityAt }) {
516
+ const calls = [...state.calls.values()]
517
+ let status
518
+ if (turnLive) {
519
+ if (calls.some((c) => QUESTION_TOOLS.has(c.name))) status = 'asking'
520
+ else if (calls.length ? calls.every((c) => AGENT_TOOLS.has(c.name)) : subagents > 0) status = 'delegating'
521
+ else status = 'working'
522
+ } else if (subagents) status = 'delegating'
523
+ else if (state.failed) status = 'error'
524
+ else if (unread !== undefined) status = unread ? 'done' : 'idle'
525
+ else status = (locking ? live : fresh) ? 'done' : 'idle'
526
+
527
+ const tool = turnLive && (status === 'working' || status === 'delegating') ? latestCall(state.calls) : null
528
+ const prompt = state.typed ? state.firstPrompt : state.fallbackPrompt
529
+ const cwd = state.cwd
530
+ return {
531
+ id: `codex:${id}`,
532
+ harness: 'codex',
533
+ title: names.get(id) || (prompt ? titleFromPrompt(prompt) : '') || 'Untitled session',
534
+ project: path.basename(cwd.replace(/\\/g, '/')) || cwd || 'unknown',
535
+ projectPath: cwd,
536
+ worktree: '',
537
+ cwd,
538
+ gitBranch: state.gitBranch,
539
+ model: state.model,
540
+ effort: state.effort,
541
+ status,
542
+ live: live || turnLive,
543
+ startedAt: state.startedAt,
544
+ lastActivityAt,
545
+ tool,
546
+ tokens: state.tokens ? { ...state.tokens } : null,
547
+ context: state.context && state.context.used ? { ...state.context } : null,
548
+ costUsd: null,
549
+ lines: { ...state.lines },
550
+ plan: planSummary(state.plan),
551
+ subagents,
552
+ prompts: state.typed ? state.prompts : state.fallbackPrompts,
553
+ }
554
+ }
555
+
556
+ export default codex()