@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
package/LICENSE.md ADDED
@@ -0,0 +1,7 @@
1
+ # License
2
+
3
+ Copyright (c) 2026 Jake McKown. All rights reserved.
4
+
5
+ This software is proprietary. No license is granted to use, copy, modify, merge, publish,
6
+ distribute, sublicense or sell it, in whole or in part, except under a separate written
7
+ agreement with the copyright holder.
package/README.md CHANGED
@@ -1,3 +1,108 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
1
+ # Threadcrumbs common
2
+
3
+ Code the Threadcrumbs apps share, so a fix lands once instead of once per app. Today that's the
4
+ harness readers that Threadnauts and Threadgauge both depend on. More will move in as the apps
5
+ need it.
6
+
7
+ ## What's in it
8
+
9
+ | Import | What it is |
10
+ | --- | --- |
11
+ | `@threadcrumbs/common/harnesses` | `HARNESSES`, `harnessById`, `detectedHarnesses`: one session reader per coding-agent harness, which finds every thread and says how to open it. Threadnauts uses these. The contract is [`harnesses/README.md`](harnesses/README.md) |
12
+ | `@threadcrumbs/common/harnesses/<id>` | One session reader on its own, such as `claude-code` |
13
+ | `@threadcrumbs/common/telemetry` | `TELEMETRY`: one telemetry reader per harness, reporting each session's live status, tool, context, tokens, spend, and plan. Threadgauge uses these. The contract, and what each harness records, is in [`telemetry/README.md`](telemetry/README.md) |
14
+ | `@threadcrumbs/common/telemetry/<id>` | One telemetry reader on its own |
15
+ | `@threadcrumbs/common/lib/fsutil` | The file helpers the readers use, including `findExecutable`, which Threadnauts also uses to find terminals |
16
+ | `@threadcrumbs/common/lib/claude-transcript` | The one Claude Code transcript parser both Claude readers use. It reads each transcript incrementally, and in skim mode it reads a new transcript's head and tail instead of all of it |
17
+ | `@threadcrumbs/common/lib/*` | The other helpers the readers share: `jsonl-follower` (incremental JSONL reading), `sqlite` (read-only opens and column checks), `telemetry-fields` (titles and plans), `opencode-store` (the store OpenCode and Kilo Code share), `claude-desktop` (the Claude app's session stores and records, for both Claude readers), `anthropic-prices` (Anthropic's list prices), plus the Cursor folder decoding and Hermes home lookup both readers of those harnesses use |
18
+ | `@threadcrumbs/common/orgs` | Which organization each repo belongs to, so the apps can group repos the same way. `organizationReader()` returns `withOrganizations(threads)`, which tags each thread with `org` by its `projectPath` and reads each folder at most once a minute. `signalsOf(dir)` reads one folder's package name and git origin owner, and `organizationsFor(signals)` combines them. The rules are in [DECISIONS.md](DECISIONS.md#a-repos-organization-comes-from-its-package-name). Node only, and it only reads |
19
+ | `@threadcrumbs/common/launch` | `present(result, via)`: carries out a harness adapter's answer to "open this thread" by fronting the window of a session already running, handing a deep link to the OS, or running the harness's CLI in a new terminal. When nothing on the machine answers the link, it tries the adapter's `fallback` links, such as Claude Code's thread in VS Code, and failing those returns `{ ok: false, error, missing: { name, download, scheme, also }, terminal }`: the app to install, where to get it, other apps that would do, and whether `via: 'terminal'` would work instead. Also `launch(target)` and `resolveFolder(dir)`. Threadgauge uses it, and Threadnauts still has its own copy |
20
+ | `@threadcrumbs/common/license` | The Threadcrumbs license both apps check offline and the key service signs: `createLicenseKeys`, `publicKeyOf`, `licenseClaims`, `signLicense`, `verifyLicense`, and the unlock rule, `paidThrough` and `unlocks`, plus `renewalDue`. Web APIs only, so the same file runs in a Cloudflare Worker. The format and rules are in [DECISIONS.md](DECISIONS.md#licenses-are-signed-by-threadcrumbs-and-checked-offline). `LICENSE_PUBLIC_KEYS` holds the production keys every app trusts, and never a sandbox key |
21
+ | `@threadcrumbs/common/license/store` | Where the license lives on this machine, shared by every app: `licenseStore()` returns `device()`, `current()`, `save(token)`, and `clear()`, over `license.json` in `threadcrumbsDataDir()`. `current().at` is the time to test unlocks at, and `current()` writes nothing on a machine where no one has entered a key. `trustedKeys({ development })` adds a sandbox key in a development build, and `cloudUrl({ development })` is the key service's address: `https://threadcrumbs.io/api`, or the local Worker at `http://localhost:8787/api` in a development build. Node only, so the Worker never loads it |
22
+
23
+ `launch/` is the only part of this package that starts processes, and `license/store.mjs` is the
24
+ only part that writes a file. The readers and `orgs/` import neither.
25
+
26
+ These are likely to follow, each when an app first needs it from here:
27
+ - **Suite discovery:** the `/api/about` identity format and the port search.
28
+ - **Local-server safety:** the Host and Origin checks and the security policy.
29
+ - **Design tokens:** the palette and status colors.
30
+
31
+ ## Using it from an app
32
+
33
+ The apps live beside this repo, in the same folder:
34
+
35
+ ```
36
+ repos/@threadcrumbs/
37
+ common/
38
+ threadgauge/
39
+ threadnauts/
40
+ ```
41
+
42
+ Each app depends on it with `"@threadcrumbs/common": "link:../common"`, so an edit here shows up
43
+ in both apps without a release. Clone this repo next to an app before running `pnpm install`
44
+ there.
45
+
46
+ An app that publishes to npm depends on a published version instead. This package is on npm as
47
+ [`@threadcrumbs/common`](https://www.npmjs.com/package/@threadcrumbs/common).
48
+
49
+ ## Publishing a version
50
+
51
+ 1. Raise `version` in `package.json`, and commit it.
52
+ 2. Run `npm pack --dry-run` and check the list: only `package.json`, `README.md`, `LICENSE.md`,
53
+ and the folders in `files`.
54
+ 3. Tag the commit `v<version>`, and push the commit and the tag.
55
+ 4. Run `npm publish`. `publishConfig` makes it public, and npm asks for your second factor.
56
+ 5. Check it with `npm view @threadcrumbs/common version`.
57
+
58
+ ## Settings it reads
59
+
60
+ Besides each harness's own variables (`CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `OPENCODE_DB`,
61
+ `KILO_DB`, `ANTIGRAVITY_HOME`, `HERMES_HOME`), the readers take five overrides, and `launch/`
62
+ takes one. Tests use the readers' five to fake an install:
63
+
64
+ | Variable | What it does |
65
+ | --- | --- |
66
+ | `THREADCRUMBS_CLI_DIRS` | Replaces the install folders searched for a harness's CLI; empty means `PATH` alone |
67
+ | `THREADCRUMBS_CLAUDE_DESKTOP_DIR` | The Claude desktop app's session store |
68
+ | `THREADCRUMBS_VSCODE_EXTENSIONS_DIR` | VS Code's extensions folder, where Claude Code looks for its VS Code extension to open threads in when the Claude app is missing |
69
+ | `THREADCRUMBS_CURSOR_PROJECTS_DIR` | Cursor's projects folder |
70
+ | `THREADCRUMBS_ANTIGRAVITY_HOME` | Antigravity's home, ahead of `ANTIGRAVITY_HOME` |
71
+ | `THREADCRUMBS_TERMINAL` | The terminal `launch/` opens a CLI in, ahead of `$TERMINAL` (Linux and macOS). `THREADNAUTS_TERMINAL` still works |
72
+
73
+ The license store takes three more:
74
+
75
+ | Variable | What it does |
76
+ | --- | --- |
77
+ | `THREADCRUMBS_DATA_DIR` | The suite's data folder, where `license.json` lives, instead of the platform's own place for it |
78
+ | `THREADCRUMBS_LICENSE_KEYS` | `{ "<kid>": "<public key>" }`, added to the trusted keys only in a development build, so a dev build can check the key service's sandbox licenses |
79
+ | `THREADCRUMBS_CLOUD_URL` | The key service's address, ahead of `cloudUrl()`'s default, in any build |
80
+
81
+ ## Tests
82
+
83
+ ```bash
84
+ pnpm test
85
+ ```
86
+
87
+ `test/harness.test.mjs` fakes an install of each harness in a temp folder for the session readers,
88
+ and each `test/<harness>-telemetry.test.mjs` does the same for that harness's telemetry reader.
89
+ `test/claude-transcript.test.mjs` covers the shared Claude parser on its own.
90
+ `test/shared-ids.test.mjs` has both readers of each harness scan one fake install and checks they
91
+ give its sessions the same ids, which Threadgauge's Open depends on.
92
+ `test/orgs.test.mjs` covers each package manager's name, the git origin, and the combining rule.
93
+ `test/read-only.test.mjs` reads the package's own source and fails if anything under `harnesses/`,
94
+ `telemetry/`, `lib/`, or `orgs/` writes a file, opens a database for writing, starts or signals a process,
95
+ or imports `launch/` or the license store. `test/launch.test.mjs` covers how `present()` refuses and names a missing app,
96
+ starting nothing but a registry query on Windows. `test/license.test.mjs` covers signing and the unlock rule, and
97
+ `test/license-store.test.mjs` covers the shared file, including two apps starting at once on a
98
+ fresh machine and a clock set back.
99
+
100
+ GitHub Actions runs the suite on Windows, macOS, and Linux for every push to `master` and every
101
+ pull request (`.github/workflows/test.yml`). The tests that only run on one OS, like the real
102
+ link checks in `test/launch.test.mjs`, get their run there.
103
+
104
+ Node 22.13 or newer, for `node:sqlite`.
105
+
106
+ ## License
107
+
108
+ Proprietary. See [LICENSE.md](LICENSE.md).
@@ -0,0 +1,213 @@
1
+ # Harness adapters
2
+
3
+ A **harness** is whatever runs the agent threads — Claude Code, Codex CLI, OpenCode, and so on.
4
+ The Threadcrumbs apps do not care which one you use: each asks every harness present on the
5
+ machine for its threads, through these adapters, and shows whatever comes back. Threadnauts
6
+ draws them as bots in its colony, which is where most of the wording below comes from.
7
+
8
+ Adding one is meant to be **one new file in this directory**, plus one line in `index.mjs`.
9
+ Nothing in an app's scanner, its API, or its page should need to change.
10
+ If you find yourself editing those to land a harness, that is a bug in this seam — please say so
11
+ in the PR, because the next person will hit it too.
12
+
13
+ ## The shape of it
14
+
15
+ ```js
16
+ // harnesses/my-harness.mjs
17
+ export default {
18
+ id: 'my-harness', // stable, kebab-case, used as a key — never change it later
19
+ name: 'My Harness', // what a human sees in the UI
20
+ detect, // () => Promise<boolean>
21
+ scanThreads, // () => Promise<Thread[]>
22
+ app, // { name, download }: the app that answers the deep link
23
+ openThread, // (ref) => { ok, url, command?, app } | { ok: false, error }
24
+ newSession, // (dir) => { ok, url, command?, app } | { ok: false, error }
25
+ }
26
+ ```
27
+
28
+ Then, in `index.mjs`:
29
+
30
+ ```js
31
+ import myHarness from './my-harness.mjs'
32
+ export const HARNESSES = [claudeCode, myHarness]
33
+ ```
34
+
35
+ ### `detect()`
36
+
37
+ Is this harness on this machine at all? Usually just "does its data directory exist". Cheap —
38
+ it runs on every scan, so that installing a harness while the colony is open is noticed on the
39
+ next poll. Returning `false` means the harness is skipped entirely, and no bot for it
40
+ ever appears.
41
+
42
+ ### `scanThreads()`
43
+
44
+ The real work: return one `Thread` per session the harness knows about.
45
+
46
+ Throwing is survivable — the scanner logs it and carries on with the other harnesses, so one
47
+ broken adapter costs you its own threads and nothing else. Prefer that over returning junk.
48
+
49
+ ### `openThread(ref)` / `newSession(dir)`
50
+
51
+ Return `{ ok: true, url }` and the server hands that URL to the OS opener. `openThread` gets
52
+ the `ref` from the thread it belongs to; `newSession` gets an absolute directory that the
53
+ server has already checked still exists.
54
+
55
+ Add `command: { argv, cwd }` — the harness's own CLI resuming the same thread, with an absolute `argv[0]` —
56
+ when the CLI is installed, and the server runs it in a terminal for a machine with no desktop app or a person who asked for one.
57
+ Never spawn it yourself.
58
+
59
+ Add `app: { name, download }` too, the same object as the adapter's own `app`: the app that answers
60
+ the deep link, as a person would call it, and the page to download it from. When nothing on the
61
+ machine answers the link, `present()` names that app, so the UI can say what to install instead
62
+ of a click that does nothing. `app.also` can list other apps that would do, such as the Claude Code
63
+ extension for VS Code.
64
+
65
+ When another app can open the thread through links of its own, add `fallback: { app, urls, note }`.
66
+ `present()` tries it when nothing answers `url`: it opens each URL in turn, two seconds apart,
67
+ and returns `note`. Claude Code uses it to open the thread's folder in VS Code and then the
68
+ extension's session link.
69
+
70
+ If your harness has no deep link, return `{ ok: false, error: '…' }` and say why — the UI
71
+ shows the message rather than pretending the click worked.
72
+
73
+ ### There is no `setArchived`, and that is deliberate
74
+
75
+ No adapter writes to a harness. Not the transcripts, not the session records, not one
76
+ flag. Threadnauts records archiving in its own `colony.json` and nowhere else: the thread leaves
77
+ the map and the bot walks back to the ship.
78
+
79
+ It used to write one flag — `isArchived` on Claude Code's own session record — and that write
80
+ genuinely landed on disk. It just did not *mean* anything: the desktop app serves from the copy it
81
+ loaded at launch, so the thread stayed in its list until the app restarted, and the app rewrote the
82
+ record from memory the next time it touched the thread. Holding that together took a re-assert on
83
+ every scan, a `ps` sweep to guess whether the app had re-read the file, and a *pending* state for
84
+ the gap between them. All of that is gone, and the scan no longer starts a subprocess at all.
85
+
86
+ Archiving in the harness's own UI still works and is still the right way to do it — your adapter
87
+ reports it through the `archived` field and the bot goes home on the next poll.
88
+
89
+ ## The `Thread` your adapter returns
90
+
91
+ Only `id` is truly required, but the colony gets duller the more you leave out — `project` is
92
+ what earns a repo its own zone, and `lastActivityAt` is what sorts the whole map.
93
+
94
+ | Field | Type | What it means |
95
+ | --- | --- | --- |
96
+ | `id` | string | **Unique across every harness.** A UUID is fine; otherwise prefix it, e.g. `my-harness:1234` |
97
+ | `title` | string | Thread title. `'Untitled thread'` if the harness has none |
98
+ | `preview` | string | First prompt, trimmed — shown on the thread card |
99
+ | `project` | string | Repo/folder **name**. This is what claims a hex zone |
100
+ | `projectPath` | string | Absolute path to the repo root |
101
+ | `worktree` | string | Worktree name, or `''` |
102
+ | `cwd` | string | Where the thread is actually working |
103
+ | `gitBranch` | string | Branch name, or `''` |
104
+ | `model` / `effort` | string | Shown on the thread card |
105
+ | `createdAt` | number | Epoch ms |
106
+ | `lastActivityAt` | number | Epoch ms. Sorts the colony and drives the "asleep for 3 days" behaviour |
107
+ | `lastFocusedAt` | number | Epoch ms, `0` if unknowable |
108
+ | `running` | boolean | Working **right now** — the bot hammers away |
109
+ | `unread` | boolean | Done: the turn ended and moved on since you last looked — the bot stops and raises a hand |
110
+ | `needsInput` | boolean | Needs you: stopped mid-turn on a permission prompt, a question, or an approval only you can answer — the bot holds a `?` and waves until you do. Outranks every other state |
111
+ | `awaitingSubagents` | boolean | Waiting on bots: its own turn is over, or its only open calls are subagents, and subagents it sent out are still working, or another thread it asked to hear from is still busy — the bot stands at its site under a `…` until they report back. Outranks `running` and `unread` |
112
+ | `hasError` | boolean | Errored — the bot slumps, red eyes |
113
+ | `starred` / `routine` / `prState` | | Optional extras; `prState: 'merged'` triggers the confetti |
114
+ | `archived` | boolean | Archived in the harness's own records. Read-only — reporting it is all an adapter does |
115
+ | `sizeBytes` | number | Transcript size. **This is how finished a building looks**, on a log scale |
116
+ | `source` | string | Free-form, for your own bookkeeping (the Claude adapter uses `desktop` / `cli`) |
117
+ | `canOpen` | boolean | Whether this thread can be opened. The UI greys the button out |
118
+ | `subagents` | array | Optional. Errands this thread has out *right now*: `{ id, task, lastActivityAt }`. Drawn as small companions at the parent's building — no zone, no badge, not counted. Omit it and nothing changes |
119
+ | `awaiting` | array | Optional. Other threads it asked to hear from that are still busy: `{ id, title }`, where `id` is that thread's own id. They are bots of their own, so this names them rather than adding companions. Omit it and nothing changes |
120
+ | `ref` | object | **Opaque.** Whatever *you* need to find this thread again |
121
+
122
+ ### About `ref`
123
+
124
+ `ref` is the whole reason the browser does not know what a session id looks like. Your adapter
125
+ puts whatever it needs in there, the page hands it straight back on open and archive, and
126
+ nothing between the two ever inspects it.
127
+
128
+ Keep it small and keep it serialisable — it makes a round trip through JSON on every action.
129
+ Do not put a file handle, a class instance, or a secret in it.
130
+
131
+ ## Ground rules
132
+
133
+ - **Read-only. No exceptions.** An adapter never writes a file and never starts a process;
134
+ `test/read-only.test.mjs` fails if one tries. A harness's transcripts and records are
135
+ somebody's actual work, and the apps are viewers, not editors. If an adapter seems to need a
136
+ write, it does not — say so in an issue. Anything that has to write to a harness belongs to the
137
+ app that asks for it, outside this package.
138
+ - **Never run anything out of another application's bundle.** Not to read from it, not to
139
+ execute it. Only files under the user's own home directory. Opening a thread goes through a
140
+ URL the OS resolves, or a command the user already has on `PATH`.
141
+ - **Never block the scan.** It runs on a poll. Cache anything expensive against file size and
142
+ mtime. Claude Code's transcripts go through `transcriptReader` in
143
+ `../lib/claude-transcript.mjs`, which reads a new transcript's head and tail once and after
144
+ that only the lines appended since the last scan, so a 12MB transcript is never reparsed.
145
+ - **Read heads, not whole files.** `readHead` in `../lib/fsutil.mjs` pulls the first chunk and
146
+ drops a trailing partial line, so `JSON.parse` never sees half a record.
147
+ - **Expect malformed data.** A session being written *right now* is a normal thing to trip
148
+ over. Skip that record and move on; do not throw the pass away.
149
+ - **Never widen `id` collisions.** The colony keys its archive list and saved layout on `id`.
150
+ Two harnesses handing back the same id would merge two unrelated threads into one bot.
151
+
152
+ ## Starting points
153
+
154
+ Verified on a real machine:
155
+
156
+ - **Claude Code** — desktop records in
157
+ `~/Library/Application Support/Claude/claude-code-sessions/<account>/<org>/local_*.json`
158
+ (`%APPDATA%\Claude\claude-code-sessions\…` on older Windows installs, and
159
+ `%LOCALAPPDATA%\Claude-Data\<install>\claude-code-sessions\…` on current ones, which leave a
160
+ `.user-data-left.<install>` marker in the old folder). The app folder is not always called
161
+ `Claude`: it is `Claude-3p` on some macOS installs, and each extra desktop profile gets its own
162
+ `Claude-<profile>` beside the first, so every `Claude*` folder holding a store is read. In the
163
+ same folder sits a
164
+ `deleted_<cliSessionId>` marker for every thread deleted in the app, holding the deletion time
165
+ in epoch ms — the record goes, the CLI transcript stays, and the marker is all that tells a
166
+ deleted thread from one started in a terminal, so the adapter reports it as `archived`; CLI
167
+ transcripts in `~/.claude/projects/<encoded-cwd>/<sessionId>.jsonl`; live processes in
168
+ `~/.claude/sessions/*.json`, whose `status` is `busy`, `waiting` (a prompt is open, named in
169
+ `waitingFor`) or `idle`, and whose `messagingSocketPath` is the address other sessions message
170
+ it at. A session that sends `SendMessage` with `notify_when_idle: true` is waiting on the
171
+ session at that address until a user record reading `[Cross-session idle notice] "<address>"`
172
+ arrives. `CLAUDE_CONFIG_DIR` (the CLI's own override for `~/.claude`) and
173
+ `THREADCRUMBS_CLAUDE_DESKTOP_DIR` (the session store) point both roots elsewhere, which is how
174
+ `test/harness.test.mjs` fakes an install. Implemented in `claude-code.mjs`.
175
+ - **Codex** — the desktop app, the VS Code extension and the CLI share one store. One row per
176
+ thread (title, cwd, branch, model, effort, archived) in `~/.codex/state_<n>.sqlite`, and
177
+ transcripts in `~/.codex/sessions/YYYY/MM/DD/rollout-<iso>-<uuid>.jsonl`, with records shaped
178
+ `{ timestamp, type, payload }`. They join on the session id, and the transcripts are the only
179
+ source for a session the database has not indexed yet. Renames are also in
180
+ `~/.codex/session_index.jsonl`. `CODEX_HOME` points it elsewhere. Implemented in `codex.mjs`.
181
+
182
+ For anything else, the fastest way in is usually to start a throwaway session in that harness
183
+ and watch which files change:
184
+
185
+ ```bash
186
+ find ~ -maxdepth 4 -newermt '-2 minutes' -type f 2>/dev/null | grep -iv Library/Caches
187
+ ```
188
+
189
+ ## Checking your work
190
+
191
+ `pnpm test` in this repo runs `test/harness.test.mjs`, which fakes an install of each harness in a
192
+ temp folder. Add yours there. Then, against a real install:
193
+
194
+ 1. `node --check harnesses/my-harness.mjs`
195
+ 2. With Threadnauts running, `GET /api/harnesses` lists every registered harness and whether
196
+ `detect()` found it. If yours is missing or `detected: false`, stop here — nothing else
197
+ will work until it shows up:
198
+
199
+ ```bash
200
+ curl -s localhost:5274/api/harnesses
201
+ ```
202
+ 3. Scan straight from node and look at the result — the number should match what the harness
203
+ itself reports, and no field should be `undefined`:
204
+
205
+ ```bash
206
+ node -e 'import("./harnesses/my-harness.mjs").then(async m => {
207
+ const t = await m.default.scanThreads()
208
+ console.log(t.length, "threads"); console.dir(t[0], { depth: 4 })
209
+ })'
210
+ ```
211
+ 4. `pnpm dev` in Threadnauts, then confirm the bots appear on the right plots, the thread card
212
+ fills in, and Open does what you expect.
213
+ 5. Archive one thread and check it shows as archived **in the harness's own UI**, not just here.
@@ -0,0 +1,269 @@
1
+ /**
2
+ * Harness adapter: Antigravity CLI (Google).
3
+ *
4
+ * Antigravity CLI stores sessions under `~/.gemini/antigravity-cli/brain/<sessionId>/`.
5
+ * Transcripts are JSONL files located at `.system_generated/logs/transcript.jsonl`
6
+ * or `transcript.jsonl` inside the session directory.
7
+ *
8
+ * Read-only, no subprocess, and no modifications to harness files.
9
+ */
10
+ import fsp from 'node:fs/promises'
11
+ import path from 'node:path'
12
+ import os from 'node:os'
13
+ import { exists, jsonLines, listDirs, readHead, readTail, findExecutable } from '../lib/fsutil.mjs'
14
+
15
+ const HOME = os.homedir()
16
+ const ANTIGRAVITY_HOME =
17
+ process.env.THREADCRUMBS_ANTIGRAVITY_HOME ||
18
+ process.env.ANTIGRAVITY_HOME ||
19
+ path.join(HOME, '.gemini', 'antigravity-cli')
20
+ const BRAIN_DIR = path.join(ANTIGRAVITY_HOME, 'brain')
21
+
22
+ const HEAD_BYTES = 96 * 1024
23
+ const TAIL_BYTES = 32 * 1024
24
+ const ACTIVE_WINDOW_MS = 30 * 60 * 1000
25
+
26
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
27
+ const APP = Object.freeze({ name: 'Antigravity', download: 'https://antigravity.google/download' })
28
+ const ENDED = new Set(['DONE', 'ERROR', 'CANCELED', 'INTERRUPTED', 'CLEARED', 'INVALID'])
29
+
30
+ /** Prefixed, per the contract in `harnesses/README.md`. */
31
+ const ID = (raw) => `antigravity:${raw}`
32
+
33
+ const clean = (v) => String(v || '').replace(/\s+/g, ' ').trim()
34
+
35
+ function contentText(content) {
36
+ if (typeof content === 'string') return content
37
+ if (!Array.isArray(content)) return ''
38
+ return content
39
+ .map((p) => (typeof p === 'string' ? p : p?.text || p?.input_text || ''))
40
+ .filter(Boolean)
41
+ .join('\n')
42
+ }
43
+
44
+ /** Extract user query text from prompt/input record. */
45
+ function userText(record) {
46
+ const raw = contentText(record?.content || record?.message?.content || '')
47
+ // Strip XML-like wrapper tags if present
48
+ const query = /<USER_REQUEST>\s*([\s\S]*?)\s*<\/USER_REQUEST>/i.exec(raw)
49
+ const text = query ? query[1] : raw.replace(/<[a-z_]+>[\s\S]*?<\/[a-z_]+>/gi, ' ')
50
+ return clean(text)
51
+ }
52
+
53
+ /** Try to extract workspace/cwd from prompt user_information tags if present. */
54
+ function extractCwd(text) {
55
+ const m = /active workspaces, each defined by a URI[\s\S]*?\[(.*?)\]\s*->/i.exec(text)
56
+ if (m && path.isAbsolute(m[1])) return m[1]
57
+ return ''
58
+ }
59
+
60
+ /**
61
+ * Where each conversation ran. A transcript usually does not say, so most sessions arrived with
62
+ * no folder at all and shared one zone called "unknown". The CLI's prompt history beside `brain/`
63
+ * does: every prompt typed is a line of `{ display, timestamp, workspace, conversationId }`. Older
64
+ * lines carry no conversation id, so not every session is covered — those stay unknown rather
65
+ * than being guessed from whichever files the session happened to read.
66
+ *
67
+ * The folder a conversation started in wins. Re-read only when the file changes.
68
+ *
69
+ * The same lines are the only sign of a turn in progress. The transcript is written when a turn
70
+ * ends, so a prompt newer than the transcript has not been answered yet, and a conversation with
71
+ * a prompt and no transcript is on its first turn.
72
+ */
73
+ const HISTORY = path.join(ANTIGRAVITY_HOME, 'history.jsonl')
74
+ let history = { mtime: -1, conversations: new Map() }
75
+
76
+ function epochMs(value) {
77
+ if (typeof value === 'number') return value < 1e11 ? value * 1000 : value
78
+ const t = Date.parse(String(value || ''))
79
+ return Number.isNaN(t) ? 0 : t
80
+ }
81
+
82
+ async function conversations() {
83
+ const st = await fsp.stat(HISTORY).catch(() => null)
84
+ if (!st) return new Map()
85
+ if (st.mtimeMs === history.mtime) return history.conversations
86
+ const out = new Map()
87
+ try {
88
+ for (const r of jsonLines(await fsp.readFile(HISTORY, 'utf8'))) {
89
+ const id = r?.conversationId
90
+ if (typeof id !== 'string') continue
91
+ const entry = out.get(id) || { workspace: '', prompt: '', firstPromptAt: 0, lastPromptAt: 0 }
92
+ const dir = r?.workspace
93
+ const at = epochMs(r?.timestamp)
94
+ if (!entry.workspace && typeof dir === 'string' && path.isAbsolute(dir)) entry.workspace = dir
95
+ if (!entry.prompt && typeof r?.display === 'string') entry.prompt = clean(r.display)
96
+ entry.firstPromptAt ||= at
97
+ entry.lastPromptAt = Math.max(entry.lastPromptAt, at)
98
+ out.set(id, entry)
99
+ }
100
+ } catch {
101
+ /* mid-write, or gone */
102
+ }
103
+ history = { mtime: st.mtimeMs, conversations: out }
104
+ return out
105
+ }
106
+
107
+ async function findTranscriptFile(sessionDir) {
108
+ const candidates = [
109
+ path.join(sessionDir, '.system_generated', 'logs', 'transcript_full.jsonl'),
110
+ path.join(sessionDir, '.system_generated', 'logs', 'transcript.jsonl'),
111
+ path.join(sessionDir, 'transcript.jsonl'),
112
+ ]
113
+ for (const c of candidates) {
114
+ if (await exists(c)) return c
115
+ }
116
+ return null
117
+ }
118
+
119
+ async function scanTranscripts() {
120
+ const out = []
121
+ for (const sessionDir of await listDirs(BRAIN_DIR)) {
122
+ const id = path.basename(sessionDir)
123
+ if (!UUID.test(id)) continue
124
+ const file = await findTranscriptFile(sessionDir)
125
+ if (!file) continue
126
+ try {
127
+ const st = await fsp.stat(file)
128
+ if (!st.size) continue
129
+ out.push({
130
+ id,
131
+ file,
132
+ sessionDir,
133
+ size: st.size,
134
+ mtime: st.mtimeMs,
135
+ born: st.birthtimeMs,
136
+ })
137
+ } catch {
138
+ /* vanished */
139
+ }
140
+ }
141
+ return out
142
+ }
143
+
144
+ const parseCache = new Map()
145
+ async function facts(entry) {
146
+ const hit = parseCache.get(entry.id)
147
+ if (hit && hit.mtime === entry.mtime && hit.size === entry.size) return hit.value
148
+
149
+ const value = { prompt: '', cwd: '', startedAt: 0, closed: true, errored: false, modelsTurn: false }
150
+ try {
151
+ for (const r of jsonLines(await readHead(entry.file, HEAD_BYTES))) {
152
+ if (r?.type === 'USER_INPUT' || r?.role === 'user' || r?.source === 'USER_EXPLICIT') {
153
+ const text = userText(r)
154
+ if (!value.prompt && text) value.prompt = text
155
+ const rawContent = String(r?.content || '')
156
+ if (!value.cwd) value.cwd = r?.cwd || extractCwd(rawContent) || ''
157
+ if (!value.startedAt && r?.created_at) {
158
+ const t = Date.parse(r.created_at)
159
+ if (!Number.isNaN(t)) value.startedAt = t
160
+ }
161
+ if (value.prompt && value.cwd && value.startedAt) break
162
+ }
163
+ }
164
+
165
+ const tail = jsonLines(await readTail(entry.file, TAIL_BYTES))
166
+ if (tail.length > 0) {
167
+ const last = tail[tail.length - 1]
168
+ value.errored = last?.status === 'ERROR' || Boolean(last?.error)
169
+ value.closed = ENDED.has(last?.status)
170
+ value.modelsTurn = !value.closed && (last?.type === 'USER_INPUT' || last?.role === 'user')
171
+ }
172
+ } catch {
173
+ /* mid-write, or vanished */
174
+ }
175
+
176
+ parseCache.set(entry.id, { mtime: entry.mtime, size: entry.size, value })
177
+ return value
178
+ }
179
+
180
+ async function scanThreads() {
181
+ const [entries, history] = await Promise.all([scanTranscripts(), conversations()])
182
+ const now = Date.now()
183
+ const threads = []
184
+ const thread = ({ id, f, typed, mtime, born, size }) => {
185
+ const cwd = f.cwd || typed?.workspace || ''
186
+ const prompt = f.prompt || typed?.prompt || ''
187
+ const lastActivityAt = Math.max(mtime, typed?.lastPromptAt || 0)
188
+ const fresh = now - lastActivityAt < ACTIVE_WINDOW_MS
189
+ const prompted = Boolean(typed) && typed.lastPromptAt > mtime && now - typed.lastPromptAt < ACTIVE_WINDOW_MS
190
+ const running = prompted || (fresh && f.modelsTurn)
191
+ return {
192
+ id: ID(id),
193
+ title: (prompt || 'Untitled thread').slice(0, 120),
194
+ preview: prompt.slice(0, 240),
195
+ project: cwd ? path.basename(cwd) : 'unknown',
196
+ projectPath: cwd,
197
+ worktree: '',
198
+ cwd,
199
+ gitBranch: '',
200
+ model: 'antigravity',
201
+ effort: '',
202
+ createdAt: f.startedAt || typed?.firstPromptAt || born || mtime,
203
+ lastActivityAt,
204
+ lastFocusedAt: 0,
205
+ unread: fresh && !running && !f.errored,
206
+ running,
207
+ hasError: !running && f.errored,
208
+ starred: false,
209
+ routine: '',
210
+ prState: '',
211
+ archived: false,
212
+ sizeBytes: size,
213
+ source: 'cli',
214
+ canOpen: true,
215
+ ref: { sessionId: id, cwd },
216
+ }
217
+ }
218
+
219
+ for (const entry of entries) {
220
+ threads.push(thread({ id: entry.id, f: await facts(entry), typed: history.get(entry.id), mtime: entry.mtime, born: entry.born, size: entry.size }))
221
+ }
222
+ const seen = new Set(entries.map((e) => e.id))
223
+ for (const [id, typed] of history) {
224
+ if (seen.has(id) || !UUID.test(id) || now - typed.lastPromptAt >= ACTIVE_WINDOW_MS) continue
225
+ const f = { prompt: '', cwd: '', startedAt: 0, closed: false, errored: false, modelsTurn: false }
226
+ threads.push(thread({ id, f, typed, mtime: 0, born: 0, size: 0 }))
227
+ }
228
+ return threads
229
+ }
230
+
231
+ async function openThread(ref) {
232
+ const id = ref?.sessionId
233
+ if (typeof id !== 'string' || !UUID.test(id)) {
234
+ return { ok: false, error: 'No openable Antigravity session id on that thread' }
235
+ }
236
+ const cwd = typeof ref?.cwd === 'string' ? ref.cwd : ''
237
+ const url = `antigravity://resume?session=${id}`
238
+ const agy = await findExecutable('agy')
239
+ const legacy = agy ? null : (await findExecutable('antigravity')) || (await findExecutable('antigravity-cli'))
240
+ let command
241
+ if (agy) command = { argv: [agy, '--conversation', id], cwd }
242
+ else if (legacy) command = { argv: [legacy, '--resume', id], cwd }
243
+ return { ok: true, url, command, app: APP }
244
+ }
245
+
246
+ async function newSession(dir) {
247
+ const abs = String(dir || '').replace(/\\/g, '/')
248
+ if (!abs.startsWith('/')) return { ok: false, error: 'That folder is not somewhere Antigravity can open' }
249
+ const bin = (await findExecutable('agy')) || (await findExecutable('antigravity')) || (await findExecutable('antigravity-cli'))
250
+ const url = `antigravity://new?${new URLSearchParams({ path: abs })}`
251
+ let command
252
+ if (bin) {
253
+ command = { argv: [bin], cwd: abs }
254
+ }
255
+ return { ok: true, url, command, app: APP }
256
+ }
257
+
258
+ const detect = () => exists(BRAIN_DIR)
259
+
260
+ export default {
261
+ id: 'antigravity',
262
+ name: 'Antigravity CLI',
263
+ app: APP,
264
+ detect,
265
+ scanThreads,
266
+ openThread,
267
+ newSession,
268
+ paths: { ANTIGRAVITY_HOME, BRAIN_DIR },
269
+ }