@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.
- package/LICENSE.md +34 -0
- package/NOTICES.md +33 -0
- package/README.md +109 -3
- package/harnesses/README.md +213 -0
- package/harnesses/antigravity.mjs +269 -0
- package/harnesses/claude-code.mjs +578 -0
- package/harnesses/codex.mjs +419 -0
- package/harnesses/cursor.mjs +199 -0
- package/harnesses/hermes.mjs +211 -0
- package/harnesses/index.mjs +36 -0
- package/harnesses/kilocode.mjs +361 -0
- package/harnesses/opencode.mjs +449 -0
- package/launch/index.mjs +163 -0
- package/launch/macos.mjs +21 -0
- package/launch/registry.mjs +52 -0
- package/launch/terminal.mjs +213 -0
- package/launch/win-terminal.mjs +87 -0
- package/launch/windows.mjs +96 -0
- package/launch/xdg.mjs +44 -0
- package/lib/anthropic-prices.mjs +70 -0
- package/lib/claude-desktop.mjs +208 -0
- package/lib/claude-transcript.mjs +462 -0
- package/lib/cursor-folder.mjs +107 -0
- package/lib/fsutil.mjs +191 -0
- package/lib/hermes-home.mjs +30 -0
- package/lib/jsonl-follower.mjs +45 -0
- package/lib/opencode-store.mjs +602 -0
- package/lib/sqlite.mjs +29 -0
- package/lib/telemetry-fields.mjs +245 -0
- package/license/index.mjs +102 -0
- package/license/store.mjs +155 -0
- package/orgs/index.mjs +157 -0
- package/package.json +40 -4
- package/telemetry/README.md +148 -0
- package/telemetry/antigravity.mjs +268 -0
- package/telemetry/claude-code.mjs +370 -0
- package/telemetry/codex.mjs +556 -0
- package/telemetry/cursor.mjs +276 -0
- package/telemetry/hermes.mjs +404 -0
- package/telemetry/index.mjs +9 -0
- package/telemetry/kilocode.mjs +31 -0
- package/telemetry/opencode.mjs +36 -0
|
@@ -0,0 +1,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
|
+
}
|