@threadcrumbs/common 0.1.9 → 0.1.11
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/README.md +8 -5
- package/harnesses/README.md +214 -213
- package/harnesses/claude-code.mjs +579 -578
- package/lib/claude-transcript.mjs +17 -3
- package/lib/proc-start.mjs +157 -0
- package/license/store.mjs +192 -155
- package/package.json +1 -1
- package/telemetry/claude-code.mjs +3 -2
package/README.md
CHANGED
|
@@ -18,7 +18,7 @@ need it.
|
|
|
18
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
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
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 |
|
|
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. `portalUrl({ license, device, fetch, cloud })` asks the key service for Polar's customer portal, signed in, where a subscriber changes plan; it resolves to `{ url }` or `{ error, why }`, with `why` ready to show ([DECISIONS.md](DECISIONS.md#changing-plan-happens-in-polars-portal-opened-with-the-license)). Node only, so the Worker never loads it |
|
|
22
22
|
| `@threadcrumbs/common/ui/theme.css` | The Threadcrumbs look as CSS variables: the Quieter backgrounds, lines, and text levels, Threadcrumbs orange, the six status colors and their plates, `--warn`, added and removed, the chart and gauge colors, focus, type sizes, spacing, radii, and the default `--sans`, `--cond`, and `--mono` stacks. Every element keeps one x-height (`font-size-adjust: 0.51`) |
|
|
23
23
|
| `@threadcrumbs/common/ui/fonts.css` | `@font-face` rules for Inter, Inter Tight, and JetBrains Mono, Latin and Latin Extended, upright, with their `.woff2` files in `ui/fonts/`. A bundler copies the files into the app, so they load from its own origin. `tools/ui-fonts.mjs` writes both from the `@fontsource` packages |
|
|
24
24
|
| `@threadcrumbs/common/ui/status` | `STATUSES` (each status's key, name, color, and plate, in the order both apps pin), `STATUS_KEYS`, `URGENCY_ORDER` (for the status row and the waffle), `LIVE_STATUSES`, `statusOf`, the status icons as SVG in `LAMP_ICONS`, and `lampHTML(status)`. `ui/status.css` colors each `[data-st]` and animates the lamps, and reduced motion stops them |
|
|
@@ -31,7 +31,8 @@ need it.
|
|
|
31
31
|
| `@threadcrumbs/common/ui/totals` | The status lamps and the footer: `statusCounts`, `lampsHTML(counts, total, pressed)`, `footerTotals(threads)`, `footerHTML(totals, { short, bars })`, and the output-over-time log, `emptyOutputLog`, `recordOutput`, and `outputBars`. `ui/totals.css` styles them and builds on `ui/rows.css` |
|
|
32
32
|
| `@threadcrumbs/common/ui/pictures` | The footer's pictures, `FOOT_DRAW` (`drawPie`, `drawWaffle`, `drawCols`) at `FOOT_SIZE`, with `footerNote`; plan ticks, `planTicks` and `planSpec`; `waffleStates` and `binTimes`; and the SVG helpers Threadgauge's other pictures use (`fx`, `clamp`, `polar`, `wideBox`, `wideSVG`, and an `esc` for SVG text). `ui/pictures.css` colors them |
|
|
33
33
|
|
|
34
|
-
`launch/` is the only part of this package that starts processes,
|
|
34
|
+
`launch/` is the only part of this package that starts processes, apart from `lib/proc-start.mjs`
|
|
35
|
+
asking `ps` or `Get-Process` when a pid started, and `license/store.mjs` is the
|
|
35
36
|
only part that writes a file. The readers and `orgs/` import neither. `ui/` is plain functions
|
|
36
37
|
that return markup, plus CSS and fonts, for a page to draw; it touches no DOM and does no I/O.
|
|
37
38
|
|
|
@@ -132,15 +133,17 @@ pnpm test
|
|
|
132
133
|
`test/harness.test.mjs` fakes an install of each harness in a temp folder for the session readers,
|
|
133
134
|
and each `test/<harness>-telemetry.test.mjs` does the same for that harness's telemetry reader.
|
|
134
135
|
`test/claude-transcript.test.mjs` covers the shared Claude parser on its own.
|
|
136
|
+
`test/proc-start.test.mjs` covers telling a live Claude Code session from a recycled pid, against
|
|
137
|
+
fake lookups: a match, a recycled pid, a failed or slow lookup, the cache, and the batching.
|
|
135
138
|
`test/shared-ids.test.mjs` has both readers of each harness scan one fake install and checks they
|
|
136
139
|
give its sessions the same ids, which Threadgauge's Open depends on.
|
|
137
140
|
`test/orgs.test.mjs` covers each package manager's name, the git origin, and the combining rule.
|
|
138
141
|
`test/read-only.test.mjs` reads the package's own source and fails if anything under `harnesses/`,
|
|
139
|
-
`telemetry/`, `lib/`, or `orgs/` writes a file, opens a database for writing, starts or signals a process
|
|
140
|
-
or imports `launch/` or the license store. `test/launch.test.mjs` covers how `present()` refuses and names a missing app,
|
|
142
|
+
`telemetry/`, `lib/`, or `orgs/` writes a file, opens a database for writing, starts or signals a process
|
|
143
|
+
(apart from `lib/proc-start.mjs`'s start-time query), or imports `launch/` or the license store. `test/launch.test.mjs` covers how `present()` refuses and names a missing app,
|
|
141
144
|
starting nothing but a registry query on Windows. `test/license.test.mjs` covers signing and the unlock rule, and
|
|
142
145
|
`test/license-store.test.mjs` covers the shared file, including two apps starting at once on a
|
|
143
|
-
fresh machine and a clock set back.
|
|
146
|
+
fresh machine and a clock set back, and `portalUrl()` against a fake key service.
|
|
144
147
|
|
|
145
148
|
GitHub Actions runs the suite on Windows, macOS, and Linux for every push to `master` and every
|
|
146
149
|
pull request (`.github/workflows/test.yml`). The tests that only run on one OS, like the real
|
package/harnesses/README.md
CHANGED
|
@@ -1,213 +1,214 @@
|
|
|
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.
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
`%
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
`Claude
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
`~/.claude/
|
|
169
|
-
`
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
`
|
|
174
|
-
`
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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. The one process the readers share is
|
|
135
|
+
`lib/proc-start.mjs` asking the OS when a pid started, and DECISIONS.md says why. A harness's transcripts and records are
|
|
136
|
+
somebody's actual work, and the apps are viewers, not editors. If an adapter seems to need a
|
|
137
|
+
write, it does not — say so in an issue. Anything that has to write to a harness belongs to the
|
|
138
|
+
app that asks for it, outside this package.
|
|
139
|
+
- **Never run anything out of another application's bundle.** Not to read from it, not to
|
|
140
|
+
execute it. Only files under the user's own home directory. Opening a thread goes through a
|
|
141
|
+
URL the OS resolves, or a command the user already has on `PATH`.
|
|
142
|
+
- **Never block the scan.** It runs on a poll. Cache anything expensive against file size and
|
|
143
|
+
mtime. Claude Code's transcripts go through `transcriptReader` in
|
|
144
|
+
`../lib/claude-transcript.mjs`, which reads a new transcript's head and tail once and after
|
|
145
|
+
that only the lines appended since the last scan, so a 12MB transcript is never reparsed.
|
|
146
|
+
- **Read heads, not whole files.** `readHead` in `../lib/fsutil.mjs` pulls the first chunk and
|
|
147
|
+
drops a trailing partial line, so `JSON.parse` never sees half a record.
|
|
148
|
+
- **Expect malformed data.** A session being written *right now* is a normal thing to trip
|
|
149
|
+
over. Skip that record and move on; do not throw the pass away.
|
|
150
|
+
- **Never widen `id` collisions.** The colony keys its archive list and saved layout on `id`.
|
|
151
|
+
Two harnesses handing back the same id would merge two unrelated threads into one bot.
|
|
152
|
+
|
|
153
|
+
## Starting points
|
|
154
|
+
|
|
155
|
+
Verified on a real machine:
|
|
156
|
+
|
|
157
|
+
- **Claude Code** — desktop records in
|
|
158
|
+
`~/Library/Application Support/Claude/claude-code-sessions/<account>/<org>/local_*.json`
|
|
159
|
+
(`%APPDATA%\Claude\claude-code-sessions\…` on older Windows installs, and
|
|
160
|
+
`%LOCALAPPDATA%\Claude-Data\<install>\claude-code-sessions\…` on current ones, which leave a
|
|
161
|
+
`.user-data-left.<install>` marker in the old folder). The app folder is not always called
|
|
162
|
+
`Claude`: it is `Claude-3p` on some macOS installs, and each extra desktop profile gets its own
|
|
163
|
+
`Claude-<profile>` beside the first, so every `Claude*` folder holding a store is read. In the
|
|
164
|
+
same folder sits a
|
|
165
|
+
`deleted_<cliSessionId>` marker for every thread deleted in the app, holding the deletion time
|
|
166
|
+
in epoch ms — the record goes, the CLI transcript stays, and the marker is all that tells a
|
|
167
|
+
deleted thread from one started in a terminal, so the adapter reports it as `archived`; CLI
|
|
168
|
+
transcripts in `~/.claude/projects/<encoded-cwd>/<sessionId>.jsonl`; live processes in
|
|
169
|
+
`~/.claude/sessions/*.json`, whose `status` is `busy`, `waiting` (a prompt is open, named in
|
|
170
|
+
`waitingFor`) or `idle`, and whose `messagingSocketPath` is the address other sessions message
|
|
171
|
+
it at. A session that sends `SendMessage` with `notify_when_idle: true` is waiting on the
|
|
172
|
+
session at that address until a user record reading `[Cross-session idle notice] "<address>"`
|
|
173
|
+
arrives. `CLAUDE_CONFIG_DIR` (the CLI's own override for `~/.claude`) and
|
|
174
|
+
`THREADCRUMBS_CLAUDE_DESKTOP_DIR` (the session store) point both roots elsewhere, which is how
|
|
175
|
+
`test/harness.test.mjs` fakes an install. Implemented in `claude-code.mjs`.
|
|
176
|
+
- **Codex** — the desktop app, the VS Code extension and the CLI share one store. One row per
|
|
177
|
+
thread (title, cwd, branch, model, effort, archived) in `~/.codex/state_<n>.sqlite`, and
|
|
178
|
+
transcripts in `~/.codex/sessions/YYYY/MM/DD/rollout-<iso>-<uuid>.jsonl`, with records shaped
|
|
179
|
+
`{ timestamp, type, payload }`. They join on the session id, and the transcripts are the only
|
|
180
|
+
source for a session the database has not indexed yet. Renames are also in
|
|
181
|
+
`~/.codex/session_index.jsonl`. `CODEX_HOME` points it elsewhere. Implemented in `codex.mjs`.
|
|
182
|
+
|
|
183
|
+
For anything else, the fastest way in is usually to start a throwaway session in that harness
|
|
184
|
+
and watch which files change:
|
|
185
|
+
|
|
186
|
+
```bash
|
|
187
|
+
find ~ -maxdepth 4 -newermt '-2 minutes' -type f 2>/dev/null | grep -iv Library/Caches
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
## Checking your work
|
|
191
|
+
|
|
192
|
+
`pnpm test` in this repo runs `test/harness.test.mjs`, which fakes an install of each harness in a
|
|
193
|
+
temp folder. Add yours there. Then, against a real install:
|
|
194
|
+
|
|
195
|
+
1. `node --check harnesses/my-harness.mjs`
|
|
196
|
+
2. With Threadnauts running, `GET /api/harnesses` lists every registered harness and whether
|
|
197
|
+
`detect()` found it. If yours is missing or `detected: false`, stop here — nothing else
|
|
198
|
+
will work until it shows up:
|
|
199
|
+
|
|
200
|
+
```bash
|
|
201
|
+
curl -s localhost:5274/api/harnesses
|
|
202
|
+
```
|
|
203
|
+
3. Scan straight from node and look at the result — the number should match what the harness
|
|
204
|
+
itself reports, and no field should be `undefined`:
|
|
205
|
+
|
|
206
|
+
```bash
|
|
207
|
+
node -e 'import("./harnesses/my-harness.mjs").then(async m => {
|
|
208
|
+
const t = await m.default.scanThreads()
|
|
209
|
+
console.log(t.length, "threads"); console.dir(t[0], { depth: 4 })
|
|
210
|
+
})'
|
|
211
|
+
```
|
|
212
|
+
4. `pnpm dev` in Threadnauts, then confirm the bots appear on the right plots, the thread card
|
|
213
|
+
fills in, and Open does what you expect.
|
|
214
|
+
5. Archive one thread and check it shows as archived **in the harness's own UI**, not just here.
|