@threadcrumbs/common 0.0.0-stage → 0.1.0
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 +7 -0
- package/README.md +108 -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 +101 -0
- package/license/store.mjs +155 -0
- package/orgs/index.mjs +157 -0
- package/package.json +39 -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,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()
|