@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,208 @@
1
+ import fsp from 'node:fs/promises'
2
+ import { existsSync, readdirSync } from 'node:fs'
3
+ import os from 'node:os'
4
+ import path from 'node:path'
5
+ import { listDirs, listFiles } from './fsutil.mjs'
6
+
7
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
8
+ const isCliId = (v) => typeof v === 'string' && UUID.test(v)
9
+
10
+ /**
11
+ * Where Electron puts `userData` on each OS. The Claude desktop app's own folder is one level
12
+ * below, named after the app — see `claudeDesktopStores` for why that name is not a constant.
13
+ *
14
+ * On Windows there are two answers, because the app ships two ways. The classic installer writes
15
+ * to `%APPDATA%`, which is what Electron's `userData` means everywhere else. Installed from the
16
+ * Microsoft Store the app is an MSIX package, and MSIX *redirects* what a packaged app believes is
17
+ * `%APPDATA%` into its own private `…\Packages\<family>\LocalCache\Roaming`. The app is installed,
18
+ * running and writing session records — and `%APPDATA%\Claude` does not exist at all.
19
+ *
20
+ * The package folder is matched by prefix rather than named: its suffix is a hash of the
21
+ * publisher. A hard-coded suffix would work until the publisher's hash changed, and then it would
22
+ * look exactly like the app having been uninstalled.
23
+ */
24
+ function userDataRoots() {
25
+ const home = os.homedir()
26
+ switch (process.platform) {
27
+ case 'win32': {
28
+ const roots = [process.env.APPDATA || path.join(home, 'AppData', 'Roaming')]
29
+ const packages = path.join(process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'), 'Packages')
30
+ for (const name of dirNames(packages)) {
31
+ if (name.startsWith('Claude_')) roots.push(path.join(packages, name, 'LocalCache', 'Roaming'))
32
+ }
33
+ return roots
34
+ }
35
+ case 'linux':
36
+ return [process.env.XDG_CONFIG_HOME || path.join(home, '.config')]
37
+ default:
38
+ return [path.join(home, 'Library', 'Application Support')]
39
+ }
40
+ }
41
+
42
+ function dirNames(dir) {
43
+ try {
44
+ return readdirSync(dir, { withFileTypes: true })
45
+ .filter((e) => e.isDirectory())
46
+ .map((e) => e.name)
47
+ } catch {
48
+ return []
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Every session store the desktop app has written, as a list.
54
+ *
55
+ * The app's folder is not always called `Claude`. macOS ships it as `Claude-3p` too, and a second
56
+ * desktop profile gets a folder of its own, `Claude-<profile>`, beside the first. Two can be live
57
+ * at once, and an older install leaves a `Claude` behind that still holds a handful of records and
58
+ * its deletion markers. Picking the first folder that had records meant reading that leftover and
59
+ * missing the one in use: on one Windows machine that was 4 records of 103, so nearly every thread
60
+ * lost its title and `Open` fell back to `claude://resume`, which imports a copy of the thread
61
+ * rather than navigating to it.
62
+ *
63
+ * So every `Claude*` folder holding a `claude-code-sessions` store is read, and the readers merge
64
+ * them the way they already merge two records for one transcript.
65
+ *
66
+ * Callers resolve this once, when they are created. Installing the app while a reader is running
67
+ * therefore needs a restart to be noticed, a deliberate trade against listing these folders on
68
+ * every scan to catch something that happens once. When nothing is found, the classic path stands
69
+ * in so every caller still works against a real path, which `detect()` reads as "no app".
70
+ */
71
+ export function claudeDesktopStores() {
72
+ if (process.env.THREADCRUMBS_CLAUDE_DESKTOP_DIR) return [process.env.THREADCRUMBS_CLAUDE_DESKTOP_DIR]
73
+ const roots = userDataRoots()
74
+ const appFolders = roots.flatMap((root) =>
75
+ dirNames(root)
76
+ .filter((name) => /^Claude(?:$|[-_ ])/.test(name))
77
+ .map((name) => path.join(root, name))
78
+ )
79
+ if (process.platform === 'win32') {
80
+ const relocated = path.join(process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'), 'Claude-Data')
81
+ appFolders.push(...dirNames(relocated).map((name) => path.join(relocated, name)))
82
+ }
83
+ const found = appFolders.map((folder) => path.join(folder, 'claude-code-sessions')).filter((store) => existsSync(store))
84
+ return found.length ? found : [path.join(roots[0], 'Claude', 'claude-code-sessions')]
85
+ }
86
+
87
+ /**
88
+ * `/repo/.claude/worktrees/feature-abc` -> project `/repo`, worktree `feature-abc`.
89
+ * Either separator: on Windows the same cwd arrives as `C:\repo\.claude\worktrees\…`.
90
+ */
91
+ const WORKTREE = /[\\/]\.claude[\\/]worktrees[\\/]([^\\/]+)/
92
+ function splitWorktree(cwd) {
93
+ const m = WORKTREE.exec(cwd)
94
+ if (!m) return { root: cwd, worktree: '' }
95
+ return { root: cwd.slice(0, m.index), worktree: m[1] }
96
+ }
97
+
98
+ /**
99
+ * The desktop app's throwaway sessions each get a fresh folder of their own,
100
+ * `<userData>/scratch-workspaces/<account>/<org>/scratch-2026-09-22-a1b2c3`. Read as projects,
101
+ * every one of them claimed a hex zone for a single bot — twenty-one zones on one machine — so
102
+ * they share one plot, the way a repo's worktrees share its plot, and the folder rides along as
103
+ * the worktree tag.
104
+ */
105
+ const SCRATCH = /^(.*[\\/]scratch-workspaces)[\\/](?:[^\\/]+[\\/]){2}(scratch-[^\\/]+)[\\/]?$/
106
+
107
+ /** The folder that plot stands on: the app's own, so nothing is started in it. */
108
+ export const SCRATCH_ROOT = /[\\/]scratch-workspaces[\\/]?$/
109
+
110
+ export function projectOf(cwd, originCwd) {
111
+ const scratch = SCRATCH.exec(originCwd || cwd || '')
112
+ if (scratch) return { projectPath: scratch[1], project: 'Scratch', worktree: scratch[2] }
113
+ const { root, worktree } = splitWorktree(cwd || '')
114
+ const projectPath = originCwd || root || cwd || ''
115
+ return { projectPath, project: path.basename(projectPath) || projectPath || 'unknown', worktree }
116
+ }
117
+
118
+ /**
119
+ * What deleting a thread in the desktop app leaves behind, in the folder its record was in:
120
+ * `deleted_<cliSessionId>`, holding the deletion time in epoch ms. The record goes; the CLI
121
+ * transcript does not. Without the marker that transcript is indistinguishable from a thread
122
+ * started in a terminal, so a thread you had deleted came back as one.
123
+ */
124
+ const DELETED_MARKER = /^deleted_(.+)$/
125
+ const isRecord = (name) => name.startsWith('local_') && name.endsWith('.json')
126
+
127
+ /**
128
+ * The fields of a desktop record the readers actually use. A record is not small: the app
129
+ * writes its whole MCP connector configuration into each one (`remoteMcpServersConfig`, close
130
+ * to a megabyte on a machine with a dozen connectors), and on one machine the store came to
131
+ * 148 MB over 204 records — read and parsed in full on every 15-second poll, for perhaps
132
+ * 60 KB of fields anyone looks at. Half of every scan, and most of the process's memory.
133
+ * Keeping only these means the cache below holds kilobytes, not the store.
134
+ */
135
+ function pickRecord(r) {
136
+ return {
137
+ sessionId: r.sessionId,
138
+ cliSessionId: r.cliSessionId,
139
+ cwd: r.cwd,
140
+ originCwd: r.originCwd,
141
+ title: r.title,
142
+ bridgeSessionIds: r.bridgeSessionIds,
143
+ model: r.model,
144
+ effort: r.effort,
145
+ createdAt: r.createdAt,
146
+ lastActivityAt: r.lastActivityAt,
147
+ lastFocusedAt: r.lastFocusedAt,
148
+ error: r.error,
149
+ turnOutcome:
150
+ r.postTurnSummary && r.postTurnSummaryFor && r.postTurnSummaryFor === r.lastAssistantUuid
151
+ ? String(r.postTurnSummary.status_category || '')
152
+ : '',
153
+ isStarred: r.isStarred,
154
+ scheduledTaskId: r.scheduledTaskId,
155
+ prState: r.prState,
156
+ isArchived: r.isArchived,
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Every thread the desktop app has a record for, and the ids of the ones it has deleted.
162
+ *
163
+ * Parsed records are kept by file and pinned to the mtime they were read at, so a steady-state
164
+ * scan is one stat per record and no reads.
165
+ */
166
+ export function desktopRecordReader() {
167
+ const recordCache = new Map()
168
+ return async function readDesktopRecords(stores) {
169
+ const records = []
170
+ const deleted = new Set()
171
+ const seen = new Set()
172
+ const accounts = (await Promise.all(stores.map((store) => listDirs(store)))).flat()
173
+ for (const account of accounts) {
174
+ for (const org of await listDirs(account)) {
175
+ for (const file of await listFiles(org, (n) => isRecord(n) || DELETED_MARKER.test(n))) {
176
+ const marker = DELETED_MARKER.exec(path.basename(file))
177
+ if (marker) {
178
+ if (isCliId(marker[1])) deleted.add(marker[1])
179
+ continue
180
+ }
181
+ seen.add(file)
182
+ let stat
183
+ try {
184
+ stat = await fsp.stat(file)
185
+ } catch {
186
+ continue
187
+ }
188
+ const cached = recordCache.get(file)
189
+ if (cached && cached.mtime === stat.mtimeMs) {
190
+ records.push(cached.record)
191
+ continue
192
+ }
193
+ try {
194
+ const record = pickRecord(JSON.parse(await fsp.readFile(file, 'utf8')))
195
+ recordCache.set(file, { mtime: stat.mtimeMs, record })
196
+ records.push(record)
197
+ } catch {
198
+ // The app is partway through writing the record. The previous read is the latest
199
+ // complete copy; without it the thread would drop out for one scan each time the app saves.
200
+ if (cached) records.push(cached.record)
201
+ }
202
+ }
203
+ }
204
+ }
205
+ for (const file of recordCache.keys()) if (!seen.has(file)) recordCache.delete(file)
206
+ return { records, deleted }
207
+ }
208
+ }
@@ -0,0 +1,462 @@
1
+ import fsp from 'node:fs/promises'
2
+ import { requestCost } from './anthropic-prices.mjs'
3
+ import { listFiles, parseJsonLine, readNewLines } from './fsutil.mjs'
4
+ import {
5
+ DETAIL_LIMITS,
6
+ activityDetail,
7
+ addFileChange,
8
+ clip,
9
+ clipBlock,
10
+ diffHunk,
11
+ emptyActivity,
12
+ endCall,
13
+ fileList,
14
+ firstChange,
15
+ keepNewest,
16
+ nextPlan,
17
+ patchLines,
18
+ planItems,
19
+ settleCalls,
20
+ startCall,
21
+ toolFile,
22
+ toolInput,
23
+ toolTarget,
24
+ } from './telemetry-fields.mjs'
25
+
26
+ const HEAD_BYTES = 192 * 1024
27
+ const TAIL_BYTES = 64 * 1024
28
+ const TAIL_LIMIT = 4 * 1024 * 1024
29
+ const NOISE = /<([a-z][\w-]*)(?:\s[^>]*)?>[\s\S]*?<\/\1>/g
30
+
31
+ const AGENT_TOOLS = new Set(['Agent', 'Task'])
32
+ const IDLE_NOTICE = /^\[Cross-session idle notice\] "([^"]+)"/
33
+ const AWAIT_LIMIT = 20
34
+ const PEER_BUSY = new Set(['busy', 'waiting'])
35
+
36
+ const MID_TURN = Object.freeze({ handedBack: false, failed: false })
37
+
38
+ const number = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0)
39
+ const text = (v) => (typeof v === 'string' ? v : '')
40
+
41
+ export const stripNoise = (value) => String(value).replace(NOISE, ' ')
42
+
43
+ const pidAlive = (pid) => {
44
+ try {
45
+ process.kill(pid, 0)
46
+ return true
47
+ } catch (err) {
48
+ return err.code === 'EPERM'
49
+ }
50
+ }
51
+
52
+ export async function readLiveSessions(dir, isAlive = pidAlive) {
53
+ const live = new Map()
54
+ for (const file of await listFiles(dir, (n) => n.endsWith('.json'))) {
55
+ let record
56
+ try {
57
+ record = JSON.parse(await fsp.readFile(file, 'utf8'))
58
+ } catch {
59
+ continue
60
+ }
61
+ if (!record?.sessionId || !record.pid || !isAlive(record.pid)) continue
62
+ live.set(record.sessionId, { pid: record.pid, status: text(record.status), name: text(record.name), socket: text(record.messagingSocketPath) })
63
+ }
64
+ return live
65
+ }
66
+
67
+ export function transcriptReader({ skim = false, detail = false } = {}) {
68
+ const files = new Map()
69
+
70
+ async function advance(entry, file, size) {
71
+ if (!entry.state || size < entry.state.offset) entry.state = emptyState(entry.sidechain, detail)
72
+ const state = entry.state
73
+ if (size === state.offset) return state
74
+ try {
75
+ if (skim && state.offset === 0 && size > HEAD_BYTES + TAIL_BYTES) {
76
+ await skimInto(state, file, size)
77
+ } else {
78
+ const { lines, carry } = await readNewLines(file, state.offset, size, state.carry)
79
+ state.offset = size
80
+ state.carry = carry
81
+ for (const line of lines) applyLine(state, line)
82
+ }
83
+ } catch (err) {
84
+ entry.state = null
85
+ throw err
86
+ }
87
+ return state
88
+ }
89
+
90
+ function read(file, stat, { sidechain = false } = {}) {
91
+ let entry = files.get(file)
92
+ if (!entry || entry.sidechain !== sidechain) {
93
+ entry = { sidechain, state: null, queue: Promise.resolve(), seen: true }
94
+ files.set(file, entry)
95
+ }
96
+ entry.seen = true
97
+ const next = entry.queue.then(() => advance(entry, file, stat.size))
98
+ entry.queue = next.catch(() => {})
99
+ return next
100
+ }
101
+
102
+ function sweep() {
103
+ for (const [file, entry] of files) {
104
+ if (entry.seen) entry.seen = false
105
+ else files.delete(file)
106
+ }
107
+ }
108
+
109
+ return { read, sweep }
110
+ }
111
+
112
+ async function skimInto(state, file, size) {
113
+ const head = await readNewLines(file, 0, HEAD_BYTES)
114
+ const headEnd = HEAD_BYTES - head.carry.length
115
+ for (const line of head.lines) applyLine(state, line)
116
+ for (let bytes = TAIL_BYTES; ; bytes *= 4) {
117
+ const from = Math.max(headEnd, size - bytes)
118
+ const tail = await readNewLines(file, from, size)
119
+ const gap = from > headEnd
120
+ const records = (gap ? tail.lines.slice(1) : tail.lines).map(parseJsonLine).filter(Boolean)
121
+ if (gap && bytes < TAIL_LIMIT && !records.some((r) => isTurn(state, r))) continue
122
+ if (gap) {
123
+ state.open.clear()
124
+ state.ending = null
125
+ state.partial = true
126
+ }
127
+ const headAt = state.lastAt
128
+ for (const record of records) applyRecord(state, record)
129
+ if (gap && state.lastAt === headAt) state.lastAt = 0
130
+ state.offset = size
131
+ state.carry = tail.carry
132
+ return
133
+ }
134
+ }
135
+
136
+ function emptyState(sidechain, detail) {
137
+ return {
138
+ detail: detail ? emptyDetail() : null,
139
+ offset: 0,
140
+ carry: Buffer.alloc(0),
141
+ sidechain,
142
+ partial: false,
143
+ startedAt: 0,
144
+ lastAt: 0,
145
+ customTitle: '',
146
+ aiTitle: '',
147
+ summary: '',
148
+ firstPrompt: '',
149
+ cwd: '',
150
+ firstCwd: '',
151
+ gitBranch: '',
152
+ firstBranch: '',
153
+ model: '',
154
+ effort: '',
155
+ usageById: new Map(),
156
+ costById: new Map(),
157
+ tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
158
+ spend: 0,
159
+ unpriced: false,
160
+ lines: { added: 0, removed: 0 },
161
+ context: 0,
162
+ open: new Map(),
163
+ ending: null,
164
+ handedBackAt: 0,
165
+ awaiting: new Map(),
166
+ cost: null,
167
+ plan: null,
168
+ prompts: 0,
169
+ }
170
+ }
171
+
172
+ function emptyDetail() {
173
+ return {
174
+ history: [],
175
+ compactions: [],
176
+ activity: emptyActivity(),
177
+ files: new Map(),
178
+ prompts: [],
179
+ errors: [],
180
+ models: new Map(),
181
+ contextParts: null,
182
+ }
183
+ }
184
+
185
+ const ownRecord = (state, record) => state.sidechain || !record.isSidechain
186
+
187
+ const isTurn = (state, record) => (record.type === 'user' || record.type === 'assistant') && ownRecord(state, record)
188
+
189
+ function applyLine(state, line) {
190
+ const record = parseJsonLine(line)
191
+ if (record) applyRecord(state, record)
192
+ }
193
+
194
+ function applyRecord(state, record) {
195
+ const at = Date.parse(record.timestamp)
196
+ if (at) {
197
+ if (!state.startedAt) state.startedAt = at
198
+ if (at > state.lastAt) state.lastAt = at
199
+ }
200
+ if (record.customTitle) state.customTitle = text(record.customTitle)
201
+ if (record.aiTitle) state.aiTitle = text(record.aiTitle)
202
+ if (record.type === 'summary' && record.summary && !state.summary) state.summary = text(record.summary)
203
+ if (record.type === 'cost-state') {
204
+ state.cost = {
205
+ usd: number(record.totalCostUSD),
206
+ linesAdded: number(record.totalLinesAdded),
207
+ linesRemoved: number(record.totalLinesRemoved),
208
+ }
209
+ return
210
+ }
211
+ if (record.cwd) {
212
+ state.cwd = text(record.cwd)
213
+ state.firstCwd ||= state.cwd
214
+ }
215
+ if (record.gitBranch && record.gitBranch !== 'HEAD') {
216
+ state.gitBranch = text(record.gitBranch)
217
+ state.firstBranch ||= state.gitBranch
218
+ }
219
+ if (state.detail && record.type === 'system' && ownRecord(state, record)) applySystem(state.detail, record, at || state.lastAt)
220
+ if (!isTurn(state, record)) return
221
+ if (record.type === 'user') applyUser(state, record, at || state.lastAt)
222
+ else applyAssistant(state, record, at || state.lastAt)
223
+ }
224
+
225
+ function applySystem(detail, record, at) {
226
+ if (record.subtype === 'compact_boundary') {
227
+ const meta = record.compactMetadata || {}
228
+ keepNewest(detail.compactions, { at, before: number(meta.preTokens), after: number(meta.postTokens) }, DETAIL_LIMITS.compactions)
229
+ } else if (record.subtype === 'api_error') {
230
+ const message = text(record.error?.message) || text(record.content) || 'API error'
231
+ const retry = record.retryAttempt ? ` (retry ${number(record.retryAttempt)} of ${number(record.maxRetries)})` : ''
232
+ keepNewest(detail.errors, { at, message: clip(`${message}${retry}`, DETAIL_LIMITS.errorText) }, DETAIL_LIMITS.errors)
233
+ }
234
+ }
235
+
236
+ function applyUser(state, record, at) {
237
+ const content = record.message?.content
238
+ let answered = false
239
+ if (Array.isArray(content)) {
240
+ for (const block of content) {
241
+ if (block?.type !== 'tool_result') continue
242
+ state.open.delete(block.tool_use_id)
243
+ answered = true
244
+ const result = record.toolUseResult
245
+ const failed = block.is_error === true || (typeof result === 'string' && /^error/i.test(result.trim()))
246
+ if (failed || result?.success === false) unsubscribe(state, block.tool_use_id)
247
+ const call = state.detail ? endCall(state.detail.activity, block.tool_use_id, at, failed, failed ? errorTextOf(block, result) : null) : null
248
+ if (!failed) countEdits(state, result, call)
249
+ }
250
+ }
251
+ state.ending = MID_TURN
252
+ const notice = record.isMeta ? IDLE_NOTICE.exec(firstText(content)) : null
253
+ if (notice) state.awaiting.delete(notice[1])
254
+ if (answered || record.isMeta || record.isCompactSummary || record.toolUseResult !== undefined) return
255
+ const prompt = stripNoise(firstText(content)).trim()
256
+ if (!prompt || prompt.startsWith('<')) return
257
+ state.open.clear()
258
+ state.prompts++
259
+ if (!state.firstPrompt) state.firstPrompt = prompt
260
+ if (state.detail) {
261
+ settleCalls(state.detail.activity)
262
+ keepNewest(state.detail.prompts, { at, text: clipBlock(prompt, DETAIL_LIMITS.promptText) }, DETAIL_LIMITS.prompts)
263
+ }
264
+ }
265
+
266
+ function hunkLines(hunks) {
267
+ let added = 0
268
+ let removed = 0
269
+ for (const hunk of Array.isArray(hunks) ? hunks : []) {
270
+ const counted = patchLines(hunk?.lines)
271
+ added += counted.added
272
+ removed += counted.removed
273
+ }
274
+ return { added, removed }
275
+ }
276
+
277
+ function editsOf(result) {
278
+ if (!result || typeof result !== 'object') return []
279
+ const edits = []
280
+ const file = text(result.filePath)
281
+ if (file && result.type === 'create' && typeof result.content === 'string') {
282
+ const added = result.content ? result.content.split('\n').length - (result.content.endsWith('\n') ? 1 : 0) : 0
283
+ edits.push({ file, added, removed: 0, created: result.content })
284
+ } else if (file && Array.isArray(result.structuredPatch)) {
285
+ edits.push({ file, ...hunkLines(result.structuredPatch), patch: result.structuredPatch })
286
+ }
287
+ for (const changed of Array.isArray(result.bashEditDiff?.files) ? result.bashEditDiff.files : []) {
288
+ if (text(changed?.filePath)) edits.push({ file: text(changed.filePath), ...hunkLines(changed.hunks), patch: changed.hunks })
289
+ }
290
+ return edits
291
+ }
292
+
293
+ function hunksOf(edit) {
294
+ if (typeof edit.created === 'string') {
295
+ const lines = edit.created.split('\n')
296
+ if (edit.created.endsWith('\n')) lines.pop()
297
+ const kept = lines.slice(0, DETAIL_LIMITS.hunkLines).map((l) => `+${l}`)
298
+ return kept.length ? [diffHunk({ line: 1, lines: kept, more: lines.length - kept.length })] : []
299
+ }
300
+ return (Array.isArray(edit.patch) ? edit.patch : []).map((h) => diffHunk({ line: h?.newStart, oldLine: h?.oldStart, lines: h?.lines }))
301
+ }
302
+
303
+ function countEdits(state, result, call = null) {
304
+ for (const edit of editsOf(result)) {
305
+ state.lines.added += edit.added
306
+ state.lines.removed += edit.removed
307
+ if (!state.detail) continue
308
+ const hunks = hunksOf(edit)
309
+ addFileChange(state.detail.files, edit.file, edit.added, edit.removed, hunks)
310
+ if (call && call.line == null && hunks.length) call.line = firstChange(hunks[0])
311
+ }
312
+ }
313
+
314
+ function errorTextOf(block, result) {
315
+ return firstText(block.content) || (typeof result === 'string' ? result : '')
316
+ }
317
+
318
+ function subscribe(state, block, at) {
319
+ const to = text(block.input?.to)
320
+ if (!to || block.input.notify_when_idle !== true) return
321
+ state.awaiting.delete(to)
322
+ state.awaiting.set(to, { since: at, call: text(block.id) })
323
+ if (state.awaiting.size > AWAIT_LIMIT) state.awaiting.delete(state.awaiting.keys().next().value)
324
+ }
325
+
326
+ function unsubscribe(state, callId) {
327
+ for (const [to, subscription] of state.awaiting) {
328
+ if (subscription.call === callId) state.awaiting.delete(to)
329
+ }
330
+ }
331
+
332
+ function applyAssistant(state, record, at) {
333
+ const message = record.message
334
+ if (!message) return
335
+ const usage = message.usage
336
+ if (message.id && usage) {
337
+ const next = {
338
+ input: number(usage.input_tokens),
339
+ output: number(usage.output_tokens),
340
+ cacheRead: number(usage.cache_read_input_tokens),
341
+ cacheWrite: number(usage.cache_creation_input_tokens),
342
+ }
343
+ const previous = state.usageById.get(message.id)
344
+ for (const key of Object.keys(next)) state.tokens[key] += next[key] - (previous?.[key] || 0)
345
+ state.usageById.set(message.id, next)
346
+ state.context = next.input + next.cacheRead + next.cacheWrite + next.output
347
+ const cost = requestCost(message.model, usage)
348
+ let added = null
349
+ if (cost != null) {
350
+ added = cost - (state.costById.get(message.id) || 0)
351
+ state.costById.set(message.id, cost)
352
+ state.spend += added
353
+ } else if (next.input + next.output + next.cacheRead + next.cacheWrite > 0) {
354
+ state.unpriced = true
355
+ }
356
+ if (state.detail) recordUsage(state.detail, message, next, previous, state.context, at, added)
357
+ }
358
+ if (message.model && message.model !== '<synthetic>') state.model = text(message.model)
359
+ if (record.effort) state.effort = text(record.effort)
360
+ const content = Array.isArray(message.content) ? message.content : []
361
+ let calling = false
362
+ for (const block of content) {
363
+ if (block?.type !== 'tool_use') continue
364
+ calling = true
365
+ if (!state.open.has(block.id)) {
366
+ state.open.set(block.id, { name: text(block.name), startedAt: at })
367
+ if (state.detail) {
368
+ startCall(state.detail.activity, {
369
+ id: text(block.id),
370
+ name: text(block.name),
371
+ target: toolTarget(block.input, state.cwd),
372
+ input: toolInput(block.input),
373
+ ...toolFile(block.input, state.cwd),
374
+ startedAt: at,
375
+ })
376
+ }
377
+ }
378
+ state.plan = nextPlan(state.plan, text(block.name), block.input)
379
+ if (block.name === 'SendMessage') subscribe(state, block, at)
380
+ }
381
+ const handedBack = !calling && message.stop_reason !== 'tool_use'
382
+ state.ending = { handedBack, failed: handedBack && record.isApiErrorMessage === true }
383
+ if (handedBack) {
384
+ state.open.clear()
385
+ state.handedBackAt = at
386
+ }
387
+ if (!state.detail) return
388
+ if (handedBack) settleCalls(state.detail.activity)
389
+ if (record.isApiErrorMessage === true) {
390
+ keepNewest(state.detail.errors, { at, message: clip(firstText(content) || 'API error', DETAIL_LIMITS.errorText) }, DETAIL_LIMITS.errors)
391
+ }
392
+ }
393
+
394
+ function recordUsage(detail, message, next, previous, context, at, cost) {
395
+ const last = detail.history.at(-1)
396
+ if (last?.id === message.id) {
397
+ last.context = context
398
+ last.output = next.output
399
+ } else {
400
+ keepNewest(detail.history, { id: message.id, at, context, output: next.output }, DETAIL_LIMITS.history)
401
+ }
402
+ if (context) detail.contextParts = { input: next.input, cacheRead: next.cacheRead, cacheWrite: next.cacheWrite, output: next.output }
403
+ const model = text(message.model)
404
+ if (!model || model === '<synthetic>') return
405
+ const totals = detail.models.get(model) || { tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, usd: 0, unpriced: false }
406
+ for (const key of Object.keys(next)) totals.tokens[key] += next[key] - (previous?.[key] || 0)
407
+ if (cost == null) totals.unpriced = true
408
+ else totals.usd += cost
409
+ detail.models.set(model, totals)
410
+ }
411
+
412
+ export function transcriptDetail(state) {
413
+ const detail = state.detail
414
+ if (!detail) return null
415
+ const models = [...detail.models].map(([model, m]) => ({ model, tokens: { ...m.tokens }, costUsd: m.unpriced ? null : m.usd }))
416
+ return {
417
+ models,
418
+ contextParts: detail.contextParts ? { ...detail.contextParts } : null,
419
+ history: detail.history.map(({ at, context, output }) => ({ at, context, output })),
420
+ compactions: detail.compactions.map((c) => ({ ...c })),
421
+ ...activityDetail(detail.activity),
422
+ plan: planItems(state.plan),
423
+ files: fileList(detail.files, state.cwd),
424
+ prompts: detail.prompts.map((p) => ({ ...p })),
425
+ errors: detail.errors.map((e) => ({ ...e })),
426
+ }
427
+ }
428
+
429
+ function firstText(content) {
430
+ if (typeof content === 'string') return content
431
+ if (!Array.isArray(content)) return ''
432
+ for (const part of content) {
433
+ if (typeof part === 'string') return part
434
+ if (part?.type === 'text' && typeof part.text === 'string') return part.text
435
+ }
436
+ return ''
437
+ }
438
+
439
+ export function turnEnding(state) {
440
+ const ending = state?.ending || MID_TURN
441
+ const open = state?.open || new Map()
442
+ const onAgents = !ending.handedBack && open.size > 0 && [...open.values()].every((c) => AGENT_TOOLS.has(c.name))
443
+ return { handedBack: ending.handedBack, failed: ending.failed, onAgents }
444
+ }
445
+
446
+ export function awaitedPeers(state, live, handedBackAt = () => 0) {
447
+ if (!state?.awaiting?.size) return []
448
+ const sockets = new Map()
449
+ const names = new Map()
450
+ for (const [sessionId, session] of live) {
451
+ if (session.socket) sockets.set(session.socket, sessionId)
452
+ if (session.name) names.set(session.name, names.has(session.name) ? null : sessionId)
453
+ }
454
+ const peers = new Map()
455
+ for (const [to, { since }] of state.awaiting) {
456
+ const sessionId = sockets.get(to.replace(/^uds:/, '')) ?? names.get(to)
457
+ const peer = sessionId ? live.get(sessionId) : null
458
+ if (!peer || !PEER_BUSY.has(peer.status) || handedBackAt(sessionId) > since) continue
459
+ peers.set(sessionId, { sessionId, name: peer.name, since })
460
+ }
461
+ return [...peers.values()]
462
+ }