@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
package/LICENSE.md
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Threadcrumbs LLC. All rights reserved.
|
|
4
|
+
|
|
5
|
+
This software is proprietary. Threadcrumbs LLC grants you a free, non-exclusive,
|
|
6
|
+
non-transferable license to install and run it, as published by Threadcrumbs LLC, on computers
|
|
7
|
+
you own or control, for your personal or internal business use.
|
|
8
|
+
|
|
9
|
+
Paid features, such as Threadnauts Pro, are licensed to you only as your license key and
|
|
10
|
+
subscription allow, under the terms you agreed to when you bought them.
|
|
11
|
+
|
|
12
|
+
You may not:
|
|
13
|
+
|
|
14
|
+
- copy, modify, merge, publish, distribute, sublicense, rent, or sell this software, in whole
|
|
15
|
+
or in part;
|
|
16
|
+
- reverse engineer, decompile, or disassemble it, except where the law allows this despite
|
|
17
|
+
this restriction;
|
|
18
|
+
- get around its license checks, or share a license key or paid content beyond what your
|
|
19
|
+
subscription allows.
|
|
20
|
+
|
|
21
|
+
This license ends automatically if you break these terms. When it ends, you must stop using
|
|
22
|
+
the software and delete your copies of it.
|
|
23
|
+
|
|
24
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
|
|
25
|
+
BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND
|
|
26
|
+
NONINFRINGEMENT. TO THE FULLEST EXTENT THE LAW ALLOWS, IN NO EVENT SHALL THREADCRUMBS LLC BE
|
|
27
|
+
LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT, OR
|
|
28
|
+
OTHERWISE, ARISING FROM, OUT OF, OR IN CONNECTION WITH THE SOFTWARE OR ITS USE.
|
|
29
|
+
|
|
30
|
+
Any other use needs a separate written agreement with Threadcrumbs LLC
|
|
31
|
+
(hello@threadcrumbs.io).
|
|
32
|
+
|
|
33
|
+
Portions of this software are third-party code used under their own licenses, which are
|
|
34
|
+
reproduced in [NOTICES.md](NOTICES.md). Those licenses govern those portions.
|
package/NOTICES.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Notices
|
|
2
|
+
|
|
3
|
+
## Third-party
|
|
4
|
+
|
|
5
|
+
@threadcrumbs/common includes the following third-party software, used under its own license.
|
|
6
|
+
|
|
7
|
+
### Original source code
|
|
8
|
+
|
|
9
|
+
Portions of @threadcrumbs/common are derived from source code released under the MIT License.
|
|
10
|
+
|
|
11
|
+
```text
|
|
12
|
+
MIT License
|
|
13
|
+
|
|
14
|
+
Copyright (c) 2026 Jarren Rocks
|
|
15
|
+
|
|
16
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
17
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
18
|
+
in the Software without restriction, including without limitation the rights
|
|
19
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
20
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
21
|
+
furnished to do so, subject to the following conditions:
|
|
22
|
+
|
|
23
|
+
The above copyright notice and this permission notice shall be included in all
|
|
24
|
+
copies or substantial portions of the Software.
|
|
25
|
+
|
|
26
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
27
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
28
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
29
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
30
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
31
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
32
|
+
SOFTWARE.
|
|
33
|
+
```
|
package/README.md
CHANGED
|
@@ -1,3 +1,109 @@
|
|
|
1
|
-
#
|
|
2
|
-
|
|
3
|
-
|
|
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
|
+
`NOTICES.md`, 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). Portions are derived from source code released under
|
|
109
|
+
the MIT License, whose notice is in [NOTICES.md](NOTICES.md) and ships in every copy.
|
|
@@ -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
|
+
}
|