@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,107 @@
|
|
|
1
|
+
import fsp from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
const isDir = async (p) => {
|
|
5
|
+
try {
|
|
6
|
+
return (await fsp.stat(p)).isDirectory()
|
|
7
|
+
} catch {
|
|
8
|
+
return false
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Turn `Users-me-Documents-GitHub-my-web-app` back into a path.
|
|
14
|
+
*
|
|
15
|
+
* Every separator became a dash and so did every dash already in a folder name, which makes the
|
|
16
|
+
* encoding lossy and the obvious reverse — replace each dash with a slash — wrong for most real
|
|
17
|
+
* repositories. That reverse is wrong for any project with a dash in its name:
|
|
18
|
+
* `my-web-app` comes back as `my/web/app`, `personal-site-2025` as
|
|
19
|
+
* `personal/site/2025`. Since `project` is what claims a hex zone, that is not cosmetic.
|
|
20
|
+
*
|
|
21
|
+
* It is lossier than dashes alone. Any run of characters that is not a letter or a digit became
|
|
22
|
+
* one dash — `C:\` is `c-`, `data_pipeline_v2` is `data-pipeline-v2` — so a folder cannot
|
|
23
|
+
* be found by turning tokens back into a name and asking whether it exists. Instead each folder
|
|
24
|
+
* on disk is encoded the same way and compared with what is left of the name, longest first,
|
|
25
|
+
* backtracking when a greedy match leads nowhere. On Windows the walk starts from the drive the
|
|
26
|
+
* leading letter names; the drive letter's case is whatever the path was opened with, so it and
|
|
27
|
+
* every other comparison there ignores case, as the filesystem does.
|
|
28
|
+
*
|
|
29
|
+
* A repository that has since been deleted cannot be resolved by anyone, and falls back to
|
|
30
|
+
* attaching the rest as a single dashed name — the likelier reading, since a folder name with
|
|
31
|
+
* dashes in it is far more common than four nested single-word folders.
|
|
32
|
+
*/
|
|
33
|
+
export const encodeFolderName = (name) => name.replace(/[^\p{L}\p{N}]+/gu, '-').replace(/^-+|-+$/g, '')
|
|
34
|
+
|
|
35
|
+
/** The folders under `dir` as `[encoded, name]`, longest encoding first, read once per decode. */
|
|
36
|
+
async function encodedChildren(dir, seen) {
|
|
37
|
+
if (seen.has(dir)) return seen.get(dir)
|
|
38
|
+
let out = []
|
|
39
|
+
try {
|
|
40
|
+
out = (await fsp.readdir(dir, { withFileTypes: true }))
|
|
41
|
+
.filter((e) => e.isDirectory() || e.isSymbolicLink())
|
|
42
|
+
.map((e) => [encodeFolderName(e.name), e.name])
|
|
43
|
+
.filter(([encoded]) => encoded)
|
|
44
|
+
.sort((a, b) => b[0].length - a[0].length)
|
|
45
|
+
} catch {
|
|
46
|
+
/* unreadable or gone */
|
|
47
|
+
}
|
|
48
|
+
seen.set(dir, out)
|
|
49
|
+
return out
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function resolveEncoded(rest, from, fold, seen) {
|
|
53
|
+
if (!rest) return from
|
|
54
|
+
const key = fold ? rest.toLowerCase() : rest
|
|
55
|
+
for (const [encoded, name] of await encodedChildren(from, seen)) {
|
|
56
|
+
const e = fold ? encoded.toLowerCase() : encoded
|
|
57
|
+
if (key !== e && !key.startsWith(`${e}-`)) continue
|
|
58
|
+
const candidate = path.join(from, name)
|
|
59
|
+
if (!(await isDir(candidate))) continue
|
|
60
|
+
const found = await resolveEncoded(rest.slice(e.length + 1), candidate, fold, seen)
|
|
61
|
+
if (found) return found
|
|
62
|
+
}
|
|
63
|
+
return ''
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The folder an encoded name stands for. `root` and `windows` exist for the tests; the scan takes
|
|
68
|
+
* the machine's own.
|
|
69
|
+
*/
|
|
70
|
+
export async function decodeFolder(name, { windows = process.platform === 'win32', root } = {}) {
|
|
71
|
+
const drive = windows ? /^([A-Za-z])(?:-(.*))?$/.exec(name) : null
|
|
72
|
+
const start = root || (drive ? `${drive[1].toUpperCase()}:${path.win32.sep}` : path.parse(process.cwd()).root)
|
|
73
|
+
const rest = drive ? drive[2] || '' : name.replace(/^-+/, '')
|
|
74
|
+
const seen = new Map()
|
|
75
|
+
const resolved = await resolveEncoded(rest, start, windows, seen)
|
|
76
|
+
if (resolved) return resolved
|
|
77
|
+
|
|
78
|
+
// Nothing on disk answers to it any more: keep the deepest ancestor that does and let the
|
|
79
|
+
// remainder stand as one name.
|
|
80
|
+
let dir = start
|
|
81
|
+
let left = rest
|
|
82
|
+
for (;;) {
|
|
83
|
+
const key = windows ? left.toLowerCase() : left
|
|
84
|
+
const next = (await encodedChildren(dir, seen)).find(([encoded]) => {
|
|
85
|
+
const e = windows ? encoded.toLowerCase() : encoded
|
|
86
|
+
return key.startsWith(`${e}-`)
|
|
87
|
+
})
|
|
88
|
+
if (!next) break
|
|
89
|
+
dir = path.join(dir, next[1])
|
|
90
|
+
left = left.slice(next[0].length + 1)
|
|
91
|
+
}
|
|
92
|
+
return left ? path.join(dir, left) : dir
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Cursor files a window with no folder open under `empty-window`. That is not a project, and
|
|
97
|
+
* decoded it became a zone called "empty-window" at the root of the disk.
|
|
98
|
+
*/
|
|
99
|
+
const NO_FOLDER = new Set(['empty-window'])
|
|
100
|
+
|
|
101
|
+
const decodeCache = new Map()
|
|
102
|
+
export async function decodeProjectDir(name) {
|
|
103
|
+
if (decodeCache.has(name)) return decodeCache.get(name)
|
|
104
|
+
const out = NO_FOLDER.has(name) ? '' : await decodeFolder(name)
|
|
105
|
+
decodeCache.set(name, out)
|
|
106
|
+
return out
|
|
107
|
+
}
|
package/lib/fsutil.mjs
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Filesystem helpers shared by every harness adapter.
|
|
3
|
+
*
|
|
4
|
+
* Nothing in here knows about a particular harness — an adapter is free to ignore the lot
|
|
5
|
+
* and read its data however it likes. See `harnesses/README.md` for the contract.
|
|
6
|
+
*/
|
|
7
|
+
import fsp from 'node:fs/promises'
|
|
8
|
+
import path from 'node:path'
|
|
9
|
+
|
|
10
|
+
/** Read the first chunk of a file without pulling a 12MB transcript into memory. */
|
|
11
|
+
export async function readHead(file, bytes) {
|
|
12
|
+
const fh = await fsp.open(file, 'r')
|
|
13
|
+
try {
|
|
14
|
+
const buf = Buffer.allocUnsafe(bytes)
|
|
15
|
+
const { bytesRead } = await fh.read(buf, 0, bytes, 0)
|
|
16
|
+
const text = buf.subarray(0, bytesRead).toString('utf8')
|
|
17
|
+
// Drop a trailing partial line so JSON.parse never sees half a record.
|
|
18
|
+
return bytesRead === bytes ? text.slice(0, text.lastIndexOf('\n') + 1) : text
|
|
19
|
+
} finally {
|
|
20
|
+
await fh.close()
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The last `bytes` of a file, with a leading partial line dropped. The mirror of `readHead`,
|
|
26
|
+
* for the questions only the end of a transcript answers — whose turn it is right now.
|
|
27
|
+
*/
|
|
28
|
+
export async function readTail(file, bytes) {
|
|
29
|
+
const fh = await fsp.open(file, 'r')
|
|
30
|
+
try {
|
|
31
|
+
const { size } = await fh.stat()
|
|
32
|
+
const want = Math.min(bytes, size)
|
|
33
|
+
const buf = Buffer.allocUnsafe(want)
|
|
34
|
+
const { bytesRead } = await fh.read(buf, 0, want, size - want)
|
|
35
|
+
const text = buf.subarray(0, bytesRead).toString('utf8')
|
|
36
|
+
return want === size ? text : text.slice(text.indexOf('\n') + 1)
|
|
37
|
+
} finally {
|
|
38
|
+
await fh.close()
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Parse a JSONL blob, skipping the partial or malformed lines a live file always has. */
|
|
43
|
+
export function jsonLines(text) {
|
|
44
|
+
const out = []
|
|
45
|
+
for (const line of text.split('\n')) {
|
|
46
|
+
const t = line.trim()
|
|
47
|
+
if (!t.startsWith('{')) continue
|
|
48
|
+
try {
|
|
49
|
+
out.push(JSON.parse(t))
|
|
50
|
+
} catch {
|
|
51
|
+
/* partial or malformed line — skip */
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return out
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function listFiles(dir, filter) {
|
|
58
|
+
let entries
|
|
59
|
+
try {
|
|
60
|
+
entries = await fsp.readdir(dir, { withFileTypes: true })
|
|
61
|
+
} catch {
|
|
62
|
+
return []
|
|
63
|
+
}
|
|
64
|
+
return entries.filter((e) => e.isFile() && filter(e.name)).map((e) => path.join(dir, e.name))
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function listDirs(dir) {
|
|
68
|
+
try {
|
|
69
|
+
const entries = await fsp.readdir(dir, { withFileTypes: true })
|
|
70
|
+
return entries.filter((e) => e.isDirectory()).map((e) => path.join(dir, e.name))
|
|
71
|
+
} catch {
|
|
72
|
+
return []
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Does this path exist at all? Adapters use it to answer `detect()`. */
|
|
77
|
+
export async function exists(p) {
|
|
78
|
+
try {
|
|
79
|
+
await fsp.access(p)
|
|
80
|
+
return true
|
|
81
|
+
} catch {
|
|
82
|
+
return false
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Where an executable is, as an absolute path, or null. PATH first, then `extraDirs` — the
|
|
88
|
+
* places an installer puts a binary that a server started with a thin PATH (an IDE launcher, a
|
|
89
|
+
* service unit) would not see. Candidates are resolved rather than joined: the caller may spawn
|
|
90
|
+
* from a different working directory than this check ran in, and a relative PATH entry would
|
|
91
|
+
* then name two different files. X_OK alone passes for a directory, hence the stat.
|
|
92
|
+
*
|
|
93
|
+
* It never looks inside an application bundle, and no caller should hand it a path that does.
|
|
94
|
+
* Running a binary out of somebody else's `.app` is how you get the OS blaming us for it.
|
|
95
|
+
*
|
|
96
|
+
* On Windows a bare name is never the file. `claude` is `claude.exe` from the native installer or
|
|
97
|
+
* `claude.cmd` from npm — and npm leaves an extensionless `claude` beside the `.cmd` as well, a
|
|
98
|
+
* shell script for Git Bash that Windows cannot run. Matching that one handed a terminal a file it
|
|
99
|
+
* could only fail on. So a name with no extension is tried with the extensions Windows can launch,
|
|
100
|
+
* and never on its own.
|
|
101
|
+
*
|
|
102
|
+
* `THREADCRUMBS_CLI_DIRS`, when set, stands in for every caller's `extraDirs`, and empty means
|
|
103
|
+
* PATH alone. It is the same kind of override as `CLAUDE_CONFIG_DIR`: the suite uses it to test
|
|
104
|
+
* "no CLI installed" on a machine that has one in `/opt/homebrew/bin`, which an emptied PATH
|
|
105
|
+
* alone would still find. Kept here rather than per adapter, so one switch covers them all.
|
|
106
|
+
*/
|
|
107
|
+
export async function findExecutable(name, extraDirs = []) {
|
|
108
|
+
if (typeof name !== 'string' || !name) return null
|
|
109
|
+
const explicit = name.includes('/') || name.includes(path.sep)
|
|
110
|
+
const onPath = (process.env.PATH || '').split(path.delimiter).filter(Boolean)
|
|
111
|
+
const override = process.env.THREADCRUMBS_CLI_DIRS
|
|
112
|
+
const installDirs = override === undefined ? extraDirs : override.split(path.delimiter).filter(Boolean)
|
|
113
|
+
const launchable = WINDOWS_EXTS.includes(path.extname(name).toLowerCase())
|
|
114
|
+
const names = process.platform === 'win32' && !launchable ? WINDOWS_EXTS.map((ext) => name + ext) : [name]
|
|
115
|
+
|
|
116
|
+
for (const dir of explicit ? ['.'] : [...onPath, ...installDirs]) {
|
|
117
|
+
for (const file of names) {
|
|
118
|
+
const candidate = path.resolve(dir, file)
|
|
119
|
+
if (await isExecutableFile(candidate)) return candidate
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return null
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** What CreateProcess will start, best first. PATHEXT also lists script hosts, which it will not. */
|
|
126
|
+
const WINDOWS_EXTS = ['.exe', '.cmd', '.bat', '.com']
|
|
127
|
+
|
|
128
|
+
async function isExecutableFile(candidate) {
|
|
129
|
+
try {
|
|
130
|
+
await fsp.access(candidate, fsp.constants.X_OK)
|
|
131
|
+
} catch {
|
|
132
|
+
return false
|
|
133
|
+
}
|
|
134
|
+
try {
|
|
135
|
+
return (await fsp.stat(candidate)).isFile()
|
|
136
|
+
} catch {
|
|
137
|
+
// An app execution alias — `wt.exe`, and anything else the Store installs into
|
|
138
|
+
// `%LOCALAPPDATA%\Microsoft\WindowsApps` — is a reparse point only CreateProcess follows:
|
|
139
|
+
// `stat` fails on it with EACCES while the alias itself is there and starts fine. Without
|
|
140
|
+
// this, Windows Terminal was never found and every terminal opened in plain `cmd.exe`.
|
|
141
|
+
if (process.platform !== 'win32') return false
|
|
142
|
+
const link = await fsp.lstat(candidate).catch(() => null)
|
|
143
|
+
return Boolean(link && !link.isDirectory())
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export const num = (v) => {
|
|
148
|
+
const n = Number(v)
|
|
149
|
+
return Number.isFinite(n) ? n : 0
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function parseJsonLine(line) {
|
|
153
|
+
if (!line || line[0] !== '{') return null
|
|
154
|
+
try {
|
|
155
|
+
return JSON.parse(line)
|
|
156
|
+
} catch {
|
|
157
|
+
return null
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const CHUNK_BYTES = 4 * 1024 * 1024
|
|
162
|
+
const NEWLINE = 0x0a
|
|
163
|
+
|
|
164
|
+
export async function readNewLines(file, from, to, carry = Buffer.alloc(0)) {
|
|
165
|
+
const lines = []
|
|
166
|
+
let pending = carry
|
|
167
|
+
const handle = await fsp.open(file, 'r')
|
|
168
|
+
try {
|
|
169
|
+
for (let position = from; position < to; ) {
|
|
170
|
+
const length = Math.min(CHUNK_BYTES, to - position)
|
|
171
|
+
const chunk = Buffer.alloc(length)
|
|
172
|
+
const { bytesRead } = await handle.read(chunk, 0, length, position)
|
|
173
|
+
if (!bytesRead) break
|
|
174
|
+
position += bytesRead
|
|
175
|
+
const joined = pending.length ? Buffer.concat([pending, chunk.subarray(0, bytesRead)]) : chunk.subarray(0, bytesRead)
|
|
176
|
+
const end = joined.lastIndexOf(NEWLINE)
|
|
177
|
+
if (end < 0) {
|
|
178
|
+
pending = joined
|
|
179
|
+
continue
|
|
180
|
+
}
|
|
181
|
+
for (const line of joined.subarray(0, end).toString('utf8').split('\n')) {
|
|
182
|
+
const trimmed = line.trim()
|
|
183
|
+
if (trimmed) lines.push(trimmed)
|
|
184
|
+
}
|
|
185
|
+
pending = Buffer.from(joined.subarray(end + 1))
|
|
186
|
+
}
|
|
187
|
+
} finally {
|
|
188
|
+
await handle.close()
|
|
189
|
+
}
|
|
190
|
+
return { lines, carry: pending }
|
|
191
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import os from 'node:os'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Where Hermes keeps its session store. HERMES_HOME wins everywhere; otherwise
|
|
7
|
+
* a platform default — `~/.hermes` on POSIX (macOS matches Linux, no Darwin
|
|
8
|
+
* special-case upstream) vs `%LOCALAPPDATA%\hermes` on native Windows. On
|
|
9
|
+
* Windows an older `%USERPROFILE%\.hermes` layout still counts when it holds
|
|
10
|
+
* the store; WSL2 follows the Linux layout. `HERMES_DATA_DIR_SUFFIX` is appended
|
|
11
|
+
* to the default folder's name, as Hermes itself does.
|
|
12
|
+
*
|
|
13
|
+
* Pure and injectable so the matrix can be unit-tested without a Mac or
|
|
14
|
+
* Windows box: platform/env/home/exists default to the live process values,
|
|
15
|
+
* so the no-arg call IS the production probe.
|
|
16
|
+
*/
|
|
17
|
+
export function resolveHermesHome({ platform = process.platform, env = process.env, home = os.homedir(), exists = fs.existsSync } = {}) {
|
|
18
|
+
if (env.HERMES_HOME) return env.HERMES_HOME
|
|
19
|
+
const suffix = env.HERMES_DATA_DIR_SUFFIX || ''
|
|
20
|
+
if (platform !== 'win32') return path.join(home, `.hermes${suffix}`)
|
|
21
|
+
const modern = path.join(
|
|
22
|
+
env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'),
|
|
23
|
+
`hermes${suffix}`
|
|
24
|
+
)
|
|
25
|
+
const legacy = path.join(home, `.hermes${suffix}`)
|
|
26
|
+
if (!exists(path.join(modern, 'state.db')) && exists(path.join(legacy, 'state.db'))) {
|
|
27
|
+
return legacy
|
|
28
|
+
}
|
|
29
|
+
return modern
|
|
30
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { parseJsonLine, readNewLines } from './fsutil.mjs'
|
|
2
|
+
|
|
3
|
+
export function jsonlFollower({ empty, apply }) {
|
|
4
|
+
const files = new Map()
|
|
5
|
+
|
|
6
|
+
async function advance(entry, file, size) {
|
|
7
|
+
if (!entry.state || size < entry.state.offset) entry.state = { ...empty(), offset: 0, carry: Buffer.alloc(0) }
|
|
8
|
+
const state = entry.state
|
|
9
|
+
if (size === state.offset) return state
|
|
10
|
+
try {
|
|
11
|
+
const { lines, carry } = await readNewLines(file, state.offset, size, state.carry)
|
|
12
|
+
state.offset = size
|
|
13
|
+
state.carry = carry
|
|
14
|
+
for (const line of lines) {
|
|
15
|
+
const record = parseJsonLine(line)
|
|
16
|
+
if (record) apply(state, record)
|
|
17
|
+
}
|
|
18
|
+
} catch (err) {
|
|
19
|
+
entry.state = null
|
|
20
|
+
throw err
|
|
21
|
+
}
|
|
22
|
+
return state
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function read(file, stat) {
|
|
26
|
+
let entry = files.get(file)
|
|
27
|
+
if (!entry) {
|
|
28
|
+
entry = { state: null, queue: Promise.resolve(), seen: true }
|
|
29
|
+
files.set(file, entry)
|
|
30
|
+
}
|
|
31
|
+
entry.seen = true
|
|
32
|
+
const next = entry.queue.then(() => advance(entry, file, stat.size))
|
|
33
|
+
entry.queue = next.catch(() => {})
|
|
34
|
+
return next
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function sweep() {
|
|
38
|
+
for (const [file, entry] of files) {
|
|
39
|
+
if (entry.seen) entry.seen = false
|
|
40
|
+
else files.delete(file)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return { read, sweep }
|
|
45
|
+
}
|