@threadcrumbs/common 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/LICENSE.md +7 -0
  2. package/README.md +108 -3
  3. package/harnesses/README.md +213 -0
  4. package/harnesses/antigravity.mjs +269 -0
  5. package/harnesses/claude-code.mjs +578 -0
  6. package/harnesses/codex.mjs +419 -0
  7. package/harnesses/cursor.mjs +199 -0
  8. package/harnesses/hermes.mjs +211 -0
  9. package/harnesses/index.mjs +36 -0
  10. package/harnesses/kilocode.mjs +361 -0
  11. package/harnesses/opencode.mjs +449 -0
  12. package/launch/index.mjs +163 -0
  13. package/launch/macos.mjs +21 -0
  14. package/launch/registry.mjs +52 -0
  15. package/launch/terminal.mjs +213 -0
  16. package/launch/win-terminal.mjs +87 -0
  17. package/launch/windows.mjs +96 -0
  18. package/launch/xdg.mjs +44 -0
  19. package/lib/anthropic-prices.mjs +70 -0
  20. package/lib/claude-desktop.mjs +208 -0
  21. package/lib/claude-transcript.mjs +462 -0
  22. package/lib/cursor-folder.mjs +107 -0
  23. package/lib/fsutil.mjs +191 -0
  24. package/lib/hermes-home.mjs +30 -0
  25. package/lib/jsonl-follower.mjs +45 -0
  26. package/lib/opencode-store.mjs +602 -0
  27. package/lib/sqlite.mjs +29 -0
  28. package/lib/telemetry-fields.mjs +245 -0
  29. package/license/index.mjs +101 -0
  30. package/license/store.mjs +155 -0
  31. package/orgs/index.mjs +157 -0
  32. package/package.json +39 -4
  33. package/telemetry/README.md +148 -0
  34. package/telemetry/antigravity.mjs +268 -0
  35. package/telemetry/claude-code.mjs +370 -0
  36. package/telemetry/codex.mjs +556 -0
  37. package/telemetry/cursor.mjs +276 -0
  38. package/telemetry/hermes.mjs +404 -0
  39. package/telemetry/index.mjs +9 -0
  40. package/telemetry/kilocode.mjs +31 -0
  41. package/telemetry/opencode.mjs +36 -0
@@ -0,0 +1,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
+ }