@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,276 @@
1
+ import fsp from 'node:fs/promises'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+ import { decodeProjectDir } from '../lib/cursor-folder.mjs'
5
+ import { exists, listDirs, listFiles } from '../lib/fsutil.mjs'
6
+ import { jsonlFollower } from '../lib/jsonl-follower.mjs'
7
+ import {
8
+ DETAIL_LIMITS,
9
+ RECENT_MS,
10
+ activityDetail,
11
+ clip,
12
+ clipBlock,
13
+ emptyActivity,
14
+ keepNewest,
15
+ nextPlan,
16
+ planItems,
17
+ planSummary,
18
+ relativePath,
19
+ startCall,
20
+ text,
21
+ titleFromPrompt,
22
+ toolFile,
23
+ toolInput,
24
+ toolTarget,
25
+ } from '../lib/telemetry-fields.mjs'
26
+
27
+ const ACTIVE_WINDOW_MS = 30 * 60 * 1000
28
+ const SUBAGENT_WINDOW_MS = 10 * 60 * 1000
29
+ const TRANSCRIPTS = 'agent-transcripts'
30
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
31
+ 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
32
+ const USER_ABORT = /user aborted/i
33
+
34
+ function rawText(record) {
35
+ const parts = record?.message?.content
36
+ return Array.isArray(parts) ? parts.map((p) => (typeof p === 'string' ? p : text(p?.text))).join('\n') : text(parts)
37
+ }
38
+
39
+ function userText(record) {
40
+ const raw = rawText(record)
41
+ const query = /<user_query>\s*([\s\S]*?)\s*<\/user_query>/.exec(raw)
42
+ return (query ? query[1] : raw.replace(/<[a-z_]+>[\s\S]*?<\/[a-z_]+>/gi, ' ')).trim()
43
+ }
44
+
45
+ function stamp(record) {
46
+ const m = /<timestamp>(.*?)<\/timestamp>/.exec(rawText(record))
47
+ const t = m ? Date.parse(m[1].replace(/\s*\(UTC[^)]*\)\s*$/, '')) : NaN
48
+ return Number.isNaN(t) ? 0 : t
49
+ }
50
+
51
+ function emptyTranscript() {
52
+ return {
53
+ firstPrompt: '',
54
+ startedAt: 0,
55
+ prompts: 0,
56
+ marked: false,
57
+ closed: false,
58
+ failed: false,
59
+ modelsTurn: false,
60
+ tool: null,
61
+ plan: null,
62
+ activity: emptyActivity(),
63
+ promptList: [],
64
+ errors: [],
65
+ }
66
+ }
67
+
68
+ function applyRecord(state, record) {
69
+ if (record.type === 'turn_ended') {
70
+ state.marked = true
71
+ state.closed = true
72
+ state.failed = record.status !== 'success' && !USER_ABORT.test(text(record.error))
73
+ state.modelsTurn = false
74
+ state.tool = null
75
+ if (state.failed) {
76
+ keepNewest(state.errors, { at: 0, message: clip(text(record.error) || 'The turn ended in an error', DETAIL_LIMITS.errorText) }, DETAIL_LIMITS.errors)
77
+ }
78
+ return
79
+ }
80
+ state.closed = false
81
+ if (record.role === 'user') {
82
+ const prompt = userText(record)
83
+ if (prompt) {
84
+ state.prompts++
85
+ state.firstPrompt ||= prompt
86
+ state.startedAt ||= stamp(record)
87
+ keepNewest(state.promptList, { at: stamp(record), text: clipBlock(prompt, DETAIL_LIMITS.promptText) }, DETAIL_LIMITS.prompts)
88
+ }
89
+ state.modelsTurn = true
90
+ state.tool = null
91
+ return
92
+ }
93
+ if (record.role !== 'assistant') return
94
+ const content = Array.isArray(record.message?.content) ? record.message.content : []
95
+ const calls = content.filter((c) => c?.type === 'tool_use')
96
+ for (const call of calls) {
97
+ state.plan = nextPlan(state.plan, text(call.name), call.input)
98
+ startCall(state.activity, { name: text(call.name), target: toolTarget(call.input, '', Infinity), input: toolInput(call.input), ...toolFile(call.input), startedAt: 0, running: false })
99
+ }
100
+ state.modelsTurn = calls.length > 0
101
+ state.tool = calls.length ? { name: text(calls.at(-1).name), startedAt: 0 } : null
102
+ }
103
+
104
+ export function cursor({
105
+ projectsDir = process.env.THREADCRUMBS_CURSOR_PROJECTS_DIR || path.join(os.homedir(), '.cursor', 'projects'),
106
+ now = Date.now,
107
+ } = {}) {
108
+ const transcripts = jsonlFollower({ empty: emptyTranscript, apply: applyRecord })
109
+ const subagentFiles = jsonlFollower({ empty: emptyTranscript, apply: applyRecord })
110
+ let subagentsSweptAt = 0
111
+ let known = new Map()
112
+
113
+ async function follow(file, stat, reader = transcripts) {
114
+ const state = await reader.read(file, stat)
115
+ const seenAt = Math.floor(stat.mtimeMs)
116
+ if (state.tool && !state.tool.startedAt) state.tool.startedAt = seenAt
117
+ for (const call of state.activity.calls) if (!call.startedAt) call.startedAt = seenAt
118
+ return state
119
+ }
120
+
121
+ const isOpen = (state, fresh) => fresh && (state.marked ? !state.closed : state.modelsTurn)
122
+
123
+ async function findTranscripts() {
124
+ const cutoff = now() - RECENT_MS
125
+ const out = []
126
+ const add = async (id, file, projectDir, sessionDir) => {
127
+ const stat = await fsp.stat(file).catch(() => null)
128
+ if (stat && stat.size && stat.mtimeMs >= cutoff) out.push({ id, file, stat, projectDir, sessionDir })
129
+ }
130
+ for (const projectDir of await listDirs(projectsDir)) {
131
+ const root = path.join(projectDir, TRANSCRIPTS)
132
+ for (const sessionDir of await listDirs(root)) {
133
+ const id = path.basename(sessionDir)
134
+ if (!UUID.test(id)) continue
135
+ for (const file of await listFiles(sessionDir, (n) => n.endsWith('.jsonl'))) await add(id, file, projectDir, sessionDir)
136
+ }
137
+ for (const file of await listFiles(root, (n) => FLAT_TRANSCRIPT.test(n))) {
138
+ await add(FLAT_TRANSCRIPT.exec(path.basename(file))[1], file, projectDir, '')
139
+ }
140
+ }
141
+ return out
142
+ }
143
+
144
+ async function runningSubagents(sessionDir) {
145
+ if (!sessionDir) return 0
146
+ let count = 0
147
+ for (const file of await listFiles(path.join(sessionDir, 'subagents'), (n) => n.endsWith('.jsonl'))) {
148
+ const stat = await fsp.stat(file).catch(() => null)
149
+ if (!stat || now() - stat.mtimeMs > SUBAGENT_WINDOW_MS) continue
150
+ const state = await follow(file, stat, subagentFiles).catch(() => null)
151
+ if (state && isOpen(state, true)) count++
152
+ }
153
+ return count
154
+ }
155
+
156
+ async function subagentList(sessionDir) {
157
+ if (!sessionDir) return []
158
+ const list = []
159
+ for (const file of await listFiles(path.join(sessionDir, 'subagents'), (n) => n.endsWith('.jsonl'))) {
160
+ const stat = await fsp.stat(file).catch(() => null)
161
+ if (!stat) continue
162
+ const state = await follow(file, stat, subagentFiles).catch(() => null)
163
+ if (!state) continue
164
+ const open = isOpen(state, now() - stat.mtimeMs <= SUBAGENT_WINDOW_MS)
165
+ const name = path.basename(file, '.jsonl')
166
+ list.push({
167
+ id: name,
168
+ name: titleFromPrompt(state.firstPrompt) || name,
169
+ type: '',
170
+ status: open ? 'working' : state.failed ? 'error' : 'done',
171
+ model: '',
172
+ tokens: null,
173
+ tool: open && state.tool ? { ...state.tool } : null,
174
+ startedAt: state.startedAt || Math.floor(stat.birthtimeMs) || Math.floor(stat.mtimeMs),
175
+ lastActivityAt: Math.floor(stat.mtimeMs),
176
+ })
177
+ }
178
+ return list.sort((a, b) => a.startedAt - b.startedAt).slice(-DETAIL_LIMITS.subagents)
179
+ }
180
+
181
+ async function sessionDetail(key) {
182
+ const id = String(key).replace(/^cursor:/, '')
183
+ const entry = known.get(id)
184
+ const stat = entry ? await fsp.stat(entry.file).catch(() => null) : null
185
+ if (!stat) return null
186
+ const state = await follow(entry.file, stat)
187
+ const open = isOpen(state, now() - stat.mtimeMs < ACTIVE_WINDOW_MS)
188
+ const { activity, tools } = activityDetail(state.activity)
189
+ for (const call of activity) {
190
+ call.target = clip(relativePath(call.target, entry.projectPath))
191
+ if (call.path) call.path = relativePath(call.path, entry.projectPath)
192
+ }
193
+ if (open && state.tool && activity.length) activity.at(-1).running = true
194
+ return {
195
+ id: `cursor:${id}`,
196
+ sessionId: id,
197
+ source: entry.file,
198
+ models: null,
199
+ history: null,
200
+ compactions: null,
201
+ contextParts: null,
202
+ activity,
203
+ tools,
204
+ plan: planItems(state.plan),
205
+ subagents: await subagentList(entry.sessionDir),
206
+ files: null,
207
+ prompts: state.promptList.map((p) => ({ ...p })),
208
+ errors: state.errors.map((e) => ({ ...e })),
209
+ }
210
+ }
211
+
212
+ async function scanSessions() {
213
+ const sessions = []
214
+ const found = new Map()
215
+ for (const entry of await findTranscripts()) {
216
+ let state
217
+ try {
218
+ state = await follow(entry.file, entry.stat)
219
+ } catch {
220
+ continue
221
+ }
222
+ const fresh = now() - entry.stat.mtimeMs < ACTIVE_WINDOW_MS
223
+ const open = isOpen(state, fresh)
224
+ const subagents = await runningSubagents(entry.sessionDir)
225
+ const projectPath = await decodeProjectDir(path.basename(entry.projectDir))
226
+ found.set(entry.id, { file: entry.file, sessionDir: entry.sessionDir, projectPath })
227
+ let status
228
+ if (subagents) status = 'delegating'
229
+ else if (open) status = 'working'
230
+ else if (state.failed) status = 'error'
231
+ else status = fresh ? 'done' : 'idle'
232
+ sessions.push({
233
+ id: `cursor:${entry.id}`,
234
+ harness: 'cursor',
235
+ title: (state.firstPrompt ? titleFromPrompt(state.firstPrompt) : '') || 'Untitled session',
236
+ project: path.basename(projectPath) || 'unknown',
237
+ projectPath,
238
+ worktree: '',
239
+ cwd: projectPath,
240
+ gitBranch: '',
241
+ model: '',
242
+ effort: '',
243
+ status,
244
+ live: open,
245
+ startedAt: state.startedAt || Math.floor(entry.stat.birthtimeMs) || Math.floor(entry.stat.mtimeMs),
246
+ lastActivityAt: Math.floor(entry.stat.mtimeMs),
247
+ tool: open && state.tool ? { ...state.tool } : null,
248
+ tokens: null,
249
+ context: null,
250
+ costUsd: null,
251
+ lines: null,
252
+ plan: planSummary(state.plan),
253
+ subagents,
254
+ prompts: state.prompts,
255
+ })
256
+ }
257
+ transcripts.sweep()
258
+ known = found
259
+ if (now() - subagentsSweptAt >= SUBAGENT_WINDOW_MS) {
260
+ subagentsSweptAt = now()
261
+ subagentFiles.sweep()
262
+ }
263
+ return sessions
264
+ }
265
+
266
+ return {
267
+ id: 'cursor',
268
+ name: 'Cursor',
269
+ detect: () => exists(projectsDir),
270
+ watchRoots: () => [projectsDir],
271
+ scanSessions,
272
+ sessionDetail,
273
+ }
274
+ }
275
+
276
+ export default cursor()
@@ -0,0 +1,404 @@
1
+ import fsp from 'node:fs/promises'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+ import { exists, listDirs } from '../lib/fsutil.mjs'
5
+ import { resolveHermesHome } from '../lib/hermes-home.mjs'
6
+ import { columnsOf, tablesOf, withReadOnly } from '../lib/sqlite.mjs'
7
+ import {
8
+ DETAIL_LIMITS,
9
+ RECENT_MS,
10
+ activityDetail,
11
+ clip,
12
+ clipBlock,
13
+ emptyActivity,
14
+ endCall,
15
+ keepNewest,
16
+ number,
17
+ planItems,
18
+ planSummary,
19
+ settleCalls,
20
+ startCall,
21
+ text,
22
+ titleFromPrompt,
23
+ toolFile,
24
+ toolInput,
25
+ toolTarget,
26
+ } from '../lib/telemetry-fields.mjs'
27
+
28
+ const ACTIVE_WINDOW_MS = 30 * 60 * 1000
29
+ const FORK_MARKERS = ['_branched_from', '_delegate_from', '_reset_from']
30
+ const DELEGATE_TOOLS = new Set(['delegate_task'])
31
+ const TODO_TOOLS = ['todo_list', 'todo']
32
+ const ASKING = /^waiting for user (approval|clarify)/i
33
+ const TOOL_LABEL = /^(?:executing tool|sequential tool running \([^)]*\)): (.+)$/i
34
+ const SESSION_COLUMNS = [
35
+ 'id', 'title', 'model', 'source', 'cwd', 'git_branch', 'git_repo_root', 'started_at', 'ended_at',
36
+ 'last_activity_at', 'last_activity_description', 'parent_session_id', 'end_reason', 'model_config', 'hidden',
37
+ 'input_tokens', 'output_tokens', 'cache_read_tokens', 'cache_write_tokens', 'estimated_cost_usd',
38
+ 'actual_cost_usd', 'billing_base_url', 'archived', 'last_read_at',
39
+ ]
40
+
41
+ const seconds = (v) => Math.round(number(v) * 1000)
42
+
43
+ function parseConfig(raw) {
44
+ try {
45
+ const config = JSON.parse(raw || '{}')
46
+ return config && typeof config === 'object' ? config : {}
47
+ } catch {
48
+ return {}
49
+ }
50
+ }
51
+
52
+ const unquote = (key) => {
53
+ if (/^'.*'$/.test(key)) return key.slice(1, -1).replace(/''/g, "'")
54
+ if (/^".*"$/.test(key)) {
55
+ try {
56
+ return JSON.parse(key)
57
+ } catch {
58
+ return key.slice(1, -1)
59
+ }
60
+ }
61
+ return key
62
+ }
63
+
64
+ export function parseContextLengths(source) {
65
+ const lengths = new Map()
66
+ let inSection = false
67
+ for (const line of text(source).split(/\r?\n/)) {
68
+ if (/^\S/.test(line)) {
69
+ inSection = /^context_lengths:\s*$/.test(line)
70
+ continue
71
+ }
72
+ const entry = inSection && /^\s+(.+?):\s+(\d+)\s*$/.exec(line)
73
+ if (entry) lengths.set(unquote(entry[1]), Number(entry[2]))
74
+ }
75
+ return lengths
76
+ }
77
+
78
+ function contextLimit(lengths, model, baseUrl) {
79
+ if (!model) return 0
80
+ const exact = lengths.get(`${model}@${text(baseUrl).replace(/\/+$/, '')}`)
81
+ if (exact) return exact
82
+ for (const [key, limit] of lengths) if (key.startsWith(`${model}@`)) return limit
83
+ return 0
84
+ }
85
+
86
+ export function hermes({ home = resolveHermesHome(), now = Date.now } = {}) {
87
+ const mainDb = path.join(home, 'state.db')
88
+ const contextCaches = new Map()
89
+
90
+ async function storeFiles() {
91
+ const files = [{ profile: 'main', dir: home, file: mainDb }]
92
+ for (const dir of await listDirs(path.join(home, 'profiles'))) {
93
+ const file = path.join(dir, 'state.db')
94
+ if (await exists(file)) files.push({ profile: path.basename(dir), dir, file })
95
+ }
96
+ return files
97
+ }
98
+
99
+ async function contextLengths(dir) {
100
+ const file = path.join(dir, 'context_length_cache.yaml')
101
+ const stat = await fsp.stat(file).catch(() => null)
102
+ if (!stat) return new Map()
103
+ const hit = contextCaches.get(file)
104
+ if (hit && hit.mtime === stat.mtimeMs) return hit.lengths
105
+ const lengths = parseContextLengths(await fsp.readFile(file, 'utf8').catch(() => ''))
106
+ contextCaches.set(file, { mtime: stat.mtimeMs, lengths })
107
+ return lengths
108
+ }
109
+
110
+ async function scanSessions() {
111
+ const sessions = []
112
+ for (const store of await storeFiles()) {
113
+ const lengths = await contextLengths(store.dir)
114
+ const found = await withReadOnly(store.file, (db) => sessionsFrom(db, store.profile, lengths, home, now()), [])
115
+ sessions.push(...found)
116
+ }
117
+ return sessions
118
+ }
119
+
120
+ async function sessionDetail(key) {
121
+ const match = /^hermes:([^:]+):(.+)$/.exec(String(key))
122
+ if (!match) return null
123
+ const [, profile, rootId] = match
124
+ const store = (await storeFiles()).find((s) => s.profile === profile)
125
+ if (!store) return null
126
+ const detail = await withReadOnly(store.file, (db) => detailFrom(db, rootId, now()), null)
127
+ return detail && { id: `hermes:${profile}:${rootId}`, sessionId: rootId, source: store.file, ...detail }
128
+ }
129
+
130
+ return {
131
+ id: 'hermes',
132
+ name: 'Hermes',
133
+ detect: () => exists(mainDb),
134
+ watchRoots: () => [home],
135
+ scanSessions,
136
+ sessionDetail,
137
+ }
138
+ }
139
+
140
+ function lineage(db, at) {
141
+ const tables = tablesOf(db)
142
+ if (!tables.has('sessions') || !tables.has('messages')) return null
143
+ const cols = columnsOf(db, 'sessions')
144
+ if (!['id', 'started_at'].every((c) => cols.has(c))) return null
145
+ const rows = db.prepare(`SELECT ${SESSION_COLUMNS.map((c) => (cols.has(c) ? c : `NULL AS ${c}`)).join(', ')} FROM sessions`).all()
146
+ for (const row of rows) row.config = parseConfig(row.model_config)
147
+ const leases = tables.has('session_turn_leases')
148
+ ? new Set(db.prepare('SELECT conversation_id FROM session_turn_leases WHERE expires_at > ?').all(at / 1000).map((r) => r.conversation_id))
149
+ : null
150
+ const messages = columnsOf(db, 'messages')
151
+ if (!['id', 'session_id', 'role', 'content'].every((c) => messages.has(c))) return null
152
+ const visible = messages.has('active') ? (messages.has('compacted') ? 'AND (active = 1 OR compacted = 1)' : 'AND active = 1') : ''
153
+
154
+ const byId = new Map(rows.map((r) => [r.id, r]))
155
+ const fork = (row) => FORK_MARKERS.find((key) => row.config[key] != null) || ''
156
+ const continues = (row) => {
157
+ const parent = row.parent_session_id ? byId.get(row.parent_session_id) : null
158
+ return parent && parent.end_reason === 'compression' && !fork(row) ? parent : null
159
+ }
160
+ const continued = new Set(rows.map(continues).filter(Boolean).map((p) => p.id))
161
+ const isErrand = (row) => row.source === 'subagent' || fork(row) === '_delegate_from'
162
+ const lastAt = (row) => seconds(row.last_activity_at || row.ended_at || row.started_at)
163
+ const chainOf = (tip) => {
164
+ const chain = [tip]
165
+ for (let parent = continues(tip); parent && chain.length <= rows.length; parent = continues(parent)) chain.push(parent)
166
+ return chain
167
+ }
168
+ const turnOpen = (row, root) =>
169
+ leases ? leases.has(root.id) : Boolean(text(row.last_activity_description)) && at - lastAt(row) < ACTIVE_WINDOW_MS
170
+ return { rows, continued, isErrand, lastAt, chainOf, turnOpen, messages, visible }
171
+ }
172
+
173
+ function sessionsFrom(db, profile, lengths, home, at) {
174
+ const found = lineage(db, at)
175
+ if (!found) return []
176
+ const { rows, continued, isErrand, lastAt, chainOf, turnOpen, messages, visible } = found
177
+
178
+ const out = []
179
+ for (const tip of rows) {
180
+ if (continued.has(tip.id) || isErrand(tip) || number(tip.hidden) || number(tip.archived) || tip.source === 'cron') continue
181
+ const chain = chainOf(tip)
182
+ const root = chain[chain.length - 1]
183
+ const open = turnOpen(tip, root)
184
+ if (!open && at - lastAt(tip) > RECENT_MS) continue
185
+ const ids = chain.map((r) => r.id)
186
+ const errands = rows.filter((r) => isErrand(r) && ids.includes(r.parent_session_id) && turnOpen(r, chainOf(r).at(-1))).length
187
+ out.push(toSession({ db, profile, tip, root, chain, ids, open, errands, lengths, home, messages, visible, at, lastAt: lastAt(tip) }))
188
+ }
189
+ return out
190
+ }
191
+
192
+ function parseJson(raw, fallback) {
193
+ try {
194
+ return JSON.parse(raw)
195
+ } catch {
196
+ return fallback
197
+ }
198
+ }
199
+
200
+ const FAILED_END = /error|fail/i
201
+
202
+ function detailFrom(db, rootId, at) {
203
+ const found = lineage(db, at)
204
+ if (!found) return null
205
+ const { rows, continued, isErrand, lastAt, chainOf, turnOpen, messages, visible } = found
206
+ const tip = rows.find((r) => !continued.has(r.id) && !isErrand(r) && chainOf(r).at(-1).id === rootId)
207
+ if (!tip) return null
208
+ const chain = chainOf(tip)
209
+ const ids = chain.map((r) => r.id)
210
+ const placeholders = ids.map(() => '?').join(', ')
211
+ const open = turnOpen(tip, chain.at(-1))
212
+ const cwd = text(tip.cwd)
213
+
214
+ const activity = emptyActivity()
215
+ const prompts = []
216
+ const pick = (column, fallback = 'NULL') => (messages.has(column) ? column : `${fallback} AS ${column}`)
217
+ const order = messages.has('timestamp') ? 'timestamp, id' : 'id'
218
+ const records = db
219
+ .prepare(
220
+ `SELECT id, role, CASE WHEN role = 'user' THEN content END AS content, ${pick('tool_calls')}, ${pick('tool_call_id')}, ${pick('timestamp', '0')}
221
+ FROM messages WHERE session_id IN (${placeholders}) ${visible} ORDER BY ${order}`,
222
+ )
223
+ .all(...ids)
224
+ for (const record of records) {
225
+ const time = seconds(record.timestamp)
226
+ if (record.role === 'user') {
227
+ settleCalls(activity)
228
+ const prompt = text(record.content).trim()
229
+ if (prompt) keepNewest(prompts, { at: time, text: clipBlock(prompt, DETAIL_LIMITS.promptText) }, DETAIL_LIMITS.prompts)
230
+ } else if (record.role === 'assistant' && record.tool_calls) {
231
+ for (const call of parseJson(record.tool_calls, [])) {
232
+ if (!call?.id) continue
233
+ const args = typeof call.function?.arguments === 'string' ? parseJson(call.function.arguments, null) : call.function?.arguments
234
+ startCall(activity, { id: text(call.id), name: text(call.function?.name), target: toolTarget(args, cwd), input: toolInput(args), ...toolFile(args, cwd), startedAt: time })
235
+ }
236
+ } else if (record.role === 'tool' && record.tool_call_id) {
237
+ endCall(activity, text(record.tool_call_id), time, null)
238
+ }
239
+ }
240
+ if (!open) settleCalls(activity)
241
+
242
+ const subagents = rows
243
+ .filter((r) => isErrand(r) && ids.includes(r.parent_session_id))
244
+ .map((r) => {
245
+ const working = turnOpen(r, chainOf(r).at(-1))
246
+ const label = TOOL_LABEL.exec(text(r.last_activity_description))
247
+ const tokens = ['input_tokens', 'output_tokens', 'cache_read_tokens', 'cache_write_tokens'].map((c) => (r[c] == null ? null : number(r[c])))
248
+ return {
249
+ id: r.id,
250
+ name: text(r.title) || 'Subagent',
251
+ type: text(r.source) === 'subagent' ? '' : 'delegate',
252
+ status: working ? 'working' : FAILED_END.test(text(r.end_reason)) ? 'error' : r.ended_at != null ? 'done' : 'idle',
253
+ model: text(r.model),
254
+ tokens: tokens.some((t) => t != null) ? { input: tokens[0] || 0, output: tokens[1] || 0, cacheRead: tokens[2] || 0, cacheWrite: tokens[3] || 0 } : null,
255
+ tool: working && label ? { name: label[1].trim(), startedAt: seconds(r.last_activity_at) } : null,
256
+ startedAt: seconds(r.started_at),
257
+ lastActivityAt: lastAt(r),
258
+ }
259
+ })
260
+ .sort((a, b) => a.startedAt - b.startedAt)
261
+ .slice(-DETAIL_LIMITS.subagents)
262
+
263
+ const models = new Map()
264
+ for (const row of chain) {
265
+ const model = text(row.model)
266
+ if (!model) continue
267
+ const entry = models.get(model) || { model, tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, costUsd: null }
268
+ entry.tokens.input += number(row.input_tokens)
269
+ entry.tokens.output += number(row.output_tokens)
270
+ entry.tokens.cacheRead += number(row.cache_read_tokens)
271
+ entry.tokens.cacheWrite += number(row.cache_write_tokens)
272
+ const cost = row.actual_cost_usd ?? row.estimated_cost_usd
273
+ if (cost != null) entry.costUsd = (entry.costUsd || 0) + number(cost)
274
+ models.set(model, entry)
275
+ }
276
+
277
+ const errors = chain
278
+ .filter((r) => FAILED_END.test(text(r.end_reason)))
279
+ .map((r) => ({ at: seconds(r.ended_at), message: `The session ended: ${text(r.end_reason)}` }))
280
+ .reverse()
281
+
282
+ return {
283
+ models: [...models.values()],
284
+ history: null,
285
+ compactions: null,
286
+ contextParts: null,
287
+ ...activityDetail(activity),
288
+ plan: planItems(latestPlan(db, ids, messages)),
289
+ subagents,
290
+ files: null,
291
+ prompts,
292
+ errors,
293
+ }
294
+ }
295
+
296
+ function openCalls(db, sessionId, messages, visible) {
297
+ if (!['tool_calls', 'tool_call_id', 'timestamp'].every((c) => messages.has(c))) return []
298
+ const last = db
299
+ .prepare(`SELECT id, tool_calls, timestamp FROM messages WHERE session_id = ? AND role = 'assistant' ${visible} ORDER BY id DESC LIMIT 1`)
300
+ .get(sessionId)
301
+ if (!last?.tool_calls) return []
302
+ let calls
303
+ try {
304
+ calls = JSON.parse(last.tool_calls)
305
+ } catch {
306
+ return []
307
+ }
308
+ const answered = new Set(
309
+ db.prepare(`SELECT tool_call_id FROM messages WHERE session_id = ? AND role = 'tool' AND id > ?`).all(sessionId, last.id).map((r) => r.tool_call_id),
310
+ )
311
+ return (Array.isArray(calls) ? calls : [])
312
+ .filter((c) => c?.id && !answered.has(c.id))
313
+ .map((c) => ({ name: text(c.function?.name), startedAt: seconds(last.timestamp) }))
314
+ }
315
+
316
+ function latestPlan(db, ids, messages) {
317
+ if (!messages.has('tool_name') || !messages.has('timestamp')) return null
318
+ const row = db
319
+ .prepare(
320
+ `SELECT content FROM messages WHERE session_id IN (${ids.map(() => '?').join(', ')}) AND role = 'tool' AND tool_name IN (${TODO_TOOLS.map(() => '?').join(', ')}) ORDER BY timestamp DESC, id DESC LIMIT 1`,
321
+ )
322
+ .get(...ids, ...TODO_TOOLS)
323
+ try {
324
+ const todos = JSON.parse(row?.content || '{}').todos
325
+ return Array.isArray(todos) ? todos.map((t) => ({ content: text(t?.content), status: text(t?.status) || 'pending' })) : null
326
+ } catch {
327
+ return null
328
+ }
329
+ }
330
+
331
+ function sum(chain, column) {
332
+ let total = 0
333
+ let known = false
334
+ for (const row of chain) {
335
+ if (row[column] == null) continue
336
+ total += number(row[column])
337
+ known = true
338
+ }
339
+ return known ? total : null
340
+ }
341
+
342
+ function toSession({ db, profile, tip, root, chain, ids, open, errands, lengths, home, messages, visible, at, lastAt }) {
343
+ const description = text(tip.last_activity_description)
344
+ const placeholders = ids.map(() => '?').join(', ')
345
+ const calls = open ? openCalls(db, tip.id, messages, visible) : []
346
+ const working = calls.filter((c) => !DELEGATE_TOOLS.has(c.name))
347
+ let status
348
+ if (open) {
349
+ if (ASKING.test(description)) status = 'asking'
350
+ else status = !working.length && (calls.length || errands) ? 'delegating' : 'working'
351
+ } else if (errands) status = 'delegating'
352
+ else if (tip.last_read_at != null) status = seconds(tip.last_activity_at || tip.started_at) > seconds(tip.last_read_at) ? 'done' : 'idle'
353
+ else status = tip.ended_at == null && at - lastAt < ACTIVE_WINDOW_MS ? 'done' : 'idle'
354
+
355
+ let tool = null
356
+ if (status === 'working' || status === 'delegating') {
357
+ const latest = (working.length ? working : calls).at(-1)
358
+ const label = TOOL_LABEL.exec(description)
359
+ if (latest) tool = latest
360
+ else if (label) tool = { name: label[1].trim(), startedAt: seconds(tip.last_activity_at) }
361
+ }
362
+
363
+ const where = text(tip.git_repo_root) || text(tip.cwd)
364
+ const userHome = os.homedir()
365
+ const atHome = !where || where === userHome || where === home || where === path.join(userHome, '.hermes')
366
+ const anchor = tip.config._usage_anchor
367
+ const used = anchor ? number(anchor.prompt_tokens) + number(anchor.completion_tokens) : 0
368
+ const reasoning = tip.config.reasoning_config
369
+ const tokens = ['input_tokens', 'output_tokens', 'cache_read_tokens', 'cache_write_tokens'].map((c) => sum(chain, c))
370
+ const costs = chain.map((r) => r.actual_cost_usd ?? r.estimated_cost_usd)
371
+ const firstPrompt = db
372
+ .prepare(`SELECT content FROM messages WHERE session_id = ? AND role = 'user' ORDER BY id ASC LIMIT 1`)
373
+ .get(root.id)?.content
374
+ const prompts = db.prepare(`SELECT COUNT(*) AS n FROM messages WHERE session_id IN (${placeholders}) AND role = 'user' ${visible}`).get(...ids)
375
+
376
+ return {
377
+ id: `hermes:${profile}:${root.id}`,
378
+ harness: 'hermes',
379
+ title: text(tip.title) || text(root.title) || titleFromPrompt(text(firstPrompt)) || 'Untitled session',
380
+ project: atHome ? 'Hermes' : path.basename(where.replace(/\\/g, '/')),
381
+ projectPath: atHome ? userHome : where,
382
+ worktree: '',
383
+ cwd: text(tip.cwd),
384
+ gitBranch: text(tip.git_branch),
385
+ model: text(tip.model),
386
+ effort: reasoning && reasoning.enabled !== false ? text(reasoning.effort) : '',
387
+ status,
388
+ live: open,
389
+ startedAt: seconds(root.started_at),
390
+ lastActivityAt: lastAt,
391
+ tool,
392
+ tokens: tokens.some((t) => t != null)
393
+ ? { input: tokens[0] || 0, output: tokens[1] || 0, cacheRead: tokens[2] || 0, cacheWrite: tokens[3] || 0 }
394
+ : null,
395
+ context: used ? { used, limit: contextLimit(lengths, text(tip.model), tip.billing_base_url) } : null,
396
+ costUsd: costs.some((c) => c != null) ? costs.reduce((total, c) => total + number(c), 0) : null,
397
+ lines: null,
398
+ plan: planSummary(latestPlan(db, ids, messages)),
399
+ subagents: errands,
400
+ prompts: number(prompts?.n),
401
+ }
402
+ }
403
+
404
+ export default hermes()
@@ -0,0 +1,9 @@
1
+ import antigravity from './antigravity.mjs'
2
+ import claudeCode from './claude-code.mjs'
3
+ import codex from './codex.mjs'
4
+ import cursor from './cursor.mjs'
5
+ import hermes from './hermes.mjs'
6
+ import kilocode from './kilocode.mjs'
7
+ import opencode from './opencode.mjs'
8
+
9
+ export const TELEMETRY = [claudeCode, codex, cursor, antigravity, hermes, kilocode, opencode]