@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.
Files changed (41) hide show
  1. package/LICENSE.md +7 -0
  2. package/README.md +108 -3
  3. package/harnesses/README.md +213 -0
  4. package/harnesses/antigravity.mjs +269 -0
  5. package/harnesses/claude-code.mjs +578 -0
  6. package/harnesses/codex.mjs +419 -0
  7. package/harnesses/cursor.mjs +199 -0
  8. package/harnesses/hermes.mjs +211 -0
  9. package/harnesses/index.mjs +36 -0
  10. package/harnesses/kilocode.mjs +361 -0
  11. package/harnesses/opencode.mjs +449 -0
  12. package/launch/index.mjs +163 -0
  13. package/launch/macos.mjs +21 -0
  14. package/launch/registry.mjs +52 -0
  15. package/launch/terminal.mjs +213 -0
  16. package/launch/win-terminal.mjs +87 -0
  17. package/launch/windows.mjs +96 -0
  18. package/launch/xdg.mjs +44 -0
  19. package/lib/anthropic-prices.mjs +70 -0
  20. package/lib/claude-desktop.mjs +208 -0
  21. package/lib/claude-transcript.mjs +462 -0
  22. package/lib/cursor-folder.mjs +107 -0
  23. package/lib/fsutil.mjs +191 -0
  24. package/lib/hermes-home.mjs +30 -0
  25. package/lib/jsonl-follower.mjs +45 -0
  26. package/lib/opencode-store.mjs +602 -0
  27. package/lib/sqlite.mjs +29 -0
  28. package/lib/telemetry-fields.mjs +245 -0
  29. package/license/index.mjs +101 -0
  30. package/license/store.mjs +155 -0
  31. package/orgs/index.mjs +157 -0
  32. package/package.json +39 -4
  33. package/telemetry/README.md +148 -0
  34. package/telemetry/antigravity.mjs +268 -0
  35. package/telemetry/claude-code.mjs +370 -0
  36. package/telemetry/codex.mjs +556 -0
  37. package/telemetry/cursor.mjs +276 -0
  38. package/telemetry/hermes.mjs +404 -0
  39. package/telemetry/index.mjs +9 -0
  40. package/telemetry/kilocode.mjs +31 -0
  41. package/telemetry/opencode.mjs +36 -0
@@ -0,0 +1,602 @@
1
+ import fsp from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import { columnsOf, tablesOf, withReadOnly } from './sqlite.mjs'
4
+ import {
5
+ DETAIL_LIMITS,
6
+ RECENT_MS,
7
+ activityDetail,
8
+ addFileChange,
9
+ clip,
10
+ clipBlock,
11
+ emptyActivity,
12
+ endCall,
13
+ fileList,
14
+ keepNewest,
15
+ number,
16
+ planItems,
17
+ planSummary,
18
+ settleCalls,
19
+ startCall,
20
+ text,
21
+ titleFromPrompt,
22
+ toolFile,
23
+ toolInput,
24
+ toolTarget,
25
+ } from './telemetry-fields.mjs'
26
+
27
+ const ACTIVE_WINDOW_MS = 30 * 60 * 1000
28
+ const SUBAGENT_WINDOW_MS = 10 * 60 * 1000
29
+ const OPEN_TOOL = new Set(['pending', 'streaming', 'running'])
30
+ const ABORTED = /abort/i
31
+ const STOPPED = /user rejected permission|permission.{0,20}denied|denied.{0,20}permission|aborted|cancelled/i
32
+ const QUESTION_TOOL = 'question'
33
+ const SUBAGENT_TOOLS = new Set(['task', 'subagent'])
34
+ const TODO_TOOL = 'todowrite'
35
+ const CATALOG_KEY = 'models-dev:v2:catalog'
36
+ const TURN_MESSAGES = ['user', 'assistant', 'shell', 'synthetic', 'compaction', 'idle']
37
+ const USAGE_COLUMNS = ['cost', 'tokens_input', 'tokens_output', 'tokens_reasoning', 'tokens_cache_read', 'tokens_cache_write']
38
+
39
+ function parse(raw) {
40
+ try {
41
+ const value = typeof raw === 'string' ? JSON.parse(raw) : raw
42
+ return value && typeof value === 'object' ? value : {}
43
+ } catch {
44
+ return {}
45
+ }
46
+ }
47
+
48
+ const list = (value) => (Array.isArray(value) ? value : Object.values(value || {}))
49
+
50
+ function addTokens(total, t) {
51
+ if (!t) return total
52
+ const next = total || { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
53
+ next.input += number(t.input)
54
+ next.output += number(t.output) + number(t.reasoning)
55
+ next.cacheRead += number(t.cache?.read)
56
+ next.cacheWrite += number(t.cache?.write)
57
+ return next
58
+ }
59
+
60
+ const windowOf = (t) => number(t?.input) + number(t?.output) + number(t?.reasoning) + number(t?.cache?.read) + number(t?.cache?.write)
61
+
62
+ function usageFromColumns(row) {
63
+ if (row.tokens_input == null && row.cost == null) return null
64
+ return {
65
+ tokens: {
66
+ input: number(row.tokens_input),
67
+ output: number(row.tokens_output) + number(row.tokens_reasoning),
68
+ cacheRead: number(row.tokens_cache_read),
69
+ cacheWrite: number(row.tokens_cache_write),
70
+ },
71
+ cost: row.cost == null ? null : number(row.cost),
72
+ }
73
+ }
74
+
75
+ const pickColumns = (cols, names) => names.map((c) => (cols.has(c) ? c : `NULL AS ${c}`)).join(', ')
76
+
77
+ export function opencodeStore({ id, name, dbPath, watchDirs, limitsFile, continuing = ['tool-calls'], liveSummary = false, now = Date.now }) {
78
+ const facts = new Map()
79
+ const options = { id, continuing: new Set(continuing), liveSummary }
80
+ let limits = { file: '', mtime: -1, byModel: new Map() }
81
+
82
+ async function fileLimits() {
83
+ const file = await limitsFile()
84
+ const stat = file ? await fsp.stat(file).catch(() => null) : null
85
+ if (!stat) return new Map()
86
+ if (limits.file === file && limits.mtime === stat.mtimeMs) return limits.byModel
87
+ const byModel = new Map()
88
+ for (const [providerId, provider] of Object.entries(parse(await fsp.readFile(file, 'utf8').catch(() => '{}')))) {
89
+ for (const [modelId, model] of Object.entries(provider?.models || {})) {
90
+ const limit = number(model?.limit?.context)
91
+ if (limit) byModel.set(`${providerId}/${modelId}`, limit)
92
+ }
93
+ }
94
+ limits = { file, mtime: stat.mtimeMs, byModel }
95
+ return byModel
96
+ }
97
+
98
+ async function scanSessions() {
99
+ const file = await dbPath()
100
+ if (!file) return []
101
+ const context = { ...options, facts, seen: new Set(), byModel: new Map(await fileLimits()), at: now() }
102
+ const sessions = await withReadOnly(
103
+ file,
104
+ (db) => {
105
+ const tables = tablesOf(db)
106
+ const byId = new Map()
107
+ const keep = (s) => {
108
+ const existing = byId.get(s.id)
109
+ if (!existing || s.lastActivityAt >= existing.lastActivityAt) byId.set(s.id, s)
110
+ }
111
+ if (['session', 'message', 'part'].every((t) => tables.has(t))) legacySessions(db, tables, context).forEach(keep)
112
+ if (tables.has('session_v2') && tables.has('session_message')) {
113
+ if (tables.has('kv')) catalogLimits(db, context.byModel)
114
+ currentSessions(db, tables, context).forEach(keep)
115
+ }
116
+ return [...byId.values()]
117
+ },
118
+ [],
119
+ )
120
+ for (const key of facts.keys()) if (!context.seen.has(key)) facts.delete(key)
121
+ return sessions
122
+ }
123
+
124
+ async function sessionDetail(key) {
125
+ const prefix = `${id}:`
126
+ const sessionId = String(key).startsWith(prefix) ? String(key).slice(prefix.length) : ''
127
+ const file = sessionId ? await dbPath() : ''
128
+ if (!file) return null
129
+ const context = { ...options, facts, seen: new Set(), byModel: new Map(), at: now() }
130
+ return withReadOnly(
131
+ file,
132
+ (db) => {
133
+ const tables = tablesOf(db)
134
+ const legacy = ['session', 'message', 'part'].every((t) => tables.has(t)) ? sessionRow(db, 'session', sessionId) : null
135
+ const current = tables.has('session_v2') && tables.has('session_message') ? sessionRow(db, 'session_v2', sessionId) : null
136
+ if (!legacy && !current) return null
137
+ const useCurrent = current && (!legacy || number(current.time_updated) >= number(legacy.time_updated))
138
+ const detail = useCurrent ? currentDetail(db, tables, current, context) : legacyDetail(db, tables, legacy, context)
139
+ return { id: `${id}:${sessionId}`, sessionId, source: file, ...detail }
140
+ },
141
+ null,
142
+ )
143
+ }
144
+
145
+ return {
146
+ id,
147
+ name,
148
+ detect: async () => Boolean(await dbPath()),
149
+ watchRoots: () => watchDirs,
150
+ scanSessions,
151
+ sessionDetail,
152
+ }
153
+ }
154
+
155
+ const ROW_COLUMNS = ['title', 'parent_id', 'model', 'time_archived', 'time_idle', 'time_busy', 'idle_outcome', ...USAGE_COLUMNS]
156
+
157
+ function sessionRows(db, table, where, value) {
158
+ const cols = columnsOf(db, table)
159
+ if (!['id', 'directory', 'time_created', 'time_updated'].every((c) => cols.has(c))) return []
160
+ if (where === 'parent_id' && !cols.has('parent_id')) return []
161
+ return db.prepare(`SELECT id, directory, time_created, time_updated, ${pickColumns(cols, ROW_COLUMNS)} FROM ${table} WHERE ${where} = ?`).all(value)
162
+ }
163
+
164
+ function sessionRow(db, table, sessionId) {
165
+ const row = sessionRows(db, table, 'id', sessionId)[0]
166
+ return row && !row.parent_id && row.time_archived == null ? row : null
167
+ }
168
+
169
+ function addModelUsage(models, model, tokens, cost) {
170
+ if (!model) return
171
+ const entry = models.get(model) || { model, tokens: null, costUsd: null }
172
+ entry.tokens = addTokens(entry.tokens, tokens)
173
+ if (cost != null) entry.costUsd = (entry.costUsd || 0) + number(cost)
174
+ models.set(model, entry)
175
+ }
176
+
177
+ function modelList(models) {
178
+ return [...models.values()].map((m) => ({ model: m.model, tokens: m.tokens || { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, costUsd: m.costUsd }))
179
+ }
180
+
181
+ function childList(rows, turnOf) {
182
+ return rows
183
+ .filter((r) => r.time_archived == null)
184
+ .map((r) => {
185
+ const turn = turnOf(r)
186
+ return {
187
+ id: r.id,
188
+ name: text(r.title).trim() || (turn.firstPrompt ? titleFromPrompt(turn.firstPrompt) : '') || 'Subagent',
189
+ type: '',
190
+ status: turn.open ? 'working' : turn.failed ? 'error' : 'done',
191
+ model: turn.model,
192
+ tokens: turn.tokens ? { ...turn.tokens } : null,
193
+ tool: turn.open && turn.tool ? { ...turn.tool } : null,
194
+ startedAt: number(r.time_created),
195
+ lastActivityAt: number(r.time_updated),
196
+ }
197
+ })
198
+ .sort((a, b) => a.startedAt - b.startedAt)
199
+ .slice(-DETAIL_LIMITS.subagents)
200
+ }
201
+
202
+ function legacyDetail(db, tables, row, context) {
203
+ const directory = text(row.directory)
204
+ const activity = emptyActivity()
205
+ const history = []
206
+ const prompts = []
207
+ const errors = []
208
+ const files = new Map()
209
+ const models = new Map()
210
+ let contextParts = null
211
+ const typed = new Set(
212
+ db
213
+ .prepare(`SELECT message_id FROM part WHERE session_id = ? AND data LIKE '%"type":"text"%' AND data NOT LIKE '%"synthetic":true%'`)
214
+ .all(row.id)
215
+ .map((r) => r.message_id),
216
+ )
217
+ const modelOf = new Map()
218
+ const messageUsage = []
219
+ for (const message of db.prepare('SELECT id, time_created, data FROM message WHERE session_id = ? ORDER BY time_created, rowid').all(row.id)) {
220
+ const data = parse(message.data)
221
+ const at = number(data.time?.created) || number(message.time_created)
222
+ if (data.role === 'user') {
223
+ if (typed.has(message.id)) {
224
+ const prompt = firstText(db, message.id)
225
+ if (prompt) keepNewest(prompts, { at, text: clipBlock(prompt, DETAIL_LIMITS.promptText) }, DETAIL_LIMITS.prompts)
226
+ }
227
+ for (const diff of Array.isArray(data.summary?.diffs) ? data.summary.diffs : []) {
228
+ if (text(diff?.file)) addFileChange(files, text(diff.file), number(diff.additions), number(diff.deletions))
229
+ }
230
+ continue
231
+ }
232
+ if (data.role !== 'assistant') continue
233
+ const model = text(data.modelID)
234
+ modelOf.set(message.id, model)
235
+ messageUsage.push([model, data.tokens, data.cost])
236
+ if (number(data.tokens?.output) > 0) {
237
+ const output = number(data.tokens.output) + number(data.tokens.reasoning)
238
+ keepNewest(history, { at: number(data.time?.completed) || at, context: windowOf(data.tokens), output }, DETAIL_LIMITS.history)
239
+ contextParts = { input: number(data.tokens.input), cacheRead: number(data.tokens.cache?.read), cacheWrite: number(data.tokens.cache?.write), output: number(data.tokens.output) + number(data.tokens.reasoning) }
240
+ }
241
+ const error = text(data.error?.name)
242
+ if (error && !ABORTED.test(error)) {
243
+ keepNewest(errors, { at, message: clip(text(data.error?.data?.message) || error, DETAIL_LIMITS.errorText) }, DETAIL_LIMITS.errors)
244
+ }
245
+ }
246
+
247
+ const steps = db.prepare(`SELECT message_id, data FROM part WHERE session_id = ? AND data LIKE '%"type":"step-finish"%' ORDER BY rowid`).all(row.id)
248
+ if (steps.length) {
249
+ for (const step of steps) {
250
+ const data = parse(step.data)
251
+ addModelUsage(models, modelOf.get(step.message_id), data.tokens, data.cost)
252
+ }
253
+ } else {
254
+ for (const [model, tokens, cost] of messageUsage) addModelUsage(models, model, tokens, cost)
255
+ }
256
+
257
+ for (const part of db.prepare(`SELECT id, data FROM part WHERE session_id = ? AND data LIKE '%"type":"tool"%' ORDER BY rowid`).all(row.id)) {
258
+ const data = parse(part.data)
259
+ if (data.type !== 'tool') continue
260
+ const state = data.state || {}
261
+ const callId = text(data.callID) || part.id
262
+ const call = { id: callId, name: text(data.tool), target: toolTarget(state.input, directory), input: toolInput(state.input), ...toolFile(state.input, directory) }
263
+ if (!startCall(activity, { ...call, startedAt: number(state.time?.start) })) continue
264
+ if (state.status === 'completed' || state.status === 'error') {
265
+ const failed = state.status === 'error' && !STOPPED.test(`${text(state.error)}\n${text(state.output)}`)
266
+ endCall(activity, callId, number(state.time?.end), failed, text(state.error) || text(state.output))
267
+ }
268
+ }
269
+ const turnOf = (r) => cached(context, `v1:${r.id}`, number(r.time_updated), () => legacyTurn(db, r, context))
270
+ if (!turnOf(row).open) settleCalls(activity)
271
+
272
+ const todoCols = tables.has('todo') ? columnsOf(db, 'todo') : new Set()
273
+ const plan = ['session_id', 'content', 'status'].every((c) => todoCols.has(c))
274
+ ? db.prepare(`SELECT content, status FROM todo WHERE session_id = ? ORDER BY ${todoCols.has('position') ? 'position' : 'rowid'}`).all(row.id)
275
+ : []
276
+
277
+ return {
278
+ models: modelList(models),
279
+ history,
280
+ compactions: null,
281
+ contextParts,
282
+ ...activityDetail(activity),
283
+ plan: planItems(plan.map((t) => ({ content: text(t.content), status: text(t.status) }))),
284
+ subagents: childList(sessionRows(db, 'session', 'parent_id', row.id), turnOf),
285
+ files: files.size || !context.liveSummary ? fileList(files, directory) : null,
286
+ prompts,
287
+ errors,
288
+ }
289
+ }
290
+
291
+ function currentDetail(db, tables, row, context) {
292
+ const directory = text(row.directory)
293
+ const activity = emptyActivity()
294
+ const history = []
295
+ const prompts = []
296
+ const errors = []
297
+ const models = new Map()
298
+ let contextParts = null
299
+ for (const message of db.prepare('SELECT type, time_created, data FROM session_message WHERE session_id = ? ORDER BY seq').all(row.id)) {
300
+ const data = parse(message.data)
301
+ const at = number(message.time_created)
302
+ if (message.type === 'user') {
303
+ const prompt = text(data.text).trim()
304
+ if (prompt) keepNewest(prompts, { at, text: clipBlock(prompt, DETAIL_LIMITS.promptText) }, DETAIL_LIMITS.prompts)
305
+ } else if (message.type === 'assistant') {
306
+ addModelUsage(models, text(data.model?.id), data.tokens, data.cost)
307
+ if (number(data.tokens?.output) > 0) {
308
+ keepNewest(history, { at, context: windowOf(data.tokens), output: number(data.tokens.output) + number(data.tokens.reasoning) }, DETAIL_LIMITS.history)
309
+ contextParts = { input: number(data.tokens.input), cacheRead: number(data.tokens.cache?.read), cacheWrite: number(data.tokens.cache?.write), output: number(data.tokens.output) + number(data.tokens.reasoning) }
310
+ }
311
+ } else if (message.type === 'idle' && text(data.outcome) === 'failed') {
312
+ keepNewest(errors, { at, message: clip(text(data.error?.message) || 'The turn failed', DETAIL_LIMITS.errorText) }, DETAIL_LIMITS.errors)
313
+ }
314
+ }
315
+
316
+ const parts = tables.has('session_part') && ['session_id', 'message_id', 'type', 'data'].every((c) => columnsOf(db, 'session_part').has(c))
317
+ let plan = []
318
+ if (parts) {
319
+ for (const part of db.prepare(`SELECT id, data FROM session_part WHERE session_id = ? AND type = 'tool' ORDER BY rowid`).all(row.id)) {
320
+ const data = parse(part.data)
321
+ const state = data.state || {}
322
+ const callId = text(data.callID) || text(data.id) || part.id
323
+ const call = { id: callId, name: text(data.name), target: toolTarget(state.input, directory), input: toolInput(state.input), ...toolFile(state.input, directory) }
324
+ if (!startCall(activity, { ...call, startedAt: number(data.time?.ran) || number(data.time?.created) })) continue
325
+ if (state.status === 'completed' || state.status === 'error') {
326
+ endCall(activity, callId, number(state.time?.end) || number(data.time?.completed), state.status === 'error', text(state.error) || text(state.output))
327
+ }
328
+ if (data.name === TODO_TOOL && state.status === 'completed' && Array.isArray(state.input?.todos)) {
329
+ plan = state.input.todos.map((t) => ({ content: text(t?.content), status: text(t?.status) }))
330
+ }
331
+ }
332
+ }
333
+ const turnOf = (r) => cached(context, `v2:${r.id}`, number(r.time_updated), () => currentTurn(db, r, parts, context))
334
+ if (!turnOf(row).open) settleCalls(activity)
335
+
336
+ return {
337
+ models: modelList(models),
338
+ history,
339
+ compactions: null,
340
+ contextParts,
341
+ ...activityDetail(activity),
342
+ plan: planItems(plan),
343
+ subagents: childList(sessionRows(db, 'session_v2', 'parent_id', row.id), turnOf),
344
+ files: null,
345
+ prompts,
346
+ errors,
347
+ }
348
+ }
349
+
350
+ function catalogLimits(db, byModel) {
351
+ const row = db.prepare('SELECT value FROM kv WHERE key = ?').get(CATALOG_KEY)
352
+ if (!row) return
353
+ const body = parse(parse(row.value).body)
354
+ for (const provider of list(body.providers ?? body)) {
355
+ for (const model of list(provider?.models)) {
356
+ const limit = number(model?.limit?.context)
357
+ if (provider?.id && model?.id && limit) byModel.set(`${provider.id}/${model.id}`, limit)
358
+ }
359
+ }
360
+ }
361
+
362
+ function cached(context, key, updated, compute) {
363
+ context.seen.add(key)
364
+ const hit = context.facts.get(key)
365
+ let turn = hit?.value
366
+ if (!hit || hit.updated !== updated) {
367
+ turn = compute()
368
+ context.facts.set(key, { updated, value: turn })
369
+ }
370
+ const open = turn.busy && context.at - updated < ACTIVE_WINDOW_MS
371
+ return { ...turn, open, tool: open ? turn.tool : null }
372
+ }
373
+
374
+ function statusOf({ open, failed, errands, tool, lastActivityAt, read, at }) {
375
+ if (open) {
376
+ if (tool?.name === QUESTION_TOOL) return 'asking'
377
+ if (errands && (!tool || SUBAGENT_TOOLS.has(tool.name))) return 'delegating'
378
+ return 'working'
379
+ }
380
+ if (errands) return 'delegating'
381
+ if (failed) return 'error'
382
+ if (read) return read.unread ? 'done' : 'idle'
383
+ return at - lastActivityAt < ACTIVE_WINDOW_MS ? 'done' : 'idle'
384
+ }
385
+
386
+ function session(context, row, turn, extra) {
387
+ const directory = text(row.directory)
388
+ const sessionModel = parse(row.model)
389
+ const lastActivityAt = number(row.time_updated)
390
+ const viewed = number(row.time_viewed)
391
+ const read = viewed > 0 ? { unread: number(row.time_idle) > viewed } : null
392
+ const status = statusOf({ ...turn, errands: extra.errands, lastActivityAt, read, at: context.at })
393
+ return {
394
+ id: `${context.id}:${row.id}`,
395
+ harness: context.id,
396
+ title: text(row.title).trim() || (turn.firstPrompt ? titleFromPrompt(turn.firstPrompt) : '') || 'Untitled session',
397
+ project: path.basename(directory.replace(/\\/g, '/')) || 'unknown',
398
+ projectPath: directory,
399
+ worktree: '',
400
+ cwd: directory,
401
+ gitBranch: '',
402
+ model: turn.model || text(sessionModel.id),
403
+ effort: turn.effort || text(sessionModel.variant),
404
+ status,
405
+ live: turn.open,
406
+ startedAt: number(row.time_created),
407
+ lastActivityAt,
408
+ tool: turn.open && turn.tool ? { ...turn.tool } : null,
409
+ tokens: turn.tokens ? { ...turn.tokens } : null,
410
+ context: turn.context ? { used: turn.context, limit: context.byModel.get(`${turn.provider}/${turn.model}`) || 0 } : null,
411
+ costUsd: turn.cost,
412
+ lines: extra.lines,
413
+ plan: planSummary(extra.plan),
414
+ subagents: extra.errands,
415
+ prompts: turn.prompts,
416
+ }
417
+ }
418
+
419
+ function legacySessions(db, tables, context) {
420
+ const cols = columnsOf(db, 'session')
421
+ if (!['id', 'directory', 'time_created', 'time_updated'].every((c) => cols.has(c))) return []
422
+ const rows = db
423
+ .prepare(
424
+ `SELECT id, directory, time_created, time_updated, ${pickColumns(cols, ['title', 'parent_id', 'model', 'time_archived', 'summary_additions', 'summary_deletions', ...USAGE_COLUMNS])} FROM session WHERE time_updated > ?`,
425
+ )
426
+ .all(context.at - RECENT_MS)
427
+ const todoCols = tables.has('todo') ? columnsOf(db, 'todo') : new Set()
428
+ const todos = ['session_id', 'content', 'status'].every((c) => todoCols.has(c))
429
+ const turnOf = (row) => cached(context, `v1:${row.id}`, number(row.time_updated), () => legacyTurn(db, row, context))
430
+ const out = []
431
+ for (const row of rows) {
432
+ if (row.parent_id || row.time_archived != null) continue
433
+ const turn = turnOf(row)
434
+ const errands = rows.filter((r) => r.parent_id === row.id && context.at - number(r.time_updated) < SUBAGENT_WINDOW_MS && turnOf(r).open).length
435
+ const lines = context.liveSummary
436
+ ? row.summary_additions == null && row.summary_deletions == null
437
+ ? null
438
+ : { added: number(row.summary_additions), removed: number(row.summary_deletions) }
439
+ : turn.lines
440
+ const plan = todos
441
+ ? db
442
+ .prepare(`SELECT content, status FROM todo WHERE session_id = ? ORDER BY ${todoCols.has('position') ? 'position' : 'rowid'}`)
443
+ .all(row.id)
444
+ .map((t) => ({ content: text(t.content), status: text(t.status) }))
445
+ : null
446
+ out.push(session(context, row, turn, { errands, lines, plan }))
447
+ }
448
+ return out
449
+ }
450
+
451
+ function legacyTurn(db, row, context) {
452
+ const turn = { busy: false, failed: false, prompts: 0, firstPrompt: '', model: '', provider: '', effort: '', tokens: null, cost: null, context: 0, lines: null, tool: null }
453
+ const typed = new Set(
454
+ db
455
+ .prepare(`SELECT message_id FROM part WHERE session_id = ? AND data LIKE '%"type":"text"%' AND data NOT LIKE '%"synthetic":true%'`)
456
+ .all(row.id)
457
+ .map((r) => r.message_id),
458
+ )
459
+ let last = null
460
+ let lastAssistant = ''
461
+ for (const message of db.prepare('SELECT id, data FROM message WHERE session_id = ? ORDER BY time_created, rowid').all(row.id)) {
462
+ const data = parse(message.data)
463
+ last = data
464
+ if (data.role === 'user') {
465
+ if (typed.has(message.id)) {
466
+ turn.prompts++
467
+ if (!turn.firstPrompt) turn.firstPrompt = firstText(db, message.id)
468
+ }
469
+ if (Array.isArray(data.summary?.diffs)) {
470
+ turn.lines ||= { added: 0, removed: 0 }
471
+ for (const diff of data.summary.diffs) {
472
+ turn.lines.added += number(diff?.additions)
473
+ turn.lines.removed += number(diff?.deletions)
474
+ }
475
+ }
476
+ continue
477
+ }
478
+ if (data.role !== 'assistant') continue
479
+ lastAssistant = message.id
480
+ if (data.modelID) turn.model = text(data.modelID)
481
+ if (data.providerID) turn.provider = text(data.providerID)
482
+ if (data.variant) turn.effort = text(data.variant)
483
+ if (data.cost != null) turn.cost = (turn.cost || 0) + number(data.cost)
484
+ if (number(data.tokens?.output) > 0) turn.context = windowOf(data.tokens)
485
+ }
486
+ const usage = usageFromColumns(row)
487
+ if (usage) {
488
+ turn.tokens = usage.tokens
489
+ if (usage.cost != null) turn.cost = usage.cost
490
+ } else {
491
+ for (const part of db.prepare(`SELECT data FROM part WHERE session_id = ? AND data LIKE '%"type":"step-finish"%'`).all(row.id)) {
492
+ turn.tokens = addTokens(turn.tokens, parse(part.data).tokens)
493
+ }
494
+ }
495
+ const error = text(last?.error?.name)
496
+ const finish = text(last?.finish)
497
+ const ended = Boolean(last?.time?.completed) || Boolean(finish)
498
+ turn.busy = last?.role === 'user' || (last?.role === 'assistant' && !error && (!ended || context.continuing.has(finish)))
499
+ turn.failed = last?.role === 'assistant' && (error ? !ABORTED.test(error) : ended && toolFailed(db, lastAssistant))
500
+ if (turn.busy) turn.tool = runningTool(db, lastAssistant)
501
+ return turn
502
+ }
503
+
504
+ function firstText(db, messageId) {
505
+ for (const part of db.prepare('SELECT data FROM part WHERE message_id = ? ORDER BY rowid LIMIT 8').all(messageId)) {
506
+ const data = parse(part.data)
507
+ if (data.type === 'text' && !data.synthetic && text(data.text).trim()) return text(data.text).trim()
508
+ }
509
+ return ''
510
+ }
511
+
512
+ function toolFailed(db, messageId) {
513
+ for (const part of db.prepare(`SELECT data FROM part WHERE message_id = ? AND data LIKE '%"status":"error"%'`).all(messageId)) {
514
+ const state = parse(part.data).state || {}
515
+ if (!STOPPED.test(`${text(state.error)}\n${text(state.output)}`)) return true
516
+ }
517
+ return false
518
+ }
519
+
520
+ function runningTool(db, messageId) {
521
+ if (!messageId) return null
522
+ let tool = null
523
+ for (const part of db.prepare('SELECT data FROM part WHERE message_id = ? ORDER BY rowid').all(messageId)) {
524
+ const data = parse(part.data)
525
+ if (data.type !== 'tool' || !OPEN_TOOL.has(data.state?.status)) continue
526
+ tool = { name: text(data.tool), startedAt: number(data.state?.time?.start) }
527
+ }
528
+ return tool
529
+ }
530
+
531
+ function currentSessions(db, tables, context) {
532
+ const cols = columnsOf(db, 'session_v2')
533
+ if (!['id', 'directory', 'time_created', 'time_updated'].every((c) => cols.has(c))) return []
534
+ const rows = db
535
+ .prepare(
536
+ `SELECT id, directory, time_created, time_updated, ${pickColumns(cols, ['title', 'parent_id', 'model', 'time_archived', 'time_idle', 'time_viewed', 'time_busy', 'idle_outcome', ...USAGE_COLUMNS])} FROM session_v2 WHERE time_updated > ?`,
537
+ )
538
+ .all(context.at - RECENT_MS)
539
+ const parts = tables.has('session_part') && ['session_id', 'message_id', 'type', 'data'].every((c) => columnsOf(db, 'session_part').has(c))
540
+ const turnOf = (row) => cached(context, `v2:${row.id}`, number(row.time_updated), () => currentTurn(db, row, parts, context))
541
+ const out = []
542
+ for (const row of rows) {
543
+ if (row.parent_id || row.time_archived != null) continue
544
+ const turn = turnOf(row)
545
+ const errands = rows.filter((r) => r.parent_id === row.id && context.at - number(r.time_updated) < SUBAGENT_WINDOW_MS && turnOf(r).open).length
546
+ out.push(session(context, row, turn, { errands, lines: null, plan: turn.plan }))
547
+ }
548
+ return out
549
+ }
550
+
551
+ function currentTurn(db, row, parts, context) {
552
+ const turn = { busy: false, failed: false, prompts: 0, firstPrompt: '', model: '', provider: '', effort: '', tokens: null, cost: null, context: 0, tool: null, plan: null }
553
+ turn.prompts = number(db.prepare(`SELECT COUNT(*) AS n FROM session_message WHERE session_id = ? AND type = 'user'`).get(row.id)?.n)
554
+ turn.firstPrompt = text(parse(db.prepare(`SELECT data FROM session_message WHERE session_id = ? AND type = 'user' ORDER BY seq ASC LIMIT 1`).get(row.id)?.data).text).trim()
555
+ const assistant = db.prepare(`SELECT id, data FROM session_message WHERE session_id = ? AND type = 'assistant' ORDER BY seq DESC LIMIT 1`).get(row.id)
556
+ const latest = parse(assistant?.data)
557
+ turn.model = text(latest.model?.id)
558
+ turn.provider = text(latest.model?.providerID)
559
+ turn.effort = text(latest.model?.variant)
560
+ const withTokens = db
561
+ .prepare(`SELECT data FROM session_message WHERE session_id = ? AND type = 'assistant' AND data LIKE '%"tokens"%' ORDER BY seq DESC LIMIT 1`)
562
+ .get(row.id)
563
+ turn.context = windowOf(parse(withTokens?.data).tokens)
564
+ const usage = usageFromColumns(row)
565
+ if (usage) {
566
+ turn.tokens = usage.tokens
567
+ turn.cost = usage.cost
568
+ }
569
+ const last = db
570
+ .prepare(`SELECT type, data FROM session_message WHERE session_id = ? AND type IN (${TURN_MESSAGES.map(() => '?').join(', ')}) ORDER BY seq DESC LIMIT 1`)
571
+ .get(row.id, ...TURN_MESSAGES)
572
+ const busy = row.time_busy != null ? number(row.time_busy) > number(row.time_idle) : Boolean(last) && last.type !== 'idle'
573
+ turn.busy = busy
574
+ const outcome = text(row.idle_outcome) || (last?.type === 'idle' ? text(parse(last.data).outcome) : '')
575
+ turn.failed = !busy && outcome === 'failed'
576
+ const tools = currentTools(db, row.id, assistant, parts)
577
+ if (busy) turn.tool = tools.running
578
+ turn.plan = tools.todos
579
+ return turn
580
+ }
581
+
582
+ function currentTools(db, sessionId, assistant, parts) {
583
+ const result = { running: null, todos: null }
584
+ let latest = []
585
+ let recent = []
586
+ if (parts && assistant) {
587
+ latest = db.prepare(`SELECT data FROM session_part WHERE session_id = ? AND message_id = ? AND type = 'tool'`).all(sessionId, assistant.id).map((p) => parse(p.data))
588
+ }
589
+ if (parts) {
590
+ recent = db
591
+ .prepare(`SELECT data FROM session_part WHERE session_id = ? AND type = 'tool' AND data LIKE ? ORDER BY rowid DESC LIMIT 20`)
592
+ .all(sessionId, `%"${TODO_TOOL}"%`)
593
+ .map((p) => parse(p.data))
594
+ }
595
+ if (!latest.length && assistant) latest = list(parse(assistant.data).content).filter((c) => c?.type === 'tool')
596
+ for (const part of latest) {
597
+ if (OPEN_TOOL.has(part.state?.status)) result.running = { name: text(part.name), startedAt: number(part.time?.ran) || number(part.time?.created) }
598
+ }
599
+ const todos = recent.find((p) => p.name === TODO_TOOL && p.state?.status === 'completed' && Array.isArray(p.state?.input?.todos))
600
+ if (todos) result.todos = todos.state.input.todos.map((t) => ({ content: text(t?.content), status: text(t?.status) }))
601
+ return result
602
+ }
package/lib/sqlite.mjs ADDED
@@ -0,0 +1,29 @@
1
+ let sqlitePromise
2
+ export const sqliteApi = () => (sqlitePromise ??= import('node:sqlite').catch(() => null))
3
+
4
+ export async function openReadOnly(file) {
5
+ const sqlite = await sqliteApi()
6
+ if (!file || !sqlite?.DatabaseSync) return null
7
+ try {
8
+ return new sqlite.DatabaseSync(file, { readOnly: true })
9
+ } catch {
10
+ return null
11
+ }
12
+ }
13
+
14
+ export async function withReadOnly(file, run, fallback) {
15
+ const db = await openReadOnly(file)
16
+ if (!db) return fallback
17
+ try {
18
+ return await run(db)
19
+ } finally {
20
+ try {
21
+ db.close()
22
+ } catch {}
23
+ }
24
+ }
25
+
26
+ export const tablesOf = (db) =>
27
+ new Set(db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all().map((r) => r.name))
28
+
29
+ export const columnsOf = (db, table) => new Set(db.prepare(`PRAGMA table_info(${table})`).all().map((r) => r.name))