@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,370 @@
1
+ import fsp from 'node:fs/promises'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+ import { claudeDesktopStores, desktopRecordReader, projectOf } from '../lib/claude-desktop.mjs'
5
+ import { awaitedPeers, readLiveSessions, transcriptDetail, transcriptReader, turnEnding } from '../lib/claude-transcript.mjs'
6
+ import { exists, listDirs, listFiles, num } from '../lib/fsutil.mjs'
7
+ import { DETAIL_LIMITS, RECENT_MS, number, planSummary, text, titleFromPrompt } from '../lib/telemetry-fields.mjs'
8
+
9
+ export { nextPlan } from '../lib/telemetry-fields.mjs'
10
+
11
+ const STALE_TURN_MS = 2 * 60 * 1000
12
+ const ACTIVE_WINDOW_MS = 30 * 60 * 1000
13
+ const SUBAGENT_WINDOW_MS = 10 * 60 * 1000
14
+ const OLDER_KEPT = 3
15
+ const SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
16
+
17
+ export function claudeCode({
18
+ home = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude'),
19
+ desktop = claudeDesktopStores(),
20
+ now = Date.now,
21
+ isAlive,
22
+ } = {}) {
23
+ const projectsDir = path.join(home, 'projects')
24
+ const liveDir = path.join(home, 'sessions')
25
+ const catalogDir = path.join(home, 'cache', 'model-catalog')
26
+ const transcripts = transcriptReader({ detail: true })
27
+ const subagentFiles = transcriptReader()
28
+ const subagentDetails = transcriptReader({ detail: true })
29
+ const desktopRecords = desktopRecordReader()
30
+ const olderReaders = new Map()
31
+ const metas = new Map()
32
+ let subagentsSweptAt = 0
33
+ let known = new Map()
34
+ let listed = new Map()
35
+ let catalog = { stamp: '', limits: new Map() }
36
+
37
+ async function contextLimits() {
38
+ const files = []
39
+ for (const file of await listFiles(catalogDir, (n) => n.endsWith('.json'))) {
40
+ const stat = await fsp.stat(file).catch(() => null)
41
+ if (stat) files.push({ file, mtime: stat.mtimeMs })
42
+ }
43
+ files.sort((a, b) => a.mtime - b.mtime)
44
+ const stamp = files.map((f) => `${f.file}@${f.mtime}`).join('|')
45
+ if (stamp === catalog.stamp) return catalog.limits
46
+ const limits = new Map()
47
+ for (const { file } of files) {
48
+ try {
49
+ const models = JSON.parse(await fsp.readFile(file, 'utf8'))?.catalog?.config?.models
50
+ for (const m of Array.isArray(models) ? models : []) {
51
+ const limit = number(m?.runtime?.max_input_tokens)
52
+ if (m?.id && limit) limits.set(m.id, limit)
53
+ }
54
+ } catch {}
55
+ }
56
+ catalog = { stamp, limits }
57
+ return limits
58
+ }
59
+
60
+ async function findTranscripts(live) {
61
+ const cutoff = now() - RECENT_MS
62
+ const byId = new Map()
63
+ for (const projectDir of await listDirs(projectsDir)) {
64
+ for (const file of await listFiles(projectDir, (n) => n.endsWith('.jsonl'))) {
65
+ const id = path.basename(file, '.jsonl')
66
+ const stat = await fsp.stat(file).catch(() => null)
67
+ if (!stat || (stat.mtimeMs < cutoff && !live.has(id))) continue
68
+ const existing = byId.get(id)
69
+ if (existing && existing.stat.mtimeMs >= stat.mtimeMs) continue
70
+ byId.set(id, { id, file, stat })
71
+ }
72
+ }
73
+ return [...byId.values()]
74
+ }
75
+
76
+ const subagentDir = (file, sessionId) => path.join(path.dirname(file), sessionId, 'subagents')
77
+
78
+ async function subagentTotals(file, sessionId) {
79
+ const cutoff = now() - SUBAGENT_WINDOW_MS
80
+ const totals = { running: 0, spend: 0, unpriced: false, added: 0, removed: 0 }
81
+ for (const sub of await listFiles(subagentDir(file, sessionId), (n) => n.endsWith('.jsonl'))) {
82
+ const stat = await fsp.stat(sub).catch(() => null)
83
+ if (!stat) continue
84
+ const state = await subagentFiles.read(sub, stat, { sidechain: true }).catch(() => null)
85
+ if (!state) continue
86
+ totals.spend += state.spend
87
+ totals.unpriced ||= state.unpriced
88
+ totals.added += state.lines.added
89
+ totals.removed += state.lines.removed
90
+ if (stat.mtimeMs >= cutoff && !turnEnding(state).handedBack) totals.running++
91
+ }
92
+ return totals
93
+ }
94
+
95
+ function sweepSubagents() {
96
+ if (now() - subagentsSweptAt < SUBAGENT_WINDOW_MS) return
97
+ subagentsSweptAt = now()
98
+ subagentFiles.sweep()
99
+ subagentDetails.sweep()
100
+ metas.clear()
101
+ }
102
+
103
+ async function readMeta(file) {
104
+ const stat = await fsp.stat(file).catch(() => null)
105
+ if (!stat) return null
106
+ const cached = metas.get(file)
107
+ if (cached?.mtime === stat.mtimeMs) return cached.meta
108
+ let meta = null
109
+ try {
110
+ meta = JSON.parse(await fsp.readFile(file, 'utf8'))
111
+ } catch {}
112
+ metas.set(file, { mtime: stat.mtimeMs, meta })
113
+ return meta
114
+ }
115
+
116
+ async function subagentList(file, sessionId) {
117
+ const found = []
118
+ for (const sub of await listFiles(subagentDir(file, sessionId), (n) => n.endsWith('.jsonl'))) {
119
+ const stat = await fsp.stat(sub).catch(() => null)
120
+ if (stat) found.push({ sub, stat })
121
+ }
122
+ found.sort((a, b) => b.stat.mtimeMs - a.stat.mtimeMs)
123
+ const list = []
124
+ for (const { sub, stat } of found.slice(0, DETAIL_LIMITS.subagents)) {
125
+ const state = await subagentFiles.read(sub, stat, { sidechain: true }).catch(() => null)
126
+ if (!state) continue
127
+ const meta = await readMeta(sub.replace(/\.jsonl$/, '.meta.json'))
128
+ const ending = turnEnding(state)
129
+ const lastActivityAt = Math.max(state.lastAt, Math.floor(stat.mtimeMs))
130
+ const open = !ending.handedBack && meta?.finished !== true
131
+ const status = ending.failed ? 'error' : !open ? 'done' : now() - lastActivityAt < SUBAGENT_WINDOW_MS ? 'working' : 'idle'
132
+ list.push({
133
+ id: path.basename(sub, '.jsonl').replace(/^agent-/, ''),
134
+ name: text(meta?.description) || titleFromPrompt(state.firstPrompt) || 'Subagent',
135
+ type: text(meta?.agentType),
136
+ status,
137
+ model: state.model,
138
+ tokens: { ...state.tokens },
139
+ tool: status === 'working' ? currentTool(state.open) : null,
140
+ startedAt: state.startedAt,
141
+ lastActivityAt,
142
+ })
143
+ }
144
+ return list.sort((a, b) => a.startedAt - b.startedAt)
145
+ }
146
+
147
+ async function scanSessions() {
148
+ const [live, limits, marks] = await Promise.all([readLiveSessions(liveDir, isAlive), contextLimits(), desktopRecords(desktop).then(desktopMarks)])
149
+ const found = []
150
+ const files = new Map()
151
+ const states = new Map()
152
+ for (const { id, file, stat } of await findTranscripts(live)) {
153
+ let state
154
+ try {
155
+ state = await transcripts.read(file, stat)
156
+ } catch {
157
+ continue
158
+ }
159
+ files.set(id, file)
160
+ states.set(id, state)
161
+ found.push({ id, file, stat, state })
162
+ }
163
+ const handedBackAt = (sessionId) => states.get(sessionId)?.handedBackAt || 0
164
+ const sessions = []
165
+ for (const { id, file, stat, state } of found) {
166
+ if (marks.get(id)?.archived) continue
167
+ const running = live.get(id)
168
+ if (!running && now() - (state.lastAt || stat.mtimeMs) > RECENT_MS) continue
169
+ const totals = await subagentTotals(file, id)
170
+ const awaiting = running ? awaitedPeers(state, live, handedBackAt) : []
171
+ sessions.push(toSession({ id, state, stat, running, subagents: running ? totals.running : 0, awaiting, totals, limits, mark: marks.get(id), now: now() }))
172
+ }
173
+ known = files
174
+ listed = new Map(sessions.map((s) => [s.id, s]))
175
+ transcripts.sweep()
176
+ sweepSubagents()
177
+ return sessions
178
+ }
179
+
180
+ async function subagentDetail(file, id, agentId) {
181
+ if (!/^[\w-]{1,100}$/.test(agentId)) return null
182
+ const sub = path.join(subagentDir(file, id), `agent-${agentId}.jsonl`)
183
+ const stat = await fsp.stat(sub).catch(() => null)
184
+ if (!stat) return null
185
+ const state = await subagentDetails.read(sub, stat, { sidechain: true })
186
+ return {
187
+ id: `claude-code:${id}`,
188
+ sessionId: id,
189
+ agentId,
190
+ source: sub,
191
+ ...transcriptDetail(state),
192
+ subagents: null,
193
+ }
194
+ }
195
+
196
+ async function locate(id) {
197
+ if (known.has(id)) return known.get(id)
198
+ if (!SESSION_ID.test(id)) return null
199
+ let newest = null
200
+ for (const projectDir of await listDirs(projectsDir)) {
201
+ const file = path.join(projectDir, `${id}.jsonl`)
202
+ const stat = await fsp.stat(file).catch(() => null)
203
+ if (stat && (!newest || stat.mtimeMs > newest.mtime)) newest = { file, mtime: stat.mtimeMs }
204
+ }
205
+ return newest?.file || null
206
+ }
207
+
208
+ function olderReader(file) {
209
+ const reader = olderReaders.get(file) || transcriptReader({ detail: true })
210
+ olderReaders.delete(file)
211
+ olderReaders.set(file, reader)
212
+ while (olderReaders.size > OLDER_KEPT) olderReaders.delete(olderReaders.keys().next().value)
213
+ return reader
214
+ }
215
+
216
+ async function readSession(sessionKey, { subagent = null } = {}) {
217
+ const id = String(sessionKey).replace(/^claude-code:/, '')
218
+ const file = await locate(id)
219
+ const stat = file ? await fsp.stat(file).catch(() => null) : null
220
+ if (!stat) return null
221
+ const state = await (known.get(id) === file ? transcripts : olderReader(file)).read(file, stat)
222
+ const [live, limits, marks] = await Promise.all([readLiveSessions(liveDir, isAlive), contextLimits(), desktopRecords(desktop).then(desktopMarks)])
223
+ if (marks.get(id)?.archived) return null
224
+ const running = live.get(id)
225
+ const totals = await subagentTotals(file, id)
226
+ const awaiting = running ? awaitedPeers(state, live) : []
227
+ const session =
228
+ listed.get(`claude-code:${id}`) ||
229
+ toSession({ id, state, stat, running, subagents: running ? totals.running : 0, awaiting, totals, limits, mark: marks.get(id), now: now() })
230
+ if (subagent != null) return { session, detail: await subagentDetail(file, id, String(subagent)) }
231
+ return {
232
+ session,
233
+ detail: { id: `claude-code:${id}`, sessionId: id, source: file, ...transcriptDetail(state), subagents: await subagentList(file, id) },
234
+ }
235
+ }
236
+
237
+ async function sessionDetail(sessionKey, { subagent = null } = {}) {
238
+ const id = String(sessionKey).replace(/^claude-code:/, '')
239
+ const file = known.get(id)
240
+ const stat = file ? await fsp.stat(file).catch(() => null) : null
241
+ if (!stat) return null
242
+ if (subagent != null) return subagentDetail(file, id, String(subagent))
243
+ const state = await transcripts.read(file, stat)
244
+ return {
245
+ id: `claude-code:${id}`,
246
+ sessionId: id,
247
+ source: file,
248
+ ...transcriptDetail(state),
249
+ subagents: await subagentList(file, id),
250
+ }
251
+ }
252
+
253
+ return {
254
+ id: 'claude-code',
255
+ name: 'Claude Code',
256
+ detect: () => exists(projectsDir),
257
+ watchRoots: () => [projectsDir, liveDir, ...desktop],
258
+ scanSessions,
259
+ sessionDetail,
260
+ readSession,
261
+ }
262
+ }
263
+
264
+
265
+ function contextLimit(limits, model) {
266
+ if (!model) return 0
267
+ const bare = model.replace(/\[.*\]$/, '')
268
+ if (limits.has(bare)) return limits.get(bare)
269
+ for (const [id, limit] of limits) {
270
+ if (bare.startsWith(id) || id.startsWith(bare)) return limit
271
+ }
272
+ return 0
273
+ }
274
+
275
+ function desktopMarks({ records, deleted }) {
276
+ const marks = new Map()
277
+ for (const r of records) {
278
+ if (typeof r.cliSessionId !== 'string' || !r.cliSessionId) continue
279
+ const next = {
280
+ archived: r.isArchived === true || r.isArchived === 'True',
281
+ originCwd: typeof r.originCwd === 'string' ? r.originCwd : '',
282
+ cwd: typeof r.cwd === 'string' ? r.cwd : '',
283
+ focusedAt: num(r.lastFocusedAt),
284
+ activeAt: num(r.lastActivityAt) || num(r.lastFocusedAt) || num(r.createdAt),
285
+ outcome: r.turnOutcome || '',
286
+ failed: Boolean(r.error) || r.turnOutcome === 'failed',
287
+ }
288
+ const seen = marks.get(r.cliSessionId)
289
+ marks.set(
290
+ r.cliSessionId,
291
+ seen
292
+ ? {
293
+ archived: seen.archived && next.archived,
294
+ originCwd: seen.originCwd || next.originCwd,
295
+ cwd: seen.cwd || next.cwd,
296
+ focusedAt: Math.max(seen.focusedAt, next.focusedAt),
297
+ activeAt: Math.max(seen.activeAt, next.activeAt),
298
+ outcome: seen.outcome || next.outcome,
299
+ failed: seen.failed || next.failed,
300
+ }
301
+ : next
302
+ )
303
+ }
304
+ for (const id of deleted) if (!marks.has(id)) marks.set(id, { archived: true, outcome: '', failed: false })
305
+ return marks
306
+ }
307
+
308
+ function statusOf({ running, state, subagents, awaiting, lastAt, mark, unread, now }) {
309
+ const midTurn = Boolean(state.ending && !state.ending.handedBack)
310
+ const { failed, onAgents: onlyAgents } = turnEnding(state)
311
+ const blocked = mark?.outcome === 'blocked'
312
+ const failedTurn = failed || Boolean(mark?.failed)
313
+ if (running) {
314
+ if (running.status === 'waiting') return 'asking'
315
+ const busy = running.status ? running.status === 'busy' : midTurn
316
+ if (busy) return onlyAgents ? 'delegating' : 'working'
317
+ if (subagents || awaiting.length) return 'delegating'
318
+ if (blocked) return 'asking'
319
+ if (failedTurn) return 'error'
320
+ return unread || now - lastAt < ACTIVE_WINDOW_MS ? 'done' : 'idle'
321
+ }
322
+ if (midTurn && now - lastAt < STALE_TURN_MS) return onlyAgents ? 'delegating' : 'working'
323
+ if (blocked) return 'asking'
324
+ if (failedTurn) return 'error'
325
+ return unread ? 'done' : 'idle'
326
+ }
327
+
328
+ function currentTool(open) {
329
+ let latest = null
330
+ for (const call of open.values()) {
331
+ if (!latest || call.startedAt >= latest.startedAt) latest = call
332
+ }
333
+ return latest ? { name: latest.name, startedAt: latest.startedAt } : null
334
+ }
335
+
336
+ function toSession({ id, state, stat, running, subagents, awaiting, totals, limits, mark, now }) {
337
+ const lastActivityAt = state.lastAt || Math.floor(stat.mtimeMs)
338
+ const unread = Boolean(mark) && (state.handedBackAt || mark.activeAt) > mark.focusedAt
339
+ const status = statusOf({ running, state, subagents, awaiting, lastAt: lastActivityAt, mark, unread, now })
340
+ const { projectPath, project, worktree } = projectOf(mark?.cwd || state.firstCwd || state.cwd, mark?.originCwd)
341
+ const limit = contextLimit(limits, state.model)
342
+ const promptTitle = state.firstPrompt ? titleFromPrompt(state.firstPrompt) : ''
343
+ return {
344
+ id: `claude-code:${id}`,
345
+ harness: 'claude-code',
346
+ title: state.customTitle || state.aiTitle || running?.name || state.summary || promptTitle || 'Untitled session',
347
+ project,
348
+ projectPath,
349
+ worktree,
350
+ cwd: state.cwd,
351
+ gitBranch: state.gitBranch,
352
+ model: state.model,
353
+ effort: state.effort,
354
+ status,
355
+ live: Boolean(running),
356
+ startedAt: state.startedAt,
357
+ lastActivityAt,
358
+ tool: status === 'working' || status === 'delegating' ? currentTool(state.open) : null,
359
+ tokens: { ...state.tokens },
360
+ context: state.context ? { used: state.context, limit } : null,
361
+ costUsd: state.unpriced || totals.unpriced ? (state.cost ? state.cost.usd : null) : state.spend + totals.spend,
362
+ lines: { added: state.lines.added + totals.added, removed: state.lines.removed + totals.removed },
363
+ plan: planSummary(state.plan),
364
+ subagents,
365
+ awaiting: awaiting.map((p) => ({ id: `claude-code:${p.sessionId}`, title: p.name })),
366
+ prompts: state.prompts,
367
+ }
368
+ }
369
+
370
+ export default claudeCode()